From f1c6beacf29092f66f470d7c7b765cd92291ae02 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 27 Jun 2025 12:38:06 +0800 Subject: [PATCH 001/226] use super-simple-queue --- server/index.js | 26 +- server/package-lock.json | 1614 ++++++++++------- server/package.json | 1 + .../SuperSimpleQueue/SuperSimpleQueue.js | 146 ++ .../SuperSimpleQueueHelper.js | 99 + server/service/networkService.js | 2 +- 6 files changed, 1185 insertions(+), 703 deletions(-) mode change 100755 => 100644 server/package-lock.json create mode 100644 server/service/SuperSimpleQueue/SuperSimpleQueue.js create mode 100644 server/service/SuperSimpleQueue/SuperSimpleQueueHelper.js diff --git a/server/index.js b/server/index.js index 4b74ef46c..a4fe8cd7f 100755 --- a/server/index.js +++ b/server/index.js @@ -53,6 +53,9 @@ import { Queue, Worker } from "bullmq"; import PulseQueue from "./service/PulseQueue/PulseQueue.js"; import PulseQueueHelper from "./service/PulseQueue/PulseQueueHelper.js"; +import SuperSimpleQueue from "./service/SuperSimpleQueue/SuperSimpleQueue.js"; +import SuperSimpleQueueHelper from "./service/SuperSimpleQueue/SuperSimpleQueueHelper.js"; + //Network service and dependencies import NetworkService from "./service/networkService.js"; import axios from "axios"; @@ -210,23 +213,38 @@ const startApp = async () => { // stringService, // }); - const pulseQueueHelper = new PulseQueueHelper({ + // const pulseQueueHelper = new PulseQueueHelper({ + // db, + // logger, + // networkService, + // statusService, + // notificationService, + // }); + // const pulseQueue = await PulseQueue.create({ + // appSettings, + // db, + // pulseQueueHelper, + // logger, + // }); + const superSimpleQueueHelper = new SuperSimpleQueueHelper({ db, logger, networkService, statusService, notificationService, }); - const pulseQueue = await PulseQueue.create({ + + const superSimpleQueue = await SuperSimpleQueue.create({ appSettings, db, - pulseQueueHelper, logger, + helper: superSimpleQueueHelper, }); // Register services // ServiceRegistry.register(JobQueue.SERVICE_NAME, jobQueue); - ServiceRegistry.register(JobQueue.SERVICE_NAME, pulseQueue); + // ServiceRegistry.register(JobQueue.SERVICE_NAME, pulseQueue); + ServiceRegistry.register(JobQueue.SERVICE_NAME, superSimpleQueue); ServiceRegistry.register(MongoDB.SERVICE_NAME, db); ServiceRegistry.register(SettingsService.SERVICE_NAME, settingsService); ServiceRegistry.register(EmailService.SERVICE_NAME, emailService); diff --git a/server/package-lock.json b/server/package-lock.json old mode 100755 new mode 100644 index 3fd675af3..427356e90 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -33,6 +33,7 @@ "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", + "super-simple-scheduler": "1.0.7", "swagger-ui-express": "5.0.1", "winston": "^3.13.0" }, @@ -51,36 +52,33 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/runtime": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", - "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -92,9 +90,9 @@ "license": "Apache-2.0" }, "node_modules/@bcoe/v8-coverage": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.1.tgz", - "integrity": "sha512-W+a0/JpU28AqH4IKtwUPcEUnUyXMDLALcn5/JLczGGT9fHE2sIby/xP/oQnx3nxkForzgzPy201RAKcB4xPAFQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", "engines": { @@ -122,9 +120,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", - "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", "license": "MIT", "optional": true, "dependencies": { @@ -132,10 +130,11 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -154,6 +153,7 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -166,17 +166,19 @@ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/config-array": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", - "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", + "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.5", + "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -184,34 +186,20 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "node_modules/@eslint/config-helpers": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", + "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "Apache-2.0", "engines": { - "node": "*" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", - "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -222,10 +210,11 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", - "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -244,22 +233,12 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -267,56 +246,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/js": { - "version": "9.20.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.20.0.tgz", - "integrity": "sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==", + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", + "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", - "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", - "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.10.0", + "@eslint/core": "^0.15.1", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@grpc/grpc-js": { - "version": "1.12.5", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.5.tgz", - "integrity": "sha512-d3iiHxdpg5+ZcJ6jnDSOT8Z0O0VMVGy34jAnYLUX8yd36b1qn8f1TwOA/Lc7TsOh03IkPJ38eGI5qD2EjNkoEA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", + "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.7.13", @@ -327,9 +310,9 @@ } }, "node_modules/@grpc/proto-loader": { - "version": "0.7.13", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.13.tgz", - "integrity": "sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==", + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", "license": "Apache-2.0", "dependencies": { "lodash.camelcase": "^4.3.0", @@ -364,6 +347,7 @@ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=18.18.0" } @@ -373,6 +357,7 @@ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" @@ -386,6 +371,7 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=18.18" }, @@ -399,6 +385,7 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -408,10 +395,11 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", - "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=18.18" }, @@ -853,9 +841,9 @@ } }, "node_modules/@mongodb-js/saslprep": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", - "integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz", + "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==", "license": "MIT", "dependencies": { "sparse-bitfield": "^3.0.3" @@ -1027,23 +1015,6 @@ "mongodb": "^6.5.0" } }, - "node_modules/@pulsecron/pulse/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -1131,10 +1102,11 @@ } }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -1147,15 +1119,16 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.4.tgz", - "integrity": "sha512-99l6wv4HEzBQhvaU/UGoeBoCK61SCROQaCCGyQSgX2tEQ3rKkNZ2S7CEWnS/4s1LV+8ODdK21UeyR1fHP2mXug==", + "version": "24.0.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.4.tgz", + "integrity": "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA==", "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~7.8.0" } }, "node_modules/@types/triple-beam": { @@ -1192,11 +1165,21 @@ "node": ">= 0.6" } }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -1209,20 +1192,18 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "license": "MIT", - "dependencies": { - "debug": "4" - }, "engines": { - "node": ">= 6.0.0" + "node": ">= 14" } }, "node_modules/ajv": { @@ -1230,6 +1211,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1340,9 +1322,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", - "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -1463,12 +1445,14 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/braces": { @@ -1491,9 +1475,9 @@ "license": "ISC" }, "node_modules/browserslist": { - "version": "4.24.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", - "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", "funding": [ { "type": "opencollective", @@ -1510,10 +1494,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -1523,9 +1507,9 @@ } }, "node_modules/bson": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.1.tgz", - "integrity": "sha512-P92xmHDQjSKPLHqFxefqMxASNq/aWJMEZugpCjf+AF/pgcUpMMQCg7t7+ewko0/u8AapvF3luf/FoehddEK+sA==", + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", "license": "Apache-2.0", "engines": { "node": ">=16.20.1" @@ -1646,9 +1630,9 @@ } }, "node_modules/call-bind-apply-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", - "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1659,13 +1643,13 @@ } }, "node_modules/call-bound": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", - "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "get-intrinsic": "^1.2.6" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -1709,9 +1693,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001690", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz", - "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==", + "version": "1.0.30001726", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", + "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", "funding": [ { "type": "opencollective", @@ -1834,6 +1818,24 @@ "fsevents": "~2.3.2" } }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -2063,15 +2065,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2318,13 +2311,13 @@ } }, "node_modules/cssnano": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.0.6.tgz", - "integrity": "sha512-54woqx8SCbp8HwvNZYn68ZFAepuouZW4lTwiMVnBErM3VkO7/Sd4oTOt3Zz3bPx3kxQ36aISppyXj2Md4lg8bw==", + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.0.7.tgz", + "integrity": "sha512-evKu7yiDIF7oS+EIpwFlMF730ijRyLFaM2o5cTxRGJR9OKHKkc+qP443ZEVR9kZG0syaAJJCPJyfv5pbrxlSng==", "license": "MIT", "dependencies": { - "cssnano-preset-default": "^7.0.6", - "lilconfig": "^3.1.2" + "cssnano-preset-default": "^7.0.7", + "lilconfig": "^3.1.3" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" @@ -2334,63 +2327,63 @@ "url": "https://opencollective.com/cssnano" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/cssnano-preset-default": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.6.tgz", - "integrity": "sha512-ZzrgYupYxEvdGGuqL+JKOY70s7+saoNlHSCK/OGn1vB2pQK8KSET8jvenzItcY+kA7NoWvfbb/YhlzuzNKjOhQ==", + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.7.tgz", + "integrity": "sha512-jW6CG/7PNB6MufOrlovs1TvBTEVmhY45yz+bd0h6nw3h6d+1e+/TX+0fflZ+LzvZombbT5f+KC063w9VoHeHow==", "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", + "browserslist": "^4.24.5", "css-declaration-sorter": "^7.2.0", - "cssnano-utils": "^5.0.0", - "postcss-calc": "^10.0.2", - "postcss-colormin": "^7.0.2", - "postcss-convert-values": "^7.0.4", - "postcss-discard-comments": "^7.0.3", - "postcss-discard-duplicates": "^7.0.1", - "postcss-discard-empty": "^7.0.0", - "postcss-discard-overridden": "^7.0.0", - "postcss-merge-longhand": "^7.0.4", - "postcss-merge-rules": "^7.0.4", - "postcss-minify-font-values": "^7.0.0", - "postcss-minify-gradients": "^7.0.0", - "postcss-minify-params": "^7.0.2", - "postcss-minify-selectors": "^7.0.4", - "postcss-normalize-charset": "^7.0.0", - "postcss-normalize-display-values": "^7.0.0", - "postcss-normalize-positions": "^7.0.0", - "postcss-normalize-repeat-style": "^7.0.0", - "postcss-normalize-string": "^7.0.0", - "postcss-normalize-timing-functions": "^7.0.0", - "postcss-normalize-unicode": "^7.0.2", - "postcss-normalize-url": "^7.0.0", - "postcss-normalize-whitespace": "^7.0.0", - "postcss-ordered-values": "^7.0.1", - "postcss-reduce-initial": "^7.0.2", - "postcss-reduce-transforms": "^7.0.0", - "postcss-svgo": "^7.0.1", - "postcss-unique-selectors": "^7.0.3" + "cssnano-utils": "^5.0.1", + "postcss-calc": "^10.1.1", + "postcss-colormin": "^7.0.3", + "postcss-convert-values": "^7.0.5", + "postcss-discard-comments": "^7.0.4", + "postcss-discard-duplicates": "^7.0.2", + "postcss-discard-empty": "^7.0.1", + "postcss-discard-overridden": "^7.0.1", + "postcss-merge-longhand": "^7.0.5", + "postcss-merge-rules": "^7.0.5", + "postcss-minify-font-values": "^7.0.1", + "postcss-minify-gradients": "^7.0.1", + "postcss-minify-params": "^7.0.3", + "postcss-minify-selectors": "^7.0.5", + "postcss-normalize-charset": "^7.0.1", + "postcss-normalize-display-values": "^7.0.1", + "postcss-normalize-positions": "^7.0.1", + "postcss-normalize-repeat-style": "^7.0.1", + "postcss-normalize-string": "^7.0.1", + "postcss-normalize-timing-functions": "^7.0.1", + "postcss-normalize-unicode": "^7.0.3", + "postcss-normalize-url": "^7.0.1", + "postcss-normalize-whitespace": "^7.0.1", + "postcss-ordered-values": "^7.0.2", + "postcss-reduce-initial": "^7.0.3", + "postcss-reduce-transforms": "^7.0.1", + "postcss-svgo": "^7.0.2", + "postcss-unique-selectors": "^7.0.4" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/cssnano-utils": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.0.tgz", - "integrity": "sha512-Uij0Xdxc24L6SirFr25MlwC2rCFX6scyUmuKpzI+JQ7cyqDEwD42fJ0xfB3yLfOnRDU5LKGgjQ9FA6LYh76GWQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.1.tgz", + "integrity": "sha512-ZIP71eQgG9JwjVZsTPSqhc6GHgEr53uJ7tK5///VfyWj6Xp2DBmixWHqJgPno+PqATzn48pL42ww9x5SSGmhZg==", "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/csso": { @@ -2426,6 +2419,15 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "license": "CC0-1.0" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/date.js": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/date.js/-/date.js-0.3.3.tgz", @@ -2451,9 +2453,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2494,7 +2496,8 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/delayed-stream": { "version": "1.0.0", @@ -2534,9 +2537,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2646,9 +2649,9 @@ } }, "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", @@ -2660,9 +2663,9 @@ } }, "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -2707,9 +2710,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.74", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.74.tgz", - "integrity": "sha512-ck3//9RC+6oss/1Bh9tiAVFy5vfSKbRHAFh7Z3/eTRkEqJeWgymloShB17Vg3Z4nmDNp35vAd1BZ6CMW4Wt6Iw==", + "version": "1.5.175", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.175.tgz", + "integrity": "sha512-Nqpef9mOVo7pZfl9NIUhj7tgtRTsMzCzRTJDP1ccim4Wb4YHOz3Le87uxeZq68OCNwau2iQ/X7UwdAZ3ReOkmg==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -2734,9 +2737,9 @@ } }, "node_modules/encoding-sniffer": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", - "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", "license": "MIT", "dependencies": { "iconv-lite": "^0.6.3", @@ -2759,9 +2762,9 @@ } }, "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -2816,9 +2819,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2827,6 +2830,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2868,22 +2886,23 @@ } }, "node_modules/eslint": { - "version": "9.20.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.1.tgz", - "integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==", + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", + "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.11.0", - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.20.0", - "@eslint/plugin-kit": "^0.2.5", + "@eslint/config-array": "^0.20.1", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.29.0", + "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.1", + "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", @@ -2891,9 +2910,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -2932,6 +2951,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-10.5.0.tgz", "integrity": "sha512-F2ALmQVPT1GoP27O1JTZGrV9Pqg8k79OeIuvw63UxMtQKREZtmkK1NFgkZQ2TW7L2JSSFKHFPTtHu5z8R9QNRw==", "dev": true, + "license": "MIT", "dependencies": { "eslint-utils": "^3.0.0", "globals": "^13.24.0", @@ -2949,6 +2969,7 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -2960,10 +2981,11 @@ } }, "node_modules/eslint-scope": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", - "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -2980,6 +3002,7 @@ "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^2.0.0" }, @@ -2998,15 +3021,17 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=10" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -3014,56 +3039,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/@eslint/core": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.11.0.tgz", - "integrity": "sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/esm": { "version": "3.2.25", "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", @@ -3075,14 +3050,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3096,6 +3072,7 @@ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -3108,6 +3085,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -3120,6 +3098,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -3129,6 +3108,7 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } @@ -3213,19 +3193,22 @@ "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 + "dev": true, + "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fecha": { "version": "4.2.3", @@ -3233,11 +3216,35 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, + "license": "MIT", "dependencies": { "flat-cache": "^4.0.0" }, @@ -3322,6 +3329,7 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, + "license": "MIT", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -3331,10 +3339,11 @@ } }, "node_modules/flatted": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", - "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", - "dev": true + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" }, "node_modules/fn.name": { "version": "1.1.0", @@ -3363,12 +3372,12 @@ } }, "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -3379,19 +3388,33 @@ } }, "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3440,18 +3463,19 @@ } }, "node_modules/gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", + "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" }, "engines": { - "node": ">=12" + "node": ">=14" } }, "node_modules/get-caller-file": { @@ -3464,21 +3488,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", - "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "dunder-proto": "^1.0.0", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", - "math-intrinsics": "^1.0.0" + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -3487,6 +3511,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -3508,15 +3545,40 @@ } }, "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { - "is-glob": "^4.0.1" + "is-glob": "^4.0.3" }, "engines": { - "node": ">= 6" + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/globals": { @@ -3587,6 +3649,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3610,9 +3687,9 @@ } }, "node_modules/helmet": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.0.0.tgz", - "integrity": "sha512-VyusHLEIIO5mjQPUI1wpOAEu+wl6Q0998jzTxqUYGE45xCIcAxy3MsbEK/yyJUJ3ADeMoB6MornPH6GMWAf+Pw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -3626,19 +3703,18 @@ "license": "MIT" }, "node_modules/htmlnano": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/htmlnano/-/htmlnano-2.1.1.tgz", - "integrity": "sha512-kAERyg/LuNZYmdqgCdYvugyLWNFAm8MWXpQMz1pLpetmCbFwoMxvkSoaAMlFrOC4OKTWI4KlZGT/RsNxg4ghOw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/htmlnano/-/htmlnano-2.1.2.tgz", + "integrity": "sha512-8Fst+0bhAfU362S6oHVb4wtJj/UYEFr0qiCLAEi8zioqmp1JYBQx5crZAADlFVX0Ly/6s/IQz6G7PL9/hgoJaQ==", "license": "MIT", "dependencies": { "cosmiconfig": "^9.0.0", - "posthtml": "^0.16.5", - "timsort": "^0.3.0" + "posthtml": "^0.16.5" }, "peerDependencies": { "cssnano": "^7.0.0", "postcss": "^8.3.11", - "purgecss": "^6.0.0", + "purgecss": "^7.0.2", "relateurl": "^0.2.7", "srcset": "5.0.1", "svgo": "^3.0.2", @@ -3708,16 +3784,16 @@ } }, "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "dependencies": { - "agent-base": "6", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/human-interval": { @@ -3766,6 +3842,7 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } @@ -3778,9 +3855,9 @@ "license": "ISC" }, "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -3798,6 +3875,7 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.19" } @@ -3809,9 +3887,9 @@ "license": "ISC" }, "node_modules/ioredis": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.5.0.tgz", - "integrity": "sha512-7CutT89g23FfSa8MDoIFs2GYYa0PaNiW/OrT+nRyjRXHDZd17HmIgy+reOQ/yhh72NznNjGuS8kbCAcA4Ro4mw==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", + "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", "license": "MIT", "dependencies": { "@ioredis/commands": "^1.1.1", @@ -3952,13 +4030,31 @@ "license": "ISC" }, "node_modules/isomorphic-unfetch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz", - "integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-4.0.2.tgz", + "integrity": "sha512-1Yd+CF/7al18/N2BDbsLBcp6RO3tucSW+jcLq24dqdX5MNbCNTw1z4BsGsp4zNmjr/Izm2cs/cEqZPp4kvWSCA==", "license": "MIT", "dependencies": { - "node-fetch": "^2.6.1", - "unfetch": "^4.2.0" + "node-fetch": "^3.2.0", + "unfetch": "^5.0.0" + } + }, + "node_modules/isomorphic-unfetch/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, "node_modules/istanbul-lib-coverage": { @@ -3986,22 +4082,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-report/node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/istanbul-reports": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", @@ -4075,7 +4155,8 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -4087,13 +4168,15 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jsonwebtoken": { "version": "9.0.2", @@ -4118,13 +4201,14 @@ } }, "node_modules/juice": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/juice/-/juice-11.0.0.tgz", - "integrity": "sha512-sGF8hPz9/Wg+YXbaNDqc1Iuoaw+J/P9lBHNQKXAGc9pPNjCd4fyPai0Zxj7MRtdjMr0lcgk5PjEIkP2b8R9F3w==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/juice/-/juice-11.0.1.tgz", + "integrity": "sha512-R3KLud4l/sN9AMmFZs0QY7cugGSiKvPhGyIsufCV5nJ0MjSlngUE7k80TmFeK9I62wOXrjWBtYA1knVs2OkF8w==", "license": "MIT", "dependencies": { "cheerio": "^1.0.0", "commander": "^12.1.0", + "entities": "^4.5.0", "mensch": "^0.3.4", "slick": "^1.12.2", "web-resource-inliner": "^7.0.0" @@ -4137,21 +4221,21 @@ } }, "node_modules/juice/node_modules/cheerio": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", - "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.0.tgz", + "integrity": "sha512-+0hMx9eYhJvWbgpKV9hN7jg0JcwydpopZE4hgi+KvQtByZXPp04NiCWU0LzcAbP63abZckIHkTQaXVF52mX3xQ==", "license": "MIT", "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", - "domutils": "^3.1.0", + "domutils": "^3.2.2", "encoding-sniffer": "^0.2.0", - "htmlparser2": "^9.1.0", - "parse5": "^7.1.2", - "parse5-htmlparser2-tree-adapter": "^7.0.0", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", - "undici": "^6.19.5", + "undici": "^7.10.0", "whatwg-mimetype": "^4.0.0" }, "engines": { @@ -4162,9 +4246,9 @@ } }, "node_modules/juice/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -4176,8 +4260,20 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/juice/node_modules/htmlparser2/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/just-extend": { @@ -4188,12 +4284,12 @@ "license": "MIT" }, "node_modules/jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } @@ -4222,6 +4318,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, + "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -4237,6 +4334,7 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -4301,6 +4399,7 @@ "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", "dev": true, "license": "MIT" }, @@ -4356,7 +4455,8 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.once": { "version": "4.1.1", @@ -4405,15 +4505,15 @@ } }, "node_modules/long": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", - "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz", + "integrity": "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==", "dev": true, "license": "MIT" }, @@ -4424,25 +4524,41 @@ "license": "ISC" }, "node_modules/luxon": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", - "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", + "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", "license": "MIT", "engines": { "node": ">=12" } }, "node_modules/mailersend": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/mailersend/-/mailersend-2.3.0.tgz", - "integrity": "sha512-pe498Ry7VaAb+oqcYqmPw1V7FlECG/mcqahQ3SiK54en4ZkyRwjyxoQwA9VU4s3npB+I44LlQGUudObZQe4/jA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mailersend/-/mailersend-2.6.0.tgz", + "integrity": "sha512-YX2Gyc6Wyw4Q4IsJ4np2Reof8nFWQ2OP/yXZTZcGCz4B7B1BOAYs71Kjb1uNRTTfDChP7rzzWBoOaX6iNlvPAg==", "license": "MIT", "dependencies": { - "gaxios": "^5.0.1", - "isomorphic-unfetch": "^3.1.0", + "gaxios": "^6.0.0", + "isomorphic-unfetch": "^4.0.0", "qs": "^6.11.0" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4510,9 +4626,9 @@ } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -4530,19 +4646,26 @@ "node": ">= 0.6" } }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "*" } }, "node_modules/minimist": { @@ -4643,6 +4766,30 @@ "mjml-cli": "bin/mjml" } }, + "node_modules/mjml-cli/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mjml-cli/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mjml-column": { "version": "5.0.0-alpha.6", "resolved": "https://registry.npmjs.org/mjml-column/-/mjml-column-5.0.0-alpha.6.tgz", @@ -5022,6 +5169,16 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/mocha/node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", @@ -5073,13 +5230,13 @@ } }, "node_modules/mongodb": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.13.0.tgz", - "integrity": "sha512-KeESYR5TEaFxOuwRqkOm3XOsMqCSkdeDMjaW5u2nuKfX7rqaofp7JQGoi7sVqQcNJTKuveNbzZtWMstb8ABP6Q==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz", + "integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==", "license": "Apache-2.0", "dependencies": { "@mongodb-js/saslprep": "^1.1.9", - "bson": "^6.10.1", + "bson": "^6.10.4", "mongodb-connection-string-url": "^3.0.0" }, "engines": { @@ -5119,24 +5276,24 @@ } }, "node_modules/mongodb-connection-string-url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz", - "integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", "license": "Apache-2.0", "dependencies": { "@types/whatwg-url": "^11.0.2", - "whatwg-url": "^13.0.0" + "whatwg-url": "^14.1.0 || ^13.0.0" } }, "node_modules/mongoose": { - "version": "8.10.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.10.1.tgz", - "integrity": "sha512-5beTeBZnJNndRXU9rxPol0JmTWZMAtgkPbooROkGilswvrZALDERY4cJrGZmgGwDS9dl0mxiB7si+Mv9Yms2fg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.0.tgz", + "integrity": "sha512-gLuAZsbwY0PHjrvfuXvUkUq9tXjyAjN3ioXph5Y6Seu7/Uo8xJaM+rrMbL/x34K4T3UTgtXRyfoq1YU16qKyIw==", "license": "MIT", "dependencies": { - "bson": "^6.10.1", + "bson": "^6.10.4", "kareem": "2.6.3", - "mongodb": "~6.13.0", + "mongodb": "~6.17.0", "mpath": "0.9.0", "mquery": "5.0.0", "ms": "2.1.3", @@ -5178,9 +5335,9 @@ "license": "MIT" }, "node_modules/msgpackr": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.2.tgz", - "integrity": "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==", + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.4.tgz", + "integrity": "sha512-uaff7RG9VIC4jacFW9xzL3jc0iM32DNHe4jYVycBcjUePT/Klnfj7pqtWJt9khvDFizmjN2TlYniYmSS2LIaZg==", "license": "MIT", "optionalDependencies": { "msgpackr-extract": "^3.0.2" @@ -5209,9 +5366,10 @@ } }, "node_modules/multer": { - "version": "1.4.5-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", - "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -5227,16 +5385,16 @@ } }, "node_modules/nan": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", - "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", + "version": "2.22.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", + "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", "license": "MIT", "optional": true }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -5255,12 +5413,13 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -5302,6 +5461,26 @@ "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -5366,9 +5545,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz", - "integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==", + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -5403,17 +5582,6 @@ "url": "https://opencollective.com/nodemon" } }, - "node_modules/nodemon/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/nodemon/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -5424,19 +5592,6 @@ "node": ">=4" } }, - "node_modules/nodemon/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/nodemon/node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -5487,9 +5642,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", - "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -5542,6 +5697,7 @@ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, + "license": "MIT", "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -5593,9 +5749,9 @@ "license": "BlueOak-1.0.0" }, "node_modules/papaparse": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.2.tgz", - "integrity": "sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", "license": "MIT" }, "node_modules/parent-module": { @@ -5629,12 +5785,12 @@ } }, "node_modules/parse5": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", - "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "license": "MIT", "dependencies": { - "entities": "^4.5.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -5665,6 +5821,18 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5716,9 +5884,9 @@ "license": "MIT" }, "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", "engines": { @@ -5753,9 +5921,9 @@ } }, "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -5772,7 +5940,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5781,12 +5949,12 @@ } }, "node_modules/postcss-calc": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.0.2.tgz", - "integrity": "sha512-DT/Wwm6fCKgpYVI7ZEWuPJ4az8hiEHtCUeYjZXqU7Ou4QqYh1Df2yCQ7Ca6N7xqKPFkxN3fhf+u9KSoOCJNAjg==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.1.1.tgz", + "integrity": "sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==", "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.1.2", + "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -5797,12 +5965,12 @@ } }, "node_modules/postcss-colormin": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.2.tgz", - "integrity": "sha512-YntRXNngcvEvDbEjTdRWGU606eZvB5prmHG4BF0yLmVpamXbpsRJzevyy6MZVyuecgzI2AWAlvFi8DAeCqwpvA==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.3.tgz", + "integrity": "sha512-xZxQcSyIVZbSsl1vjoqZAcMYYdnJsIyG8OvqShuuqf12S88qQboxxEy0ohNCOLwVPXTU+hFHvJPACRL2B5ohTA==", "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", + "browserslist": "^4.24.5", "caniuse-api": "^3.0.0", "colord": "^2.9.3", "postcss-value-parser": "^4.2.0" @@ -5811,114 +5979,114 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-convert-values": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.4.tgz", - "integrity": "sha512-e2LSXPqEHVW6aoGbjV9RsSSNDO3A0rZLCBxN24zvxF25WknMPpX8Dm9UxxThyEbaytzggRuZxaGXqaOhxQ514Q==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.5.tgz", + "integrity": "sha512-0VFhH8nElpIs3uXKnVtotDJJNX0OGYSZmdt4XfSfvOMrFw1jKfpwpZxfC4iN73CTM/MWakDEmsHQXkISYj4BXw==", "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", + "browserslist": "^4.24.5", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-discard-comments": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.3.tgz", - "integrity": "sha512-q6fjd4WU4afNhWOA2WltHgCbkRhZPgQe7cXF74fuVB/ge4QbM9HEaOIzGSiMvM+g/cOsNAUGdf2JDzqA2F8iLA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.4.tgz", + "integrity": "sha512-6tCUoql/ipWwKtVP/xYiFf1U9QgJ0PUvxN7pTcsQ8Ns3Fnwq1pU5D5s1MhT/XySeLq6GXNvn37U46Ded0TckWg==", "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.1.2" + "postcss-selector-parser": "^7.1.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-discard-duplicates": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.1.tgz", - "integrity": "sha512-oZA+v8Jkpu1ct/xbbrntHRsfLGuzoP+cpt0nJe5ED2FQF8n8bJtn7Bo28jSmBYwqgqnqkuSXJfSUEE7if4nClQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.2.tgz", + "integrity": "sha512-eTonaQvPZ/3i1ASDHOKkYwAybiM45zFIc7KXils4mQmHLqIswXD9XNOKEVxtTFnsmwYzF66u4LMgSr0abDlh5w==", "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-discard-empty": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.0.tgz", - "integrity": "sha512-e+QzoReTZ8IAwhnSdp/++7gBZ/F+nBq9y6PomfwORfP7q9nBpK5AMP64kOt0bA+lShBFbBDcgpJ3X4etHg4lzA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.1.tgz", + "integrity": "sha512-cFrJKZvcg/uxB6Ijr4l6qmn3pXQBna9zyrPC+sK0zjbkDUZew+6xDltSF7OeB7rAtzaaMVYSdbod+sZOCWnMOg==", "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-discard-overridden": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.0.tgz", - "integrity": "sha512-GmNAzx88u3k2+sBTZrJSDauR0ccpE24omTQCVmaTTZFz1du6AasspjaUPMJ2ud4RslZpoFKyf+6MSPETLojc6w==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.1.tgz", + "integrity": "sha512-7c3MMjjSZ/qYrx3uc1940GSOzN1Iqjtlqe8uoSg+qdVPYyRb0TILSqqmtlSFuE4mTDECwsm397Ya7iXGzfF7lg==", "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-merge-longhand": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.4.tgz", - "integrity": "sha512-zer1KoZA54Q8RVHKOY5vMke0cCdNxMP3KBfDerjH/BYHh4nCIh+1Yy0t1pAEQF18ac/4z3OFclO+ZVH8azjR4A==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.5.tgz", + "integrity": "sha512-Kpu5v4Ys6QI59FxmxtNB/iHUVDn9Y9sYw66D6+SZoIk4QTz1prC4aYkhIESu+ieG1iylod1f8MILMs1Em3mmIw==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0", - "stylehacks": "^7.0.4" + "stylehacks": "^7.0.5" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-merge-rules": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.4.tgz", - "integrity": "sha512-ZsaamiMVu7uBYsIdGtKJ64PkcQt6Pcpep/uO90EpLS3dxJi6OXamIobTYcImyXGoW0Wpugh7DSD3XzxZS9JCPg==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.5.tgz", + "integrity": "sha512-ZonhuSwEaWA3+xYbOdJoEReKIBs5eDiBVLAGpYZpNFPzXZcEE5VKR7/qBEQvTZpiwjqhhqEQ+ax5O3VShBj9Wg==", "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", + "browserslist": "^4.24.5", "caniuse-api": "^3.0.0", - "cssnano-utils": "^5.0.0", - "postcss-selector-parser": "^6.1.2" + "cssnano-utils": "^5.0.1", + "postcss-selector-parser": "^7.1.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-minify-font-values": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.0.tgz", - "integrity": "sha512-2ckkZtgT0zG8SMc5aoNwtm5234eUx1GGFJKf2b1bSp8UflqaeFzR50lid4PfqVI9NtGqJ2J4Y7fwvnP/u1cQog==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.1.tgz", + "integrity": "sha512-2m1uiuJeTplll+tq4ENOQSzB8LRnSUChBv7oSyFLsJRtUgAAJGP6LLz0/8lkinTgxrmJSPOEhgY1bMXOQ4ZXhQ==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -5927,75 +6095,75 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-minify-gradients": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.0.tgz", - "integrity": "sha512-pdUIIdj/C93ryCHew0UgBnL2DtUS3hfFa5XtERrs4x+hmpMYGhbzo6l/Ir5de41O0GaKVpK1ZbDNXSY6GkXvtg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.1.tgz", + "integrity": "sha512-X9JjaysZJwlqNkJbUDgOclyG3jZEpAMOfof6PUZjPnPrePnPG62pS17CjdM32uT1Uq1jFvNSff9l7kNbmMSL2A==", "license": "MIT", "dependencies": { "colord": "^2.9.3", - "cssnano-utils": "^5.0.0", + "cssnano-utils": "^5.0.1", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-minify-params": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.2.tgz", - "integrity": "sha512-nyqVLu4MFl9df32zTsdcLqCFfE/z2+f8GE1KHPxWOAmegSo6lpV2GNy5XQvrzwbLmiU7d+fYay4cwto1oNdAaQ==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.3.tgz", + "integrity": "sha512-vUKV2+f5mtjewYieanLX0xemxIp1t0W0H/D11u+kQV/MWdygOO7xPMkbK+r9P6Lhms8MgzKARF/g5OPXhb8tgg==", "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", - "cssnano-utils": "^5.0.0", + "browserslist": "^4.24.5", + "cssnano-utils": "^5.0.1", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-minify-selectors": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.0.4.tgz", - "integrity": "sha512-JG55VADcNb4xFCf75hXkzc1rNeURhlo7ugf6JjiiKRfMsKlDzN9CXHZDyiG6x/zGchpjQS+UAgb1d4nqXqOpmA==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.0.5.tgz", + "integrity": "sha512-x2/IvofHcdIrAm9Q+p06ZD1h6FPcQ32WtCRVodJLDR+WMn8EVHI1kvLxZuGKz/9EY5nAmI6lIQIrpo4tBy5+ug==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", - "postcss-selector-parser": "^6.1.2" + "postcss-selector-parser": "^7.1.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-normalize-charset": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.0.tgz", - "integrity": "sha512-ABisNUXMeZeDNzCQxPxBCkXexvBrUHV+p7/BXOY+ulxkcjUZO0cp8ekGBwvIh2LbCwnWbyMPNJVtBSdyhM2zYQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.1.tgz", + "integrity": "sha512-sn413ofhSQHlZFae//m9FTOfkmiZ+YQXsbosqOWRiVQncU2BA3daX3n0VF3cG6rGLSFVc5Di/yns0dFfh8NFgQ==", "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-normalize-display-values": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.0.tgz", - "integrity": "sha512-lnFZzNPeDf5uGMPYgGOw7v0BfB45+irSRz9gHQStdkkhiM0gTfvWkWB5BMxpn0OqgOQuZG/mRlZyJxp0EImr2Q==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.1.tgz", + "integrity": "sha512-E5nnB26XjSYz/mGITm6JgiDpAbVuAkzXwLzRZtts19jHDUBFxZ0BkXAehy0uimrOjYJbocby4FVswA/5noOxrQ==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -6004,13 +6172,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-normalize-positions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.0.tgz", - "integrity": "sha512-I0yt8wX529UKIGs2y/9Ybs2CelSvItfmvg/DBIjTnoUSrPxSV7Z0yZ8ShSVtKNaV/wAY+m7bgtyVQLhB00A1NQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.1.tgz", + "integrity": "sha512-pB/SzrIP2l50ZIYu+yQZyMNmnAcwyYb9R1fVWPRxm4zcUFCY2ign7rcntGFuMXDdd9L2pPNUgoODDk91PzRZuQ==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -6019,13 +6187,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-normalize-repeat-style": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.0.tgz", - "integrity": "sha512-o3uSGYH+2q30ieM3ppu9GTjSXIzOrRdCUn8UOMGNw7Af61bmurHTWI87hRybrP6xDHvOe5WlAj3XzN6vEO8jLw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.1.tgz", + "integrity": "sha512-NsSQJ8zj8TIDiF0ig44Byo3Jk9e4gNt9x2VIlJudnQQ5DhWAHJPF4Tr1ITwyHio2BUi/I6Iv0HRO7beHYOloYQ==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -6034,13 +6202,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-normalize-string": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.0.tgz", - "integrity": "sha512-w/qzL212DFVOpMy3UGyxrND+Kb0fvCiBBujiaONIihq7VvtC7bswjWgKQU/w4VcRyDD8gpfqUiBQ4DUOwEJ6Qg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.1.tgz", + "integrity": "sha512-QByrI7hAhsoze992kpbMlJSbZ8FuCEc1OT9EFbZ6HldXNpsdpZr+YXC5di3UEv0+jeZlHbZcoCADgb7a+lPmmQ==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -6049,13 +6217,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-normalize-timing-functions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.0.tgz", - "integrity": "sha512-tNgw3YV0LYoRwg43N3lTe3AEWZ66W7Dh7lVEpJbHoKOuHc1sLrzMLMFjP8SNULHaykzsonUEDbKedv8C+7ej6g==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.1.tgz", + "integrity": "sha512-bHifyuuSNdKKsnNJ0s8fmfLMlvsQwYVxIoUBnowIVl2ZAdrkYQNGVB4RxjfpvkMjipqvbz0u7feBZybkl/6NJg==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -6064,29 +6232,29 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-normalize-unicode": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.2.tgz", - "integrity": "sha512-ztisabK5C/+ZWBdYC+Y9JCkp3M9qBv/XFvDtSw0d/XwfT3UaKeW/YTm/MD/QrPNxuecia46vkfEhewjwcYFjkg==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.3.tgz", + "integrity": "sha512-EcoA29LvG3F+EpOh03iqu+tJY3uYYKzArqKJHxDhUYLa2u58aqGq16K6/AOsXD9yqLN8O6y9mmePKN5cx6krOw==", "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", + "browserslist": "^4.24.5", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-normalize-url": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.0.tgz", - "integrity": "sha512-+d7+PpE+jyPX1hDQZYG+NaFD+Nd2ris6r8fPTBAjE8z/U41n/bib3vze8x7rKs5H1uEw5ppe9IojewouHk0klQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.1.tgz", + "integrity": "sha512-sUcD2cWtyK1AOL/82Fwy1aIVm/wwj5SdZkgZ3QiUzSzQQofrbq15jWJ3BA7Z+yVRwamCjJgZJN0I9IS7c6tgeQ==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -6095,13 +6263,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-normalize-whitespace": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.0.tgz", - "integrity": "sha512-37/toN4wwZErqohedXYqWgvcHUGlT8O/m2jVkAfAe9Bd4MzRqlBmXrJRePH0e9Wgnz2X7KymTgTOaaFizQe3AQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.1.tgz", + "integrity": "sha512-vsbgFHMFQrJBJKrUFJNZ2pgBeBkC2IvvoHjz1to0/0Xk7sII24T0qFOiJzG6Fu3zJoq/0yI4rKWi7WhApW+EFA==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -6110,45 +6278,45 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-ordered-values": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.1.tgz", - "integrity": "sha512-irWScWRL6nRzYmBOXReIKch75RRhNS86UPUAxXdmW/l0FcAsg0lvAXQCby/1lymxn/o0gVa6Rv/0f03eJOwHxw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.2.tgz", + "integrity": "sha512-AMJjt1ECBffF7CEON/Y0rekRLS6KsePU6PRP08UqYW4UGFRnTXNrByUzYK1h8AC7UWTZdQ9O3Oq9kFIhm0SFEw==", "license": "MIT", "dependencies": { - "cssnano-utils": "^5.0.0", + "cssnano-utils": "^5.0.1", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-reduce-initial": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.2.tgz", - "integrity": "sha512-pOnu9zqQww7dEKf62Nuju6JgsW2V0KRNBHxeKohU+JkHd/GAH5uvoObqFLqkeB2n20mr6yrlWDvo5UBU5GnkfA==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.3.tgz", + "integrity": "sha512-RFvkZaqiWtGMlVjlUHpaxGqEL27lgt+Q2Ixjf83CRAzqdo+TsDyGPtJUbPx2MuYIJ+sCQc2TrOvRnhcXQfgIVA==", "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", + "browserslist": "^4.24.5", "caniuse-api": "^3.0.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-reduce-transforms": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.0.tgz", - "integrity": "sha512-pnt1HKKZ07/idH8cpATX/ujMbtOGhUfE+m8gbqwJE05aTaNw8gbo34a2e3if0xc0dlu75sUOiqvwCGY3fzOHew==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.1.tgz", + "integrity": "sha512-MhyEbfrm+Mlp/36hvZ9mT9DaO7dbncU0CvWI8V93LRkY6IYlu38OPg3FObnuKTUxJ4qA8HpurdQOo5CyqqO76g==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0" @@ -6157,13 +6325,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -6174,9 +6342,9 @@ } }, "node_modules/postcss-svgo": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.0.1.tgz", - "integrity": "sha512-0WBUlSL4lhD9rA5k1e5D8EN5wCEyZD6HJk0jIvRxl+FDVOMlJ7DePHYWGGVc5QRqrJ3/06FTXM0bxjmJpmTPSA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.0.2.tgz", + "integrity": "sha512-5Dzy66JlnRM6pkdOTF8+cGsB1fnERTE8Nc+Eed++fOWo1hdsBptCsbG8UuJkgtZt75bRtMJIrPeZmtfANixdFA==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0", @@ -6186,22 +6354,22 @@ "node": "^18.12.0 || ^20.9.0 || >= 18" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-unique-selectors": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.3.tgz", - "integrity": "sha512-J+58u5Ic5T1QjP/LDV9g3Cx4CNOgB5vz+kM6+OxHHhFACdcDeKhBXjQmB7fnIZM12YSTvsL0Opwco83DmacW2g==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.4.tgz", + "integrity": "sha512-pmlZjsmEAG7cHd7uK3ZiNSW6otSZ13RHuZ/4cDN/bVglS5EpF2r2oxY99SuOHa8m7AWoBCelTS3JPpzsIs8skQ==", "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.1.2" + "postcss-selector-parser": "^7.1.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" } }, "node_modules/postcss-value-parser": { @@ -6335,14 +6503,15 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/prettier": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", - "integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.1.tgz", + "integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -6361,9 +6530,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", - "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", + "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -6411,9 +6580,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -6448,7 +6617,8 @@ "version": "7.5.0", "resolved": "https://registry.npmjs.org/rambda/-/rambda-7.5.0.tgz", "integrity": "sha512-y/M9weqWAH4iopRd7EHDEQQvpFPHj1AA3oHozE9tfITHUtTR7Z9PSlIRRG2l1GuW7sefC1cXFfIcF+cgnShdBA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/randombytes": { "version": "2.1.0", @@ -6531,12 +6701,6 @@ "node": ">=4" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6591,9 +6755,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7104,19 +7268,43 @@ } }, "node_modules/stylehacks": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.4.tgz", - "integrity": "sha512-i4zfNrGMt9SB4xRK9L83rlsFCgdGANfeDAYacO1pkqcE7cRHPdWHwnKZVz7WY17Veq/FvyYsRAU++Ga+qDFIww==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.5.tgz", + "integrity": "sha512-5kNb7V37BNf0Q3w+1pxfa+oiNPS++/b4Jil9e/kPDgrk1zjEd6uR7SZeJiYaLYH6RRSC1XX2/37OTeU/4FvuIA==", "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", - "postcss-selector-parser": "^6.1.2" + "browserslist": "^4.24.5", + "postcss-selector-parser": "^7.1.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.4.31" + "postcss": "^8.4.32" + } + }, + "node_modules/super-simple-scheduler": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.0.7.tgz", + "integrity": "sha512-kxKcBWE4rYgUP+XHgvO5m8nzyTI+5UoC8VeLkXJiC7EdSJrOwocyYUBuuDzE+wDNVKEv20UineXKhe1P0twYiA==", + "license": "MIT", + "dependencies": { + "human-interval": "2.0.1", + "uuid": "11.1.0", + "winston": "3.17.0" + } + }, + "node_modules/super-simple-scheduler/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" } }, "node_modules/supports-color": { @@ -7167,9 +7355,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.18.2.tgz", - "integrity": "sha512-J+y4mCw/zXh1FOj5wGJvnAajq6XgHOyywsa9yITmwxIlJbMqITq3gYRZHaeqLVH/eV/HOPphE6NjF+nbSNC5Zw==", + "version": "5.25.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.25.2.tgz", + "integrity": "sha512-V4JyoygUe5nCbn7bAD0fVKSC0yNcL3ROIQtGC7M0NATKuyosCSmMU6T0yDZIIuGpSxjsjZh/D2Ejb8lnF2jjxw==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -7202,12 +7390,6 @@ "tar-stream": "^2.1.4" } }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", @@ -7239,18 +7421,38 @@ "node": ">=18" } }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, - "node_modules/timsort": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", - "integrity": "sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A==", - "license": "MIT" - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7283,15 +7485,15 @@ } }, "node_modules/tr46": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", - "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "license": "MIT", "dependencies": { - "punycode": "^2.3.0" + "punycode": "^2.3.1" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/triple-beam": { @@ -7320,6 +7522,7 @@ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -7342,6 +7545,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -7389,25 +7593,28 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz", + "integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==", "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", "license": "MIT" }, "node_modules/unfetch": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", - "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==", - "license": "MIT" + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-5.0.0.tgz", + "integrity": "sha512-3xM2c89siXg0nHvlmYsQ2zkLASvVMBisZm5lF3gFDqfF2xonNStDJyMpvaOBe0a1Edxmqrf2E0HBdmy9QyZaeg==", + "license": "MIT", + "workspaces": [ + "./packages/isomorphic-unfetch" + ] }, "node_modules/unpipe": { "version": "1.0.0", @@ -7419,9 +7626,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "funding": [ { "type": "opencollective", @@ -7439,7 +7646,7 @@ "license": "MIT", "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -7453,6 +7660,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -7643,6 +7851,15 @@ "node": ">=4.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -7686,16 +7903,16 @@ } }, "node_modules/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "license": "MIT", "dependencies": { - "tr46": "^4.1.1", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/which": { @@ -7754,6 +7971,7 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } diff --git a/server/package.json b/server/package.json index 51e7cd218..561d235c9 100755 --- a/server/package.json +++ b/server/package.json @@ -40,6 +40,7 @@ "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", + "super-simple-scheduler": "1.0.7", "swagger-ui-express": "5.0.1", "winston": "^3.13.0" }, diff --git a/server/service/SuperSimpleQueue/SuperSimpleQueue.js b/server/service/SuperSimpleQueue/SuperSimpleQueue.js new file mode 100644 index 000000000..fab46b260 --- /dev/null +++ b/server/service/SuperSimpleQueue/SuperSimpleQueue.js @@ -0,0 +1,146 @@ +import Scheduler from "super-simple-scheduler"; +const SERVICE_NAME = "SuperSimpleQueue"; + +class SuperSimpleQueue { + static SERVICE_NAME = SERVICE_NAME; + + constructor({ appSettings, db, logger, helper }) { + this.appSettings = appSettings; + this.db = db; + this.logger = logger; + this.helper = helper; + } + + static async create({ appSettings, db, logger, helper }) { + const instance = new SuperSimpleQueue({ appSettings, db, logger, helper }); + await instance.init(); + return instance; + } + + init = async () => { + this.scheduler = new Scheduler({ + logLevel: process.env.LOG_LEVEL, + debug: process.env.NODE_ENV, + }); + this.scheduler.start(); + this.scheduler.addTemplate("test", this.helper.getMonitorJob()); + const monitors = await this.db.getAllMonitors(); + for (const monitor of monitors) { + await this.addJob(monitor._id, monitor); + } + }; + + addJob = async (monitorId, monitor) => { + this.scheduler.addJob({ + id: monitorId.toString(), + template: "test", + repeat: monitor.interval, + data: monitor, + }); + }; + + deleteJob = async (monitor) => { + this.scheduler.removeJob(monitor._id.toString()); + }; + + pauseJob = async (monitor) => { + const result = this.scheduler.pauseJob(monitor._id.toString()); + if (result === false) { + throw new Error("Failed to resume monitor"); + } + this.logger.debug({ + message: `Paused monitor ${monitor._id}`, + service: SERVICE_NAME, + method: "pauseJob", + }); + }; + + resumeJob = async (monitor) => { + const result = this.scheduler.resumeJob(monitor._id.toString()); + if (result === false) { + throw new Error("Failed to resume monitor"); + } + this.logger.debug({ + message: `Resumed monitor ${monitor._id}`, + service: SERVICE_NAME, + method: "resumeJob", + }); + }; + + updateJob = async (monitor) => { + this.scheduler.updateJob(monitor._id.toString(), monitor.interval); + }; + + shutdown = async () => { + this.scheduler.stop(); + }; + + getMetrics = async () => { + const jobs = this.scheduler.getJobs(); + const metrics = jobs.reduce( + (acc, job) => { + acc.totalRuns += job.runCount || 0; + acc.totalFailures += job.failCount || 0; + acc.jobs++; + if (job.failCount > 0 && job.lastFailedAt >= job.lsatRunAt) { + acc.failingJobs++; + } + + if (job.lockedAt) { + acc.activeJobs++; + } + + if (job.failCount > 0) { + acc.jobsWithFailures.push({ + monitorId: job.id, + monitorUrl: job?.data?.url || null, + monitorType: job?.data?.type || null, + failedAt: job.lastFailedAt, + failCount: job.failCount, + failReason: job.lastFailReason, + }); + } + return acc; + }, + { + jobs: 0, + activeJobs: 0, + failingJobs: 0, + jobsWithFailures: [], + totalRuns: 0, + totalFailures: 0, + } + ); + return metrics; + }; + + getJobs = async () => { + const jobs = this.scheduler.getJobs(); + return jobs.map((job) => { + return { + monitorId: job.id, + monitorUrl: job?.data?.url || null, + monitorType: job?.data?.type || null, + active: job.active, + lockedAt: job.lockedAt, + runCount: job.runCount || 0, + failCount: job.failCount || 0, + failReason: job.lastFailReason, + lastRunAt: job.lastRunAt, + lastFinishedAt: job.lastFinishedAt, + lastRunTook: job.lockedAt ? null : job.lastFinishedAt - job.lastRunAt, + lastFailedAt: job.lastFailedAt, + }; + }); + }; + + flushQueues = async () => { + console.log("flush not implemented"); + }; + + obliterate = async () => { + console.log("obliterate not implemented"); + }; +} + +export default SuperSimpleQueue; diff --git a/server/service/SuperSimpleQueue/SuperSimpleQueueHelper.js b/server/service/SuperSimpleQueue/SuperSimpleQueueHelper.js new file mode 100644 index 000000000..45c5978b9 --- /dev/null +++ b/server/service/SuperSimpleQueue/SuperSimpleQueueHelper.js @@ -0,0 +1,99 @@ +const SERVICE_NAME = "SuperSimpleQueueHelper"; + +class SuperSimpleQueueHelper { + constructor({ db, logger, networkService, statusService, notificationService }) { + this.db = db; + this.logger = logger; + this.networkService = networkService; + this.statusService = statusService; + this.notificationService = notificationService; + } + + getMonitorJob = () => { + return async (monitor) => { + try { + const monitorId = monitor._id; + if (!monitorId) { + throw new Error("No monitor id"); + } + + const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId); + if (maintenanceWindowActive) { + this.logger.info({ + message: `Monitor ${monitorId} is in maintenance window`, + service: SERVICE_NAME, + method: "getMonitorJob", + }); + return; + } + const networkResponse = await this.networkService.getStatus(monitor); + if (!networkResponse) { + throw new Error("No network response"); + } + + const { + monitor: updatedMonitor, + statusChanged, + prevStatus, + } = await this.statusService.updateStatus(networkResponse); + + this.notificationService + .handleNotifications({ + ...networkResponse, + monitor: updatedMonitor, + prevStatus, + statusChanged, + }) + .catch((error) => { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "getMonitorJob", + details: `Error sending notifications for job ${monitor._id}: ${error.message}`, + stack: error.stack, + }); + }); + } catch (error) { + this.logger.warn({ + message: error.message, + service: error.service || SERVICE_NAME, + method: error.method || "getMonitorJob", + stack: error.stack, + }); + throw error; + } + }; + }; + + async isInMaintenanceWindow(monitorId) { + const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(monitorId); + // Check for active maintenance window: + const maintenanceWindowIsActive = maintenanceWindows.reduce((acc, window) => { + if (window.active) { + const start = new Date(window.start); + const end = new Date(window.end); + const now = new Date(); + const repeatInterval = window.repeat || 0; + + // If start is < now and end > now, we're in maintenance + if (start <= now && end >= now) return true; + + // If maintenance window was set in the past with a repeat, + // we need to advance start and end to see if we are in range + + while (start < now && repeatInterval !== 0) { + start.setTime(start.getTime() + repeatInterval); + end.setTime(end.getTime() + repeatInterval); + if (start <= now && end >= now) { + return true; + } + } + return false; + } + return acc; + }, false); + return maintenanceWindowIsActive; + } +} + +export default SuperSimpleQueueHelper; diff --git a/server/service/networkService.js b/server/service/networkService.js index 08b2f065e..01f72d37d 100755 --- a/server/service/networkService.js +++ b/server/service/networkService.js @@ -256,7 +256,7 @@ class NetworkService { async requestPagespeed(monitor) { try { const url = monitor.url; - const updatedMonitor = { ...monitor }; + const updatedMonitor = monitor.toObject(); let pagespeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&category=seo&category=accessibility&category=best-practices&category=performance`; const dbSettings = await this.settingsService.getDBSettings(); From ca13185d846be17ec60e914f1953917dec8d1ca6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 27 Jun 2025 13:40:41 +0800 Subject: [PATCH 002/226] convert monitor to object --- server/service/SuperSimpleQueue/SuperSimpleQueue.js | 2 +- server/service/networkService.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/service/SuperSimpleQueue/SuperSimpleQueue.js b/server/service/SuperSimpleQueue/SuperSimpleQueue.js index fab46b260..ec6c16ea9 100644 --- a/server/service/SuperSimpleQueue/SuperSimpleQueue.js +++ b/server/service/SuperSimpleQueue/SuperSimpleQueue.js @@ -35,7 +35,7 @@ class SuperSimpleQueue { id: monitorId.toString(), template: "test", repeat: monitor.interval, - data: monitor, + data: monitor.toObject(), }); }; diff --git a/server/service/networkService.js b/server/service/networkService.js index 01f72d37d..08b2f065e 100755 --- a/server/service/networkService.js +++ b/server/service/networkService.js @@ -256,7 +256,7 @@ class NetworkService { async requestPagespeed(monitor) { try { const url = monitor.url; - const updatedMonitor = monitor.toObject(); + const updatedMonitor = { ...monitor }; let pagespeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&category=seo&category=accessibility&category=best-practices&category=performance`; const dbSettings = await this.settingsService.getDBSettings(); From 38f5c41272552249d8781615b5585ef6229ebee9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 27 Jun 2025 13:45:18 +0800 Subject: [PATCH 003/226] implement flush --- .../SuperSimpleQueue/SuperSimpleQueue.js | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/server/service/SuperSimpleQueue/SuperSimpleQueue.js b/server/service/SuperSimpleQueue/SuperSimpleQueue.js index ec6c16ea9..578f9dd3f 100644 --- a/server/service/SuperSimpleQueue/SuperSimpleQueue.js +++ b/server/service/SuperSimpleQueue/SuperSimpleQueue.js @@ -18,15 +18,26 @@ class SuperSimpleQueue { } init = async () => { - this.scheduler = new Scheduler({ - logLevel: process.env.LOG_LEVEL, - debug: process.env.NODE_ENV, - }); - this.scheduler.start(); - this.scheduler.addTemplate("test", this.helper.getMonitorJob()); - const monitors = await this.db.getAllMonitors(); - for (const monitor of monitors) { - await this.addJob(monitor._id, monitor); + try { + this.scheduler = new Scheduler({ + logLevel: process.env.LOG_LEVEL, + debug: process.env.NODE_ENV, + }); + this.scheduler.start(); + this.scheduler.addTemplate("test", this.helper.getMonitorJob()); + const monitors = await this.db.getAllMonitors(); + for (const monitor of monitors) { + await this.addJob(monitor._id, monitor); + } + return true; + } catch (error) { + this.logger.error({ + message: "Failed to initialize SuperSimpleQueue", + service: SERVICE_NAME, + method: "init", + details: error, + }); + return false; } }; @@ -135,7 +146,12 @@ class SuperSimpleQueue { }; flushQueues = async () => { - console.log("flush not implemented"); + const stopRes = this.scheduler.stop(); + const flushRes = this.scheduler.flushJobs(); + const initRes = await this.init(); + return { + success: stopRes && flushRes && initRes, + }; }; obliterate = async () => { From ce103c6b599bba20a46946f220ec666e8ce91bd6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 27 Jun 2025 13:51:39 +0800 Subject: [PATCH 004/226] format --- server/templates/addReview.mjml | 2 +- server/templates/employeeActivation.mjml | 6 ++++-- server/templates/hardwareIncident.mjml | 6 +++--- server/templates/noIncidentsThisWeek.mjml | 2 +- server/templates/passwordReset.mjml | 4 ++-- server/templates/serverIsDown.mjml | 8 ++++---- server/templates/serverIsUp.mjml | 8 ++++---- server/templates/welcomeEmail.mjml | 2 +- 8 files changed, 20 insertions(+), 18 deletions(-) diff --git a/server/templates/addReview.mjml b/server/templates/addReview.mjml index 9d00be7d0..644970f6e 100755 --- a/server/templates/addReview.mjml +++ b/server/templates/addReview.mjml @@ -30,7 +30,7 @@ -

Hello {{name}}!

+

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 diff --git a/server/templates/employeeActivation.mjml b/server/templates/employeeActivation.mjml index 3e35db6e8..317832c1f 100755 --- a/server/templates/employeeActivation.mjml +++ b/server/templates/employeeActivation.mjml @@ -29,10 +29,12 @@ -

Hello {{name}}!

+

Hello {{ name }}!

One of the admins created an account for you on the Checkmate server.

You can go ahead and create your account using this link.

-

{{link}}

+

+ {{ link }} +

Thank you.

diff --git a/server/templates/hardwareIncident.mjml b/server/templates/hardwareIncident.mjml index 19fb589fc..3f50a3d58 100755 --- a/server/templates/hardwareIncident.mjml +++ b/server/templates/hardwareIncident.mjml @@ -47,10 +47,10 @@ -

Hello {{name}}!

-

{{monitor}} at {{url}} has the following infrastructure alerts:

+

Hello {{ name }}!

+

{{ monitor }} at {{ url }} has the following infrastructure alerts:

{{#each alerts}} -

• {{this}}

+

• {{ this }}

{{/each}}
diff --git a/server/templates/noIncidentsThisWeek.mjml b/server/templates/noIncidentsThisWeek.mjml index d31d63d10..d160e44de 100755 --- a/server/templates/noIncidentsThisWeek.mjml +++ b/server/templates/noIncidentsThisWeek.mjml @@ -45,7 +45,7 @@ -

Hello {{name}}!

+

Hello {{ name }}!

There were no incidents this week. Good job!

Current monitors:

Google: 100% availability

diff --git a/server/templates/passwordReset.mjml b/server/templates/passwordReset.mjml index e0b85a4f4..5da29b341 100755 --- a/server/templates/passwordReset.mjml +++ b/server/templates/passwordReset.mjml @@ -31,10 +31,10 @@ -

Hello {{name}}!

+

Hello {{ name }}!

You are receiving this email because a password reset request has been made - for {{email}}. Please use the link below on the site to reset your password. + for {{ email }}. Please use the link below on the site to reset your password.

Reset Password

If you didn't request this, please ignore this email.

diff --git a/server/templates/serverIsDown.mjml b/server/templates/serverIsDown.mjml index b75d970e6..d46011070 100755 --- a/server/templates/serverIsDown.mjml +++ b/server/templates/serverIsDown.mjml @@ -36,7 +36,7 @@ font-size="18px" color="red" > - {{monitor}} is down + {{ monitor }} is down
-

Hello {{name}}!

+

Hello {{ name }}!

We detected an incident on one of your monitors. Your service is currently down. We'll send a message to you once it is up again.

-

Monitor name: {{monitor}}

-

URL: {{url}}

+

Monitor name: {{ monitor }}

+

URL: {{ url }}

diff --git a/server/templates/serverIsUp.mjml b/server/templates/serverIsUp.mjml index beb34481d..074c5e3dd 100755 --- a/server/templates/serverIsUp.mjml +++ b/server/templates/serverIsUp.mjml @@ -36,7 +36,7 @@ font-size="18px" color="green" > - {{monitor}} is up + {{ monitor }} is up
-

Hello {{name}}!

+

Hello {{ name }}!

Your latest incident is resolved and your monitored service is up again.

-

Monitor name: {{monitor}}

-

URL: {{url}}

+

Monitor name: {{ monitor }}

+

URL: {{ url }}

diff --git a/server/templates/welcomeEmail.mjml b/server/templates/welcomeEmail.mjml index 663518ec2..372e4c8ae 100755 --- a/server/templates/welcomeEmail.mjml +++ b/server/templates/welcomeEmail.mjml @@ -30,7 +30,7 @@ -

Hello {{name}}!

+

Hello {{ name }}!

Thank you for trying out Checkmate! We developed it with great care to meet our own needs, and we're excited to share it with you. From c3f5d249a08bcc3db4994488cb098ff0a645f81e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 7 Jul 2025 08:55:57 -0700 Subject: [PATCH 005/226] use latest library --- server/package-lock.json | 104 +++++++++--------- server/package.json | 2 +- .../SuperSimpleQueue/SuperSimpleQueue.js | 4 +- 3 files changed, 55 insertions(+), 55 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 427356e90..a7396bee2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -33,7 +33,7 @@ "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", - "super-simple-scheduler": "1.0.7", + "super-simple-scheduler": "1.0.12", "swagger-ui-express": "5.0.1", "winston": "^3.13.0" }, @@ -172,9 +172,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", - "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -187,9 +187,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", - "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -247,9 +247,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", - "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", + "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", "dev": true, "license": "MIT", "engines": { @@ -813,16 +813,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1123,9 +1123,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.0.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.4.tgz", - "integrity": "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA==", + "version": "24.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz", + "integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==", "license": "MIT", "dependencies": { "undici-types": "~7.8.0" @@ -2258,9 +2258,9 @@ } }, "node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", @@ -2287,9 +2287,9 @@ } }, "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", "license": "BSD-2-Clause", "engines": { "node": ">= 6" @@ -2663,9 +2663,9 @@ } }, "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -2710,9 +2710,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.175", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.175.tgz", - "integrity": "sha512-Nqpef9mOVo7pZfl9NIUhj7tgtRTsMzCzRTJDP1ccim4Wb4YHOz3Le87uxeZq68OCNwau2iQ/X7UwdAZ3ReOkmg==", + "version": "1.5.179", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", + "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -2886,19 +2886,19 @@ } }, "node_modules/eslint": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", - "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", + "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.1", - "@eslint/config-helpers": "^0.2.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.29.0", + "@eslint/js": "9.30.1", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -5286,9 +5286,9 @@ } }, "node_modules/mongoose": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.0.tgz", - "integrity": "sha512-gLuAZsbwY0PHjrvfuXvUkUq9tXjyAjN3ioXph5Y6Seu7/Uo8xJaM+rrMbL/x34K4T3UTgtXRyfoq1YU16qKyIw==", + "version": "8.16.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.1.tgz", + "integrity": "sha512-Q+0TC+KLdY4SYE+u9gk9pdW1tWu/pl0jusyEkMGTgBoAbvwQdfy4f9IM8dmvCwb/blSfp7IfLkob7v76x6ZGpQ==", "license": "MIT", "dependencies": { "bson": "^6.10.4", @@ -6509,9 +6509,9 @@ } }, "node_modules/prettier": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.1.tgz", - "integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -7284,9 +7284,9 @@ } }, "node_modules/super-simple-scheduler": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.0.7.tgz", - "integrity": "sha512-kxKcBWE4rYgUP+XHgvO5m8nzyTI+5UoC8VeLkXJiC7EdSJrOwocyYUBuuDzE+wDNVKEv20UineXKhe1P0twYiA==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.0.12.tgz", + "integrity": "sha512-DQeSfnHYKkaaMW6OhZ1+AGneGR94H3/ux+EuHKd0oOnIzhjYUcC613boGWUjcleEQSbXaEW40TO8HWXyi76M1Q==", "license": "MIT", "dependencies": { "human-interval": "2.0.1", @@ -7355,9 +7355,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.25.2", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.25.2.tgz", - "integrity": "sha512-V4JyoygUe5nCbn7bAD0fVKSC0yNcL3ROIQtGC7M0NATKuyosCSmMU6T0yDZIIuGpSxjsjZh/D2Ejb8lnF2jjxw==", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.26.0.tgz", + "integrity": "sha512-U8m1LruHrk33gIIT5qDKhXMygT4FonRGBE92zMbxP4i9ULolPlKISy5Pd3RCES8pWdbGzXhvm/Q6jdA/HsrClg==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -7593,9 +7593,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz", - "integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.11.0.tgz", + "integrity": "sha512-heTSIac3iLhsmZhUCjyS3JQEkZELateufzZuBaVM5RHXdSBMb1LPMQf5x+FH7qjsZYDP0ttAc3nnVpUB+wYbOg==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/server/package.json b/server/package.json index 561d235c9..361d8b29b 100755 --- a/server/package.json +++ b/server/package.json @@ -40,7 +40,7 @@ "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", - "super-simple-scheduler": "1.0.7", + "super-simple-scheduler": "1.0.12", "swagger-ui-express": "5.0.1", "winston": "^3.13.0" }, diff --git a/server/service/SuperSimpleQueue/SuperSimpleQueue.js b/server/service/SuperSimpleQueue/SuperSimpleQueue.js index 578f9dd3f..c528099b6 100644 --- a/server/service/SuperSimpleQueue/SuperSimpleQueue.js +++ b/server/service/SuperSimpleQueue/SuperSimpleQueue.js @@ -87,7 +87,7 @@ class SuperSimpleQueue { }; getMetrics = async () => { - const jobs = this.scheduler.getJobs(); + const jobs = await this.scheduler.getJobs(); const metrics = jobs.reduce( (acc, job) => { acc.totalRuns += job.runCount || 0; @@ -126,7 +126,7 @@ class SuperSimpleQueue { }; getJobs = async () => { - const jobs = this.scheduler.getJobs(); + const jobs = await this.scheduler.getJobs(); return jobs.map((job) => { return { monitorId: job.id, From cb2016a5dce749d1f5b602151583e4bdd5cf57f5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 8 Jul 2025 11:04:20 -0700 Subject: [PATCH 006/226] update queue version --- server/package-lock.json | 73 ++++++++++++------- server/package.json | 2 +- .../SuperSimpleQueue/SuperSimpleQueue.js | 11 ++- 3 files changed, 56 insertions(+), 30 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index a7396bee2..8ea8f17d9 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -33,7 +33,7 @@ "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", - "super-simple-scheduler": "1.0.12", + "super-simple-scheduler": "1.0.17", "swagger-ui-express": "5.0.1", "winston": "^3.13.0" }, @@ -120,9 +120,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz", + "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==", "license": "MIT", "optional": true, "dependencies": { @@ -1123,9 +1123,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.0.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz", - "integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==", + "version": "24.0.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.11.tgz", + "integrity": "sha512-CJV8eqrYnwQJGMrvcRhQmZfpyniDavB+7nAZYJc6w99hFYJyFN3INV1/2W3QfQrqM36WTLrijJ1fxxvGBmCSxA==", "license": "MIT", "dependencies": { "undici-types": "~7.8.0" @@ -1198,9 +1198,9 @@ } }, "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "engines": { "node": ">= 14" @@ -1693,9 +1693,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001726", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", - "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", "funding": [ { "type": "opencollective", @@ -2710,9 +2710,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.179", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", - "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==", + "version": "1.5.180", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.180.tgz", + "integrity": "sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -5286,9 +5286,9 @@ } }, "node_modules/mongoose": { - "version": "8.16.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.1.tgz", - "integrity": "sha512-Q+0TC+KLdY4SYE+u9gk9pdW1tWu/pl0jusyEkMGTgBoAbvwQdfy4f9IM8dmvCwb/blSfp7IfLkob7v76x6ZGpQ==", + "version": "8.16.2", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.2.tgz", + "integrity": "sha512-52T4XPhDgJL4cqooBsOORzYBH3ddMwABMEF/LV7TgvD2DEKZlnYTc2HF9ch1U2lcKjhE4pQ+WuInfLFJbguGcQ==", "license": "MIT", "dependencies": { "bson": "^6.10.4", @@ -7284,16 +7284,39 @@ } }, "node_modules/super-simple-scheduler": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.0.12.tgz", - "integrity": "sha512-DQeSfnHYKkaaMW6OhZ1+AGneGR94H3/ux+EuHKd0oOnIzhjYUcC613boGWUjcleEQSbXaEW40TO8HWXyi76M1Q==", + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.0.17.tgz", + "integrity": "sha512-PIs2dymRu6lVgvddhwjCORzCgf7jDCEoRSZu5xwEsNZIjxStMgpGeV9N1EbKzkr3T+NNojUcywAZDL4lTrNH7g==", "license": "MIT", "dependencies": { "human-interval": "2.0.1", + "mongoose": "8.16.1", "uuid": "11.1.0", "winston": "3.17.0" } }, + "node_modules/super-simple-scheduler/node_modules/mongoose": { + "version": "8.16.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.1.tgz", + "integrity": "sha512-Q+0TC+KLdY4SYE+u9gk9pdW1tWu/pl0jusyEkMGTgBoAbvwQdfy4f9IM8dmvCwb/blSfp7IfLkob7v76x6ZGpQ==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.17.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, "node_modules/super-simple-scheduler/node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -7355,9 +7378,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.26.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.26.0.tgz", - "integrity": "sha512-U8m1LruHrk33gIIT5qDKhXMygT4FonRGBE92zMbxP4i9ULolPlKISy5Pd3RCES8pWdbGzXhvm/Q6jdA/HsrClg==", + "version": "5.26.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.26.2.tgz", + "integrity": "sha512-WmMS9iMlHQejNm/Uw5ZTo4e3M2QMmEavRz7WLWVsq7Mlx4PSHJbY+VCrLsAz9wLxyHVgrJdt7N8+SdQLa52Ykg==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" diff --git a/server/package.json b/server/package.json index 361d8b29b..bc5cf4128 100755 --- a/server/package.json +++ b/server/package.json @@ -40,7 +40,7 @@ "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", - "super-simple-scheduler": "1.0.12", + "super-simple-scheduler": "1.0.17", "swagger-ui-express": "5.0.1", "winston": "^3.13.0" }, diff --git a/server/service/SuperSimpleQueue/SuperSimpleQueue.js b/server/service/SuperSimpleQueue/SuperSimpleQueue.js index c528099b6..d5e3e6ee6 100644 --- a/server/service/SuperSimpleQueue/SuperSimpleQueue.js +++ b/server/service/SuperSimpleQueue/SuperSimpleQueue.js @@ -20,11 +20,14 @@ class SuperSimpleQueue { init = async () => { try { this.scheduler = new Scheduler({ - logLevel: process.env.LOG_LEVEL, - debug: process.env.NODE_ENV, + storeType: "mongo", + logLevel: "debug", + debug: true, + dbUri: this.appSettings.dbConnectionString, }); this.scheduler.start(); - this.scheduler.addTemplate("test", this.helper.getMonitorJob()); + + this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob()); const monitors = await this.db.getAllMonitors(); for (const monitor of monitors) { await this.addJob(monitor._id, monitor); @@ -44,7 +47,7 @@ class SuperSimpleQueue { addJob = async (monitorId, monitor) => { this.scheduler.addJob({ id: monitorId.toString(), - template: "test", + template: "monitor-job", repeat: monitor.interval, data: monitor.toObject(), }); From 1456c4f749b2dfd67013650441009df99153c1fa Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 10 Jul 2025 16:06:38 -0700 Subject: [PATCH 007/226] bump version --- server/package-lock.json | 9 +++++---- server/package.json | 2 +- server/service/SuperSimpleQueue/SuperSimpleQueue.js | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 8ea8f17d9..6fbb33de6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -33,7 +33,7 @@ "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", - "super-simple-scheduler": "1.0.17", + "super-simple-scheduler": "1.2.3", "swagger-ui-express": "5.0.1", "winston": "^3.13.0" }, @@ -7284,12 +7284,13 @@ } }, "node_modules/super-simple-scheduler": { - "version": "1.0.17", - "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.0.17.tgz", - "integrity": "sha512-PIs2dymRu6lVgvddhwjCORzCgf7jDCEoRSZu5xwEsNZIjxStMgpGeV9N1EbKzkr3T+NNojUcywAZDL4lTrNH7g==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.2.3.tgz", + "integrity": "sha512-lpA/xzwRXl9KXN/+mznHfF2cMDQOXejJYna/bk3L0SPBDr28afrtx3orEKZ2JVDwF+f3Np01YlYamT51UU8OCw==", "license": "MIT", "dependencies": { "human-interval": "2.0.1", + "ioredis": "5.6.1", "mongoose": "8.16.1", "uuid": "11.1.0", "winston": "3.17.0" diff --git a/server/package.json b/server/package.json index bc5cf4128..494b81e2c 100755 --- a/server/package.json +++ b/server/package.json @@ -40,7 +40,7 @@ "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", - "super-simple-scheduler": "1.0.17", + "super-simple-scheduler": "1.2.3", "swagger-ui-express": "5.0.1", "winston": "^3.13.0" }, diff --git a/server/service/SuperSimpleQueue/SuperSimpleQueue.js b/server/service/SuperSimpleQueue/SuperSimpleQueue.js index d5e3e6ee6..f0b59640e 100644 --- a/server/service/SuperSimpleQueue/SuperSimpleQueue.js +++ b/server/service/SuperSimpleQueue/SuperSimpleQueue.js @@ -20,7 +20,8 @@ class SuperSimpleQueue { init = async () => { try { this.scheduler = new Scheduler({ - storeType: "mongo", + // storeType: "mongo", + // storeType: "redis", logLevel: "debug", debug: true, dbUri: this.appSettings.dbConnectionString, From 066df2c64f7332a331e0b0b89bf7dabf2a871037 Mon Sep 17 00:00:00 2001 From: Yasir Rafique Date: Fri, 18 Jul 2025 10:59:02 +0100 Subject: [PATCH 008/226] Relax monitor URL validation --- client/src/Validation/validation.js | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index b2fe7de71..7459de13d 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -128,26 +128,21 @@ const monitorValidation = joi.object({ .string() .trim() .custom((value, helpers) => { - // Regex from https://gist.github.com/dperini/729294 + const noProtocol = value.replace(/^(https?:\/\/)/, ""); + + if (/^[a-zA-Z0-9-]+(:\d{1,5})?$/.test(noProtocol)) { + return value; + } + + // Accept full domain/IP/URL (as fallback) 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]" + @@ -155,21 +150,19 @@ const monitorValidation = joi.object({ ")?" + "[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"); + + if (urlRegex.test(value)) { + return value; } - return value; + return helpers.error("string.invalidUrl"); }) .messages({ "string.empty": "This field is required.", From b823f0f7db0f0cee6bd0c511968883a2027fae92 Mon Sep 17 00:00:00 2001 From: Yasir Rafique Date: Fri, 18 Jul 2025 11:40:20 +0100 Subject: [PATCH 009/226] Relax monitor URL validation for Docker and local hostnames (fixes #2605) --- client/src/Validation/validation.js | 2 +- server/validation/joi.js | 47 ++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index 7459de13d..887e4e487 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -130,7 +130,7 @@ const monitorValidation = joi.object({ .custom((value, helpers) => { const noProtocol = value.replace(/^(https?:\/\/)/, ""); - if (/^[a-zA-Z0-9-]+(:\d{1,5})?$/.test(noProtocol)) { + if (/^[a-zA-Z0-9.-]+(:\d{1,5})?$/.test(noProtocol)) { return value; } diff --git a/server/validation/joi.js b/server/validation/joi.js index f555756eb..8549d470a 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -168,7 +168,52 @@ const createMonitorBodyValidation = joi.object({ name: joi.string().required(), description: joi.string().required(), type: joi.string().required(), - url: joi.string().required(), + url: joi + .string() + .required() + .custom((value, helpers) => { + const noProtocol = value.replace(/^(https?:\/\/)/, ""); + + if (/^[a-zA-Z0-9.-]+(:\d{1,5})?$/.test(noProtocol)) { + return value; + } + + // Existing complex URL validation as fallback + var urlRegex = new RegExp( + "^" + + "(?:(?:https?|ftp):\\/\\/)?" + + "(?:" + + "(?:[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]))" + + "|" + + "(?:" + + "(?:" + + "[a-z0-9\\u00a1-\\uffff]" + + "[a-z0-9\\u00a1-\\uffff_-]{0,62}" + + ")?" + + "[a-z0-9\\u00a1-\\uffff]\\." + + ")+" + + "(?:[a-z\\u00a1-\\uffff]{2,}\\.?)" + + ")" + + "(?::\\d{2,5})?" + + "(?:[/?#]\\S*)?" + + "$", + "i" + ); + + if (urlRegex.test(value)) { + return value; + } + + return helpers.error("string.invalidUrl"); + }) + .messages({ + "string.empty": "This field is required.", + "string.uri": "The URL you provided is not valid.", + "string.invalidUrl": + "Please enter a valid URL, hostname, or container name (with optional port).", + }), ignoreTlsErrors: joi.boolean().default(false), port: joi.number(), isActive: joi.boolean(), From 8ffd9a5e2a396a10a9ba9b136efaa198c81d63b4 Mon Sep 17 00:00:00 2001 From: Yasir Rafique Date: Fri, 18 Jul 2025 11:58:27 +0100 Subject: [PATCH 010/226] Tighten Docker hostname regex for monitor URL validation --- client/src/Validation/validation.js | 2 +- server/validation/joi.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index 887e4e487..7676d65d8 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -130,7 +130,7 @@ const monitorValidation = joi.object({ .custom((value, helpers) => { const noProtocol = value.replace(/^(https?:\/\/)/, ""); - if (/^[a-zA-Z0-9.-]+(:\d{1,5})?$/.test(noProtocol)) { + if (/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:\d{1,5})?$/.test(noProtocol)) { return value; } diff --git a/server/validation/joi.js b/server/validation/joi.js index 8549d470a..3fb652736 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -174,7 +174,7 @@ const createMonitorBodyValidation = joi.object({ .custom((value, helpers) => { const noProtocol = value.replace(/^(https?:\/\/)/, ""); - if (/^[a-zA-Z0-9.-]+(:\d{1,5})?$/.test(noProtocol)) { + if (/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:\d{1,5})?$/.test(noProtocol)) { return value; } From 945cff978bd9c54cec974096f310230667684c72 Mon Sep 17 00:00:00 2001 From: Yasir Rafique Date: Sat, 19 Jul 2025 06:53:01 +0100 Subject: [PATCH 011/226] Use URL.canParse for URL validation; add strict regex fallback (maintainer feedback) --- client/src/Validation/validation.js | 56 ++++++++++++++--------------- server/validation/joi.js | 56 ++++++++++++++--------------- 2 files changed, 52 insertions(+), 60 deletions(-) diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index 7676d65d8..94575ce99 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -128,40 +128,36 @@ const monitorValidation = joi.object({ .string() .trim() .custom((value, helpers) => { - const noProtocol = value.replace(/^(https?:\/\/)/, ""); + // 1. Standard URLs: must have protocol and pass canParse() + if (/^(https?:\/\/)/.test(value)) { + if ( + typeof URL !== "undefined" && + typeof URL.canParse === "function" && + URL.canParse(value) + ) { + return value; + } + // else, it's a malformed URL with protocol + return helpers.error("string.invalidUrl"); + } - if (/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:\d{1,5})?$/.test(noProtocol)) { - return value; - } - - // Accept full domain/IP/URL (as fallback) - var urlRegex = new RegExp( - "^" + - "(?:(?:https?|ftp):\\/\\/)?" + - "(?:" + - "(?:[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]))" + - "|" + - "(?:" + - "(?:" + - "[a-z0-9\\u00a1-\\uffff]" + - "[a-z0-9\\u00a1-\\uffff_-]{0,62}" + - ")?" + - "[a-z0-9\\u00a1-\\uffff]\\." + - ")+" + - "(?:[a-z\\u00a1-\\uffff]{2,}\\.?)" + - ")" + - "(?::\\d{2,5})?" + - "(?:[/?#]\\S*)?" + - "$", - "i" - ); - - if (urlRegex.test(value)) { + // 2. Docker/internal hostnames (no protocol) + if (/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:\d{1,5})?$/.test(value)) { + if (value.includes("..")) { + return helpers.error("string.invalidUrl"); + } + // Split by dot, check each label + const labels = value.split(":")[0].split("."); + for (const label of labels) { + if (!label) return helpers.error("string.invalidUrl"); + if (label.startsWith("-") || label.endsWith("-")) { + return helpers.error("string.invalidUrl"); + } + } return value; } + // 3. Everything else is invalid return helpers.error("string.invalidUrl"); }) .messages({ diff --git a/server/validation/joi.js b/server/validation/joi.js index 3fb652736..bf69bd06f 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -172,40 +172,36 @@ const createMonitorBodyValidation = joi.object({ .string() .required() .custom((value, helpers) => { - const noProtocol = value.replace(/^(https?:\/\/)/, ""); + // 1. Standard URLs: must have protocol and pass canParse() + if (/^(https?:\/\/)/.test(value)) { + if ( + typeof URL !== "undefined" && + typeof URL.canParse === "function" && + URL.canParse(value) + ) { + return value; + } + // else, it's a malformed URL with protocol + return helpers.error("string.invalidUrl"); + } - if (/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:\d{1,5})?$/.test(noProtocol)) { - return value; - } - - // Existing complex URL validation as fallback - var urlRegex = new RegExp( - "^" + - "(?:(?:https?|ftp):\\/\\/)?" + - "(?:" + - "(?:[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]))" + - "|" + - "(?:" + - "(?:" + - "[a-z0-9\\u00a1-\\uffff]" + - "[a-z0-9\\u00a1-\\uffff_-]{0,62}" + - ")?" + - "[a-z0-9\\u00a1-\\uffff]\\." + - ")+" + - "(?:[a-z\\u00a1-\\uffff]{2,}\\.?)" + - ")" + - "(?::\\d{2,5})?" + - "(?:[/?#]\\S*)?" + - "$", - "i" - ); - - if (urlRegex.test(value)) { + // 2. Docker/internal hostnames (no protocol) + if (/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:\d{1,5})?$/.test(value)) { + if (value.includes("..")) { + return helpers.error("string.invalidUrl"); + } + // Split by dot, check each label + const labels = value.split(":")[0].split("."); + for (const label of labels) { + if (!label) return helpers.error("string.invalidUrl"); + if (label.startsWith("-") || label.endsWith("-")) { + return helpers.error("string.invalidUrl"); + } + } return value; } + // 3. Everything else is invalid return helpers.error("string.invalidUrl"); }) .messages({ From c392933e55948c16abe8c9012709ef3c4ebdb7e6 Mon Sep 17 00:00:00 2001 From: Mohansai Mallineni Date: Sun, 20 Jul 2025 20:08:06 +0530 Subject: [PATCH 012/226] feat(i18n): implement new language mappings and initialize translation files --- client/src/Components/LanguageSelector.jsx | 14 +- client/src/locales/fi.json | 836 --------------------- client/src/locales/fr.json | 836 --------------------- client/src/locales/{ar.json => ja.json} | 0 client/src/locales/{es.json => uk.json} | 4 +- client/src/locales/{cs.json => vi.json} | 188 ++--- 6 files changed, 106 insertions(+), 1772 deletions(-) delete mode 100644 client/src/locales/fi.json delete mode 100644 client/src/locales/fr.json rename client/src/locales/{ar.json => ja.json} (100%) rename client/src/locales/{es.json => uk.json} (99%) rename client/src/locales/{cs.json => vi.json} (74%) diff --git a/client/src/Components/LanguageSelector.jsx b/client/src/Components/LanguageSelector.jsx index 284d0db05..57d102a9e 100644 --- a/client/src/Components/LanguageSelector.jsx +++ b/client/src/Components/LanguageSelector.jsx @@ -6,6 +6,14 @@ import { useSelector } from "react-redux"; import { useDispatch } from "react-redux"; import { setLanguage } from "../Features/UI/uiSlice"; + +const langMap = { + cs: "cz", + ja: "jp", + uk: "ua", + vi: "vn", +}; + const LanguageSelector = () => { const { i18n } = useTranslation(); const theme = useTheme(); @@ -28,14 +36,12 @@ const LanguageSelector = () => { {languages.map((lang) => { let parsedLang = lang === "en" ? "gb" : lang; - // Fix for Czech - if (parsedLang === "cs") { - parsedLang = "cz"; - } if (parsedLang.includes("-")) { parsedLang = parsedLang.split("-")[1].toLowerCase(); } + parsedLang = langMap[parsedLang] || parsedLang; + const flag = parsedLang ? `fi fi-${parsedLang}` : null; return ( diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json deleted file mode 100644 index 90b30e925..000000000 --- a/client/src/locales/fi.json +++ /dev/null @@ -1,836 +0,0 @@ -{ - "submit": "Lähetä", - "title": "Otsikko", - "distributedStatusHeaderText": "Reaaliaikainen kattavuus oikeilla laitteilla", - "distributedStatusSubHeaderText": "Miljoonien laitteiden voimin ympäri maailman näet järjestelmän suorituskyvyn maailman alueittain, maittain tai kaupungeittain", - "settingsDisabled": "Poistettu käytöstä", - "settingsSuccessSaved": "", - "settingsFailedToSave": "", - "settingsStatsCleared": "", - "settingsFailedToClearStats": "", - "settingsFailedToAddDemoMonitors": "", - "settingsMonitorsDeleted": "", - "settingsFailedToDeleteMonitors": "", - "starPromptTitle": "", - "starPromptDescription": "", - "https": "HTTPS", - "http": "HTTP", - "monitor": "Seurain", - "aboutus": "Tietoja meistä", - "now": "Nyt", - "delete": "Poista", - "configure": "Määritä", - "responseTime": "Vasteaika", - "ms": "ms", - "bar": "Palkki", - "area": "Alue", - "country": "MAA", - "city": "KAUPUNKI", - "response": "Vastaus", - "monitorStatusUp": "Valvontakohde {name} ({url}) on nyt toiminnassa ja vastaa", - "monitorStatusDown": "Valvontakohde {name} ({url}) ei ole toiminnassa, eikä vastaa", - "webhookSendSuccess": "Webhook-ilmoitus lähetettiin onnistuneesti", - "webhookSendError": "Virhe lähetettäessä webhook-ilmoitusta palveluun {platform}", - "webhookUnsupportedPlatform": "Ei tuettu palvelu: {platform}", - "distributedRightCategoryTitle": "Valvonta", - "distributedStatusServerMonitors": "Palvelin valvonta", - "distributedStatusServerMonitorsDescription": "Valvo palvelimien tilaa", - "distributedUptimeCreateSelectURL": "Tässä voit valita isäntäkohteen URL-osoitteen ja valvontatyypin.", - "distributedUptimeCreateChecks": "Suoritettavat tarkistukset", - "distributedUptimeCreateChecksDescription": "Voit aina lisätä tai poistaa tarkistuksia sivuston lisäämisen jälkeen.", - "distributedUptimeCreateIncidentNotification": "Häiriöilmoitukset", - "distributedUptimeCreateIncidentDescription": "Jos ilmenee häiriö, ilmoita käyttäjille.", - "distributedUptimeCreateAdvancedSettings": "Lisäasetukset", - "distributedUptimeDetailsNoMonitorHistory": "Tälle valvontakohteelle ei ole vielä tarkistushistoriaa.", - "distributedUptimeDetailsStatusHeaderUptime": "", - "distributedUptimeDetailsStatusHeaderLastUpdate": "Viimeksi päivitetty", - "notifications": { - "enableNotifications": "", - "testNotification": "Testi-ilmoitus", - "addOrEditNotifications": "Lisää tai muokkaa ilmoituksia", - "slack": { - "label": "Slack", - "description": "", - "webhookLabel": "Webhookin URL", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "discord": { - "label": "Discord", - "description": "", - "webhookLabel": "Discord Webhookin URL-osoite", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "" - }, - "telegram": { - "label": "Telegram", - "description": "", - "tokenLabel": "", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "" - }, - "webhook": { - "label": "Webhookit", - "description": "", - "urlLabel": "Webhookin URL", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "" - }, - "testNotificationDevelop": "Testi-ilmoitus 2", - "integrationButton": "", - "testSuccess": "Testi ilmoituksen lähetys onnistui!", - "testFailed": "Testi ilmoituksen lähetys epäonnistui", - "unsupportedType": "", - "networkError": "", - "fallback": { - "title": "", - "checks": [""] - }, - "createButton": "", - "createTitle": "", - "create": { - "success": "", - "failed": "" - }, - "fetch": { - "success": "", - "failed": "" - }, - "delete": { - "success": "", - "failed": "" - }, - "edit": { - "success": "", - "failed": "" - }, - "test": { - "success": "", - "failed": "" - } - }, - "testLocale": "", - "add": "Lisää", - "monitors": "Seuraimet", - "distributedUptimeStatusCreateStatusPage": "", - "distributedUptimeStatusCreateStatusPageAccess": "", - "distributedUptimeStatusCreateStatusPageReady": "", - "distributedUptimeStatusBasicInfoHeader": "", - "distributedUptimeStatusBasicInfoDescription": "", - "distributedUptimeStatusLogoHeader": "Logo", - "distributedUptimeStatusLogoDescription": "", - "distributedUptimeStatusLogoUploadButton": "", - "distributedUptimeStatusStandardMonitorsHeader": "", - "distributedUptimeStatusStandardMonitorsDescription": "", - "distributedUptimeStatusCreateYour": "", - "distributedUptimeStatusEditYour": "", - "distributedUptimeStatusPublishedLabel": "", - "distributedUptimeStatusCompanyNameLabel": "Yrityksen nimi", - "distributedUptimeStatusPageAddressLabel": "", - "distributedUptimeStatus30Days": "30 päivää", - "distributedUptimeStatus60Days": "60 päivää", - "distributedUptimeStatus90Days": "90 päivää", - "distributedUptimeStatusPageNotSetUp": "", - "distributedUptimeStatusContactAdmin": "Ota yhteys järjestelmävalvojaasi", - "distributedUptimeStatusPageNotPublic": "", - "distributedUptimeStatusPageDeleteDialog": "", - "distributedUptimeStatusPageDeleteConfirm": "", - "distributedUptimeStatusPageDeleteDescription": "", - "distributedUptimeStatusDevices": "", - "distributedUptimeStatusUpt": "", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "UPT logo", - "incidentsTableNoIncidents": "", - "incidentsTablePaginationLabel": "", - "incidentsTableMonitorName": "", - "incidentsTableStatus": "Tila", - "incidentsTableDateTime": "Päivämäärä ja aika", - "incidentsTableStatusCode": "", - "incidentsTableMessage": "Viesti", - "incidentsOptionsHeader": "", - "incidentsOptionsHeaderFilterBy": "", - "incidentsOptionsHeaderFilterAll": "Kaikki", - "incidentsOptionsHeaderFilterDown": "Alhaalla", - "incidentsOptionsHeaderFilterCannotResolve": "", - "incidentsOptionsHeaderShow": "Näytä:", - "incidentsOptionsHeaderLastHour": "Viime tunti", - "incidentsOptionsHeaderLastDay": "Viime päivä", - "incidentsOptionsHeaderLastWeek": "Viime viikko", - "incidentsOptionsPlaceholderAllServers": "Kaikki palvelimet", - "infrastructureCreateYour": "", - "infrastructureCreateGeneralSettingsDescription": "", - "infrastructureServerRequirement": "", - "infrastructureCustomizeAlerts": "", - "infrastructureAlertNotificationDescription": "", - "infrastructureCreateMonitor": "", - "infrastructureProtocol": "Protokolla", - "infrastructureServerUrlLabel": "Palvelimen URL-osoite", - "infrastructureDisplayNameLabel": "", - "infrastructureAuthorizationSecretLabel": "", - "gb": "", - "mb": "", - "mem": "", - "memoryUsage": "Muistinkäyttö", - "cpu": "", - "cpuUsage": "", - "cpuTemperature": "", - "diskUsage": "", - "used": "", - "total": "", - "cores": "", - "frequency": "", - "status": "Tila", - "cpuPhysical": "", - "cpuLogical": "", - "cpuFrequency": "", - "avgCpuTemperature": "", - "memory": "Muisti", - "disk": "", - "uptime": "", - "os": "", - "host": "", - "actions": "", - "integrations": "", - "integrationsPrism": "", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "", - "integrationsDiscord": "", - "integrationsDiscordInfo": "", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "", - "commonSave": "Tallenna", - "createYour": "", - "createMonitor": "", - "pause": "", - "resume": "", - "editing": "", - "url": "", - "access": "", - "timezone": "Aikavyöhyke", - "features": "", - "administrator": "", - "loginHere": "", - "displayName": "", - "urlMonitor": "", - "portToMonitor": "", - "websiteMonitoring": "", - "websiteMonitoringDescription": "", - "pingMonitoring": "", - "pingMonitoringDescription": "", - "dockerContainerMonitoring": "", - "dockerContainerMonitoringDescription": "", - "portMonitoring": "", - "portMonitoringDescription": "", - "createMaintenanceWindow": "", - "createMaintenance": "", - "editMaintenance": "", - "maintenanceWindowName": "", - "friendlyNameInput": "", - "friendlyNamePlaceholder": "", - "maintenanceRepeat": "", - "maintenance": "", - "duration": "", - "addMonitors": "", - "window": "", - "cancel": "Peruuttaa", - "message": "Viesti", - "low": "", - "high": "", - "statusCode": "Tilakoodi", - "date&Time": "Päivämäärä ja aika", - "type": "", - "statusPageName": "", - "publicURL": "Julkinen URL-osoite", - "repeat": "", - "edit": "Muokkaa", - "createA": "", - "remove": "", - "maintenanceWindowDescription": "", - "startTime": "", - "timeZoneInfo": "", - "monitorsToApply": "", - "nextWindow": "", - "notFoundButton": "", - "pageSpeedConfigureSettingsDescription": "", - "monitorDisplayName": "", - "whenNewIncident": "", - "notifySMS": "Ilmoita tekstiviestillä (tulossa pian)", - "notifyEmails": "", - "seperateEmails": "", - "checkFrequency": "", - "matchMethod": "", - "expectedValue": "", - "deleteDialogTitle": "", - "deleteDialogDescription": "", - "pageSpeedMonitor": "", - "shown": "", - "ago": "sitten", - "companyName": "Yrityksen nimi", - "pageSpeedDetailsPerformanceReport": "", - "pageSpeedDetailsPerformanceReportCalculator": "", - "checkingEvery": "", - "statusPageCreateSettings": "", - "basicInformation": "", - "statusPageCreateBasicInfoDescription": "", - "statusPageCreateSelectTimeZoneDescription": "", - "statusPageCreateAppearanceDescription": "", - "statusPageCreateSettingsCheckboxLabel": "", - "statusPageCreateBasicInfoStatusPageAddress": "", - "statusPageCreateTabsContent": "", - "statusPageCreateTabsContentDescription": "", - "statusPageCreateTabsContentFeaturesDescription": "", - "showCharts": "Näytä kaaviot", - "showUptimePercentage": "", - "removeLogo": "Poista logo", - "statusPageStatus": "", - "statusPageStatusContactAdmin": "Ota yhteyttä järjestelmänvalvojaan", - "statusPageStatusNotPublic": "", - "statusPageStatusNoPage": "", - "statusPageStatusServiceStatus": "", - "deleteStatusPage": "", - "deleteStatusPageConfirm": "", - "deleteStatusPageDescription": "", - "uptimeCreate": "", - "uptimeCreateJsonPath": "", - "uptimeCreateJsonPathQuery": "", - "maintenanceTableActionMenuDialogTitle": "", - "infrastructureEditYour": "Muokkaa omaa", - "infrastructureEditMonitor": "", - "infrastructureMonitorCreated": "", - "infrastructureMonitorUpdated": "", - "errorInvalidTypeId": "", - "errorInvalidFieldId": "", - "inviteNoTokenFound": "", - "pageSpeedWarning": "", - "pageSpeedLearnMoreLink": "", - "pageSpeedAddApiKey": "", - "update": "Päivitä", - "invalidFileFormat": "", - "invalidFileSize": "Tiedoston koko on liian suuri!", - "ClickUpload": "", - "DragandDrop": "raahaa ja pudota", - "MaxSize": "Enimmäiskoko", - "SupportedFormats": "Tuetut muodot", - "FirstName": "Etunimi", - "LastName": "Sukunimi", - "EmailDescriptionText": "Tämä on nykyinen sähköpostiosoitteesi — sitä ei voi vaihtaa.", - "YourPhoto": "Profiilikuva", - "PhotoDescriptionText": "", - "save": "Tallenna", - "DeleteDescriptionText": "", - "DeleteAccountWarning": "", - "DeleteWarningTitle": "Haluatko todella poistaa tämän tilin?", - "bulkImport": { - "title": "", - "selectFileTips": "", - "selectFileDescription": "", - "selectFile": "Valitse tiedosto", - "parsingFailed": "", - "uploadSuccess": "", - "validationFailed": "", - "noFileSelected": "Ei valittua tiedostoa", - "fallbackPage": "" - }, - "DeleteAccountTitle": "Poista tili", - "DeleteAccountButton": "Poista tili", - "publicLink": "Julkinen linkki", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "reset": "Palauta", - "ignoreTLSError": "", - "tlsErrorIgnored": "", - "ignoreTLSErrorDescription": "", - "createNew": "Luo uusi", - "greeting": { - "prepend": "Hei", - "append": "", - "overview": "" - }, - "roles": { - "superAdmin": "Pääylläpitäjä", - "admin": "Ylläpitäjä", - "teamMember": "Tiimin jäsen", - "demoUser": "Demokäyttäjä" - }, - "teamPanel": { - "teamMembers": "Tiimin jäsenet", - "filter": { - "all": "Kaikki", - "member": "Jäsen" - }, - "inviteTeamMember": "", - "inviteNewTeamMember": "", - "inviteDescription": "", - "email": "Sähköposti", - "selectRole": "Valitse rooli", - "inviteLink": "Kutsulinkki", - "cancel": "Peruuttaa", - "noMembers": "", - "getToken": "Hae tunniste", - "emailToken": "Sähköpostitunnus", - "table": { - "name": "Nimi", - "email": "Sähköposti", - "role": "Rooli", - "created": "Luotu" - } - }, - "monitorState": { - "paused": "Tauota", - "resumed": "Jatka", - "active": "" - }, - "menu": { - "uptime": "", - "pagespeed": "", - "infrastructure": "Infrastruktuuri", - "incidents": "", - "statusPages": "Tilasivut", - "maintenance": "Huolto", - "integrations": "", - "settings": "Asetukset", - "support": "Tuki", - "discussions": "Keskustelut", - "docs": "Dokumentit", - "changelog": "Muutosloki", - "profile": "Profiili", - "password": "Salasana", - "team": "Tiimi", - "logOut": "Kirjaudu ulos", - "notifications": "", - "logs": "" - }, - "settingsEmailUser": "", - "state": "Tila", - "statusBreadCrumbsStatusPages": "Tilasivut", - "statusBreadCrumbsDetails": "Tiedot", - "commonSaving": "Tallennetaan...", - "navControls": "Ohjaimet", - "incidentsPageTitle": "Häiriöt", - "passwordPanel": { - "passwordChangedSuccess": "Salasanasi vaihdettiin onnistuneesti.", - "passwordInputIncorrect": "", - "currentPassword": "Nykyinen salasana", - "enterCurrentPassword": "Kirjoita nykyinen salasanasi", - "newPassword": "Uusi salasana", - "enterNewPassword": "Kirjoita uusi salasana", - "confirmNewPassword": "Vahvista uusi salasana", - "passwordRequirements": "Uuden salasanan on oltava vähintään 8 merkkiä pitkä ja sen on sisältävä vähintään yksi iso kirjain, yksi pieni kirjain, yksi numero sekä yksi erikoismerkki.", - "saving": "Tallennetaan..." - }, - "emailSent": "Sähköpostin lähetys onnistui", - "failedToSendEmail": "Sähköpostin lähetys epäonnistui", - "settingsTestEmailSuccess": "Testisähköpostin lähetys onnistui", - "settingsTestEmailFailed": "Testisähköpostin lähetys epäonnistui", - "settingsTestEmailFailedWithReason": "Testisähköpostin lähetys epäonnistui: {{reason}}", - "settingsTestEmailUnknownError": "Tuntematon virhe", - "statusMsg": { - "paused": "Valvonta on keskeytetty.", - "up": "Sivusi on ylhäällä.", - "down": "Sivusi on alhaalla.", - "pending": "Odottaa..." - }, - "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "" - }, - "common": { - "appName": "", - "monitoringAgentName": "", - "buttons": { - "toggleTheme": "" - }, - "toasts": { - "networkError": "", - "checkConnection": "", - "unknownError": "" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "", - "back": "" - }, - "inputs": { - "email": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "invalid": "" - } - }, - "password": { - "label": "", - "rules": { - "length": { - "beginning": "", - "highlighted": "" - }, - "special": { - "beginning": "", - "highlighted": "" - }, - "number": { - "beginning": "", - "highlighted": "" - }, - "uppercase": { - "beginning": "", - "highlighted": "" - }, - "lowercase": { - "beginning": "", - "highlighted": "" - }, - "match": { - "beginning": "", - "highlighted": "" - } - }, - "errors": { - "empty": "", - "length": "", - "uppercase": "", - "lowercase": "", - "number": "", - "special": "", - "incorrect": "" - } - }, - "passwordConfirm": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "different": "" - } - }, - "firstName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - }, - "lastName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - } - }, - "errors": { - "validation": "" - }, - "fields": { - "password": { - "errors": { - "incorrect": "" - } - } - } - }, - "login": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "" - }, - "links": { - "forgotPassword": "", - "register": "", - "forgotPasswordLink": "", - "registerLink": "" - }, - "toasts": { - "success": "", - "incorrectPassword": "" - }, - "errors": { - "password": { - "incorrect": "" - } - } - }, - "registration": { - "heading": { - "superAdmin": "", - "user": "" - }, - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "" - }, - "description": { - "superAdmin": "", - "user": "" - }, - "gettingStartedButton": { - "superAdmin": "", - "user": "" - }, - "termsAndPolicies": "", - "links": { - "login": "" - }, - "toasts": { - "success": "" - } - }, - "forgotPassword": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "", - "stepFour": "" - }, - "buttons": { - "openEmail": "", - "resetPassword": "" - }, - "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" - }, - "links": { - "login": "", - "resend": "" - }, - "toasts": { - "sent": "", - "emailNotFound": "", - "redirect": "", - "success": "", - "error": "" - } - } - }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "", - "stillUnreachable": "" - }, - "alertBox": "", - "description": "", - "retryButton": { - "default": "", - "processing": "" - } - } - }, - "createNotifications": { - "title": "", - "nameSettings": { - "title": "", - "description": "", - "nameLabel": "", - "namePlaceholder": "" - }, - "typeSettings": { - "title": "", - "description": "", - "typeLabel": "" - }, - "emailSettings": { - "title": "", - "description": "", - "emailLabel": "", - "emailPlaceholder": "" - }, - "slackSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "pagerdutySettings": { - "title": "", - "description": "", - "integrationKeyLabel": "", - "integrationKeyPlaceholder": "" - }, - "discordSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "webhookSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - } - }, - "notificationConfig": { - "title": "", - "description": "" - }, - "monitorStatus": { - "checkingEvery": "", - "withCaptureAgent": "", - "up": "", - "down": "", - "paused": "" - }, - "advancedMatching": "", - "sendTestNotifications": "", - "testNotificationsDisabled": "", - "selectAll": "", - "showAdminLoginLink": "", - "logsPage": { - "title": "", - "description": "", - "tabs": { - "queue": "", - "logs": "" - }, - "toast": { - "fetchLogsSuccess": "" - }, - "logLevelSelect": { - "title": "", - "values": { - "all": "", - "info": "", - "warn": "", - "error": "", - "debug": "" - } - } - }, - "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", - "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "", - "activeHeader": "", - "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "", - "lastRunTookHeader": "" - }, - "metricsTable": { - "title": "", - "metricHeader": "", - "valueHeader": "" - }, - "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", - "failedAtHeader": "", - "failReasonHeader": "" - } - }, - "export": { - "title": "", - "success": "", - "failed": "" - }, - "monitorActions": { - "title": "", - "import": "", - "export": "" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "" - }, - "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" - }, - "emailSettings": { - "buttonSendTestEmail": "", - "description": "", - "descriptionTransport": "", - "labelAddress": "", - "labelConnectionHost": "", - "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", - "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", - "labelRequireTLS": "", - "labelSecure": "", - "labelTLSServername": "", - "labelUser": "", - "linkTransport": "", - "placeholderUser": "", - "title": "" - }, - "pageSpeedSettings": { - "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "" - }, - "saveButtonLabel": "", - "statsSettings": { - "clearAllStatsButton": "", - "clearAllStatsDescription": "", - "clearAllStatsDialogConfirm": "", - "clearAllStatsDialogDescription": "", - "clearAllStatsDialogTitle": "", - "description": "", - "labelTTL": "", - "labelTTLOptional": "", - "title": "" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", - "dialogDescription": "", - "dialogTitle": "", - "title": "" - }, - "timezoneSettings": { - "description": "", - "label": "", - "title": "" - }, - "title": "", - "uiSettings": { - "description": "", - "labelLanguage": "", - "labelTheme": "", - "title": "" - }, - "urlSettings": { - "description": "", - "label": "", - "selectDisabled": "", - "selectEnabled": "", - "title": "" - } - } -} diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json deleted file mode 100644 index fd2173b7b..000000000 --- a/client/src/locales/fr.json +++ /dev/null @@ -1,836 +0,0 @@ -{ - "submit": "Envoyer", - "title": "Titre", - "distributedStatusHeaderText": "", - "distributedStatusSubHeaderText": "", - "settingsDisabled": "Désactivé", - "settingsSuccessSaved": "", - "settingsFailedToSave": "", - "settingsStatsCleared": "", - "settingsFailedToClearStats": "", - "settingsFailedToAddDemoMonitors": "", - "settingsMonitorsDeleted": "", - "settingsFailedToDeleteMonitors": "", - "starPromptTitle": "", - "starPromptDescription": "", - "https": "", - "http": "", - "monitor": "", - "aboutus": "A propos de nous", - "now": "", - "delete": "Supprimer", - "configure": "", - "responseTime": "", - "ms": "", - "bar": "", - "area": "Zone", - "country": "PAYS", - "city": "VILLE", - "response": "", - "monitorStatusUp": "", - "monitorStatusDown": "", - "webhookSendSuccess": "", - "webhookSendError": "", - "webhookUnsupportedPlatform": "", - "distributedRightCategoryTitle": "Moniteur", - "distributedStatusServerMonitors": "", - "distributedStatusServerMonitorsDescription": "", - "distributedUptimeCreateSelectURL": "", - "distributedUptimeCreateChecks": "", - "distributedUptimeCreateChecksDescription": "", - "distributedUptimeCreateIncidentNotification": "Notifications d'incidents", - "distributedUptimeCreateIncidentDescription": "", - "distributedUptimeCreateAdvancedSettings": "Paramètres avancés", - "distributedUptimeDetailsNoMonitorHistory": "", - "distributedUptimeDetailsStatusHeaderUptime": "", - "distributedUptimeDetailsStatusHeaderLastUpdate": "", - "notifications": { - "enableNotifications": "", - "testNotification": "Notification de test", - "addOrEditNotifications": "Ajouter ou éditer des notifications", - "slack": { - "label": "", - "description": "", - "webhookLabel": "URL du webhook", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "discord": { - "label": "", - "description": "", - "webhookLabel": "URL du webhook Discord", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "telegram": { - "label": "", - "description": "", - "tokenLabel": "", - "tokenPlaceholder": "", - "chatIdLabel": "Votre Chat ID", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "" - }, - "webhook": { - "label": "", - "description": "", - "urlLabel": "URL du webhook", - "urlPlaceholder": "https://votre-serveur.fr/webhook", - "urlRequired": "L'URL du webhook est nécessaire" - }, - "testNotificationDevelop": "Notification de test 2", - "integrationButton": "Intégration de notification", - "testSuccess": "La notification de test a été envoyée avec succès !", - "testFailed": "Échec de l'envoi de la notification de test", - "unsupportedType": "", - "networkError": "", - "fallback": { - "title": "", - "checks": [""] - }, - "createButton": "", - "createTitle": "", - "create": { - "success": "", - "failed": "" - }, - "fetch": { - "success": "", - "failed": "" - }, - "delete": { - "success": "", - "failed": "" - }, - "edit": { - "success": "", - "failed": "" - }, - "test": { - "success": "", - "failed": "" - } - }, - "testLocale": "", - "add": "Ajouter", - "monitors": "", - "distributedUptimeStatusCreateStatusPage": "page de statut", - "distributedUptimeStatusCreateStatusPageAccess": "Accès", - "distributedUptimeStatusCreateStatusPageReady": "", - "distributedUptimeStatusBasicInfoHeader": "", - "distributedUptimeStatusBasicInfoDescription": "", - "distributedUptimeStatusLogoHeader": "Logo", - "distributedUptimeStatusLogoDescription": "", - "distributedUptimeStatusLogoUploadButton": "Téléverser un logo", - "distributedUptimeStatusStandardMonitorsHeader": "", - "distributedUptimeStatusStandardMonitorsDescription": "", - "distributedUptimeStatusCreateYour": "", - "distributedUptimeStatusEditYour": "", - "distributedUptimeStatusPublishedLabel": "", - "distributedUptimeStatusCompanyNameLabel": "Nom de l'entreprise", - "distributedUptimeStatusPageAddressLabel": "Adresse de votre page de statut", - "distributedUptimeStatus30Days": "30 jours", - "distributedUptimeStatus60Days": "60 jours", - "distributedUptimeStatus90Days": "90 jours", - "distributedUptimeStatusPageNotSetUp": "", - "distributedUptimeStatusContactAdmin": "Veuillez contacter votre administrateur", - "distributedUptimeStatusPageNotPublic": "Cette page de statut n'est pas publique.", - "distributedUptimeStatusPageDeleteDialog": "Voulez-vous supprimer cette page de statut ?", - "distributedUptimeStatusPageDeleteConfirm": "Oui, supprimer la page de statut", - "distributedUptimeStatusPageDeleteDescription": "Supprimer votre page de statut est irréversible.", - "distributedUptimeStatusDevices": "", - "distributedUptimeStatusUpt": "", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "", - "incidentsTableNoIncidents": "", - "incidentsTablePaginationLabel": "incidents", - "incidentsTableMonitorName": "", - "incidentsTableStatus": "", - "incidentsTableDateTime": "Date et heure", - "incidentsTableStatusCode": "", - "incidentsTableMessage": "Message", - "incidentsOptionsHeader": "", - "incidentsOptionsHeaderFilterBy": "", - "incidentsOptionsHeaderFilterAll": "", - "incidentsOptionsHeaderFilterDown": "", - "incidentsOptionsHeaderFilterCannotResolve": "", - "incidentsOptionsHeaderShow": "", - "incidentsOptionsHeaderLastHour": "Dernière heure", - "incidentsOptionsHeaderLastDay": "Dernier jour", - "incidentsOptionsHeaderLastWeek": "Dernière semaine", - "incidentsOptionsPlaceholderAllServers": "", - "infrastructureCreateYour": "", - "infrastructureCreateGeneralSettingsDescription": "", - "infrastructureServerRequirement": "", - "infrastructureCustomizeAlerts": "", - "infrastructureAlertNotificationDescription": "", - "infrastructureCreateMonitor": "", - "infrastructureProtocol": "Protocole", - "infrastructureServerUrlLabel": "URL du serveur", - "infrastructureDisplayNameLabel": "", - "infrastructureAuthorizationSecretLabel": "", - "gb": "", - "mb": "", - "mem": "", - "memoryUsage": "", - "cpu": "CPU", - "cpuUsage": "", - "cpuTemperature": "", - "diskUsage": "Utilisation du disque", - "used": "", - "total": "", - "cores": "", - "frequency": "", - "status": "", - "cpuPhysical": "", - "cpuLogical": "", - "cpuFrequency": "Fréquence CPU", - "avgCpuTemperature": "Température moyenne du CPU", - "memory": "", - "disk": "Disque", - "uptime": "", - "os": "", - "host": "", - "actions": "Actions", - "integrations": "Intégrations", - "integrationsPrism": "Connecter Prism à votre service préféré.", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "Connecter à Slack et voir les incidents dans un canal", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "Connecter à Discord et voir les incidents dans un canal", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "Envoyer tous les incidents à Zapier", - "commonSave": "Sauvegarder", - "createYour": "Créez votre", - "createMonitor": "", - "pause": "", - "resume": "", - "editing": "", - "url": "URL", - "access": "Accès", - "timezone": "Fuseau horaire", - "features": "", - "administrator": "Administrateur ?", - "loginHere": "", - "displayName": "Nom d'affichage", - "urlMonitor": "URL à suivre", - "portToMonitor": "", - "websiteMonitoring": "", - "websiteMonitoringDescription": "", - "pingMonitoring": "", - "pingMonitoringDescription": "", - "dockerContainerMonitoring": "", - "dockerContainerMonitoringDescription": "", - "portMonitoring": "", - "portMonitoringDescription": "", - "createMaintenanceWindow": "", - "createMaintenance": "Créer une maintenance", - "editMaintenance": "", - "maintenanceWindowName": "", - "friendlyNameInput": "", - "friendlyNamePlaceholder": "", - "maintenanceRepeat": "", - "maintenance": "", - "duration": "", - "addMonitors": "Ajouter des moniteurs", - "window": "", - "cancel": "Annuler", - "message": "", - "low": "", - "high": "", - "statusCode": "", - "date&Time": "Date et heure", - "type": "", - "statusPageName": "", - "publicURL": "", - "repeat": "", - "edit": "", - "createA": "Créer un", - "remove": "", - "maintenanceWindowDescription": "", - "startTime": "", - "timeZoneInfo": "", - "monitorsToApply": "", - "nextWindow": "", - "notFoundButton": "", - "pageSpeedConfigureSettingsDescription": "", - "monitorDisplayName": "", - "whenNewIncident": "Lors d'un nouvel incident,", - "notifySMS": "", - "notifyEmails": "", - "seperateEmails": "", - "checkFrequency": "Vérifier la fréquence", - "matchMethod": "", - "expectedValue": "", - "deleteDialogTitle": "", - "deleteDialogDescription": "", - "pageSpeedMonitor": "", - "shown": "", - "ago": "depuis", - "companyName": "", - "pageSpeedDetailsPerformanceReport": "", - "pageSpeedDetailsPerformanceReportCalculator": "", - "checkingEvery": "Vérification tous les", - "statusPageCreateSettings": "", - "basicInformation": "", - "statusPageCreateBasicInfoDescription": "", - "statusPageCreateSelectTimeZoneDescription": "", - "statusPageCreateAppearanceDescription": "", - "statusPageCreateSettingsCheckboxLabel": "", - "statusPageCreateBasicInfoStatusPageAddress": "", - "statusPageCreateTabsContent": "", - "statusPageCreateTabsContentDescription": "", - "statusPageCreateTabsContentFeaturesDescription": "", - "showCharts": "", - "showUptimePercentage": "", - "removeLogo": "", - "statusPageStatus": "", - "statusPageStatusContactAdmin": "", - "statusPageStatusNotPublic": "", - "statusPageStatusNoPage": "", - "statusPageStatusServiceStatus": "", - "deleteStatusPage": "", - "deleteStatusPageConfirm": "Oui, supprimer la page de statut", - "deleteStatusPageDescription": "", - "uptimeCreate": "", - "uptimeCreateJsonPath": "", - "uptimeCreateJsonPathQuery": "", - "maintenanceTableActionMenuDialogTitle": "", - "infrastructureEditYour": "", - "infrastructureEditMonitor": "", - "infrastructureMonitorCreated": "", - "infrastructureMonitorUpdated": "", - "errorInvalidTypeId": "", - "errorInvalidFieldId": "", - "inviteNoTokenFound": "", - "pageSpeedWarning": "", - "pageSpeedLearnMoreLink": "", - "pageSpeedAddApiKey": "", - "update": "", - "invalidFileFormat": "Format de fichier non supporté !", - "invalidFileSize": "Le fichier est trop volumineux !", - "ClickUpload": "Cliquez pour téléverser", - "DragandDrop": "", - "MaxSize": "", - "SupportedFormats": "", - "FirstName": "", - "LastName": "", - "EmailDescriptionText": "", - "YourPhoto": "Photo de profil", - "PhotoDescriptionText": "", - "save": "", - "DeleteDescriptionText": "", - "DeleteAccountWarning": "", - "DeleteWarningTitle": "Vous supprimez vraiment ce compte ?", - "bulkImport": { - "title": "", - "selectFileTips": "", - "selectFileDescription": "", - "selectFile": "", - "parsingFailed": "", - "uploadSuccess": "", - "validationFailed": "Echec de la validation", - "noFileSelected": "", - "fallbackPage": "" - }, - "DeleteAccountTitle": "Supprimer le compte", - "DeleteAccountButton": "Supprimer le compte", - "publicLink": "", - "maskedPageSpeedKeyPlaceholder": "", - "reset": "", - "ignoreTLSError": "", - "tlsErrorIgnored": "", - "ignoreTLSErrorDescription": "", - "createNew": "Créer un nouveau", - "greeting": { - "prepend": "", - "append": "L'après-midi est votre libre, rendons-le épique !", - "overview": "" - }, - "roles": { - "superAdmin": "", - "admin": "Admin", - "teamMember": "", - "demoUser": "" - }, - "teamPanel": { - "teamMembers": "Membres de l'équipe", - "filter": { - "all": "Tous", - "member": "" - }, - "inviteTeamMember": "", - "inviteNewTeamMember": "", - "inviteDescription": "", - "email": "Email", - "selectRole": "", - "inviteLink": "Lien d'invitation", - "cancel": "Annuler", - "noMembers": "", - "getToken": "", - "emailToken": "", - "table": { - "name": "", - "email": "Email", - "role": "", - "created": "Créée" - } - }, - "monitorState": { - "paused": "", - "resumed": "", - "active": "Actif" - }, - "menu": { - "uptime": "", - "pagespeed": "", - "infrastructure": "", - "incidents": "", - "statusPages": "", - "maintenance": "", - "integrations": "Intégrations", - "settings": "", - "support": "", - "discussions": "Discussions", - "docs": "", - "changelog": "Changelog", - "profile": "", - "password": "", - "team": "", - "logOut": "Déconnexion", - "notifications": "", - "logs": "" - }, - "settingsEmailUser": "", - "state": "", - "statusBreadCrumbsStatusPages": "", - "statusBreadCrumbsDetails": "", - "commonSaving": "Sauvegarde...", - "navControls": "", - "incidentsPageTitle": "Incidents", - "passwordPanel": { - "passwordChangedSuccess": "Votre mot de passe a été modifié avec succès.", - "passwordInputIncorrect": "La saisie de votre mot de passe est incorrecte.", - "currentPassword": "Mot de passe actuel", - "enterCurrentPassword": "Entrez votre mot de passe actuel", - "newPassword": "Nouveau mot de passe", - "enterNewPassword": "Entrez votre nouveau mot de passe", - "confirmNewPassword": "Confirmer le nouveau mot de passe", - "passwordRequirements": "Le nouveau mot de passe doit contenir au moins 8 caractères et au moins une lettre majuscule, une lettre minuscule, un chiffre et un caractère spécial.", - "saving": "Sauvegarde..." - }, - "emailSent": "", - "failedToSendEmail": "", - "settingsTestEmailSuccess": "", - "settingsTestEmailFailed": "", - "settingsTestEmailFailedWithReason": "", - "settingsTestEmailUnknownError": "Erreur inconnue", - "statusMsg": { - "paused": "", - "up": "", - "down": "", - "pending": "" - }, - "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "" - }, - "common": { - "appName": "", - "monitoringAgentName": "", - "buttons": { - "toggleTheme": "" - }, - "toasts": { - "networkError": "", - "checkConnection": "", - "unknownError": "" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "", - "back": "" - }, - "inputs": { - "email": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "invalid": "" - } - }, - "password": { - "label": "", - "rules": { - "length": { - "beginning": "", - "highlighted": "" - }, - "special": { - "beginning": "", - "highlighted": "" - }, - "number": { - "beginning": "", - "highlighted": "" - }, - "uppercase": { - "beginning": "", - "highlighted": "" - }, - "lowercase": { - "beginning": "", - "highlighted": "" - }, - "match": { - "beginning": "", - "highlighted": "" - } - }, - "errors": { - "empty": "", - "length": "", - "uppercase": "", - "lowercase": "", - "number": "", - "special": "", - "incorrect": "" - } - }, - "passwordConfirm": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "different": "" - } - }, - "firstName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - }, - "lastName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - } - }, - "errors": { - "validation": "" - }, - "fields": { - "password": { - "errors": { - "incorrect": "" - } - } - } - }, - "login": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "" - }, - "links": { - "forgotPassword": "", - "register": "", - "forgotPasswordLink": "", - "registerLink": "" - }, - "toasts": { - "success": "", - "incorrectPassword": "" - }, - "errors": { - "password": { - "incorrect": "" - } - } - }, - "registration": { - "heading": { - "superAdmin": "", - "user": "" - }, - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "" - }, - "description": { - "superAdmin": "", - "user": "" - }, - "gettingStartedButton": { - "superAdmin": "", - "user": "" - }, - "termsAndPolicies": "", - "links": { - "login": "" - }, - "toasts": { - "success": "" - } - }, - "forgotPassword": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "", - "stepFour": "" - }, - "buttons": { - "openEmail": "", - "resetPassword": "" - }, - "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" - }, - "links": { - "login": "", - "resend": "" - }, - "toasts": { - "sent": "", - "emailNotFound": "", - "redirect": "", - "success": "", - "error": "" - } - } - }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "", - "stillUnreachable": "" - }, - "alertBox": "", - "description": "", - "retryButton": { - "default": "", - "processing": "" - } - } - }, - "createNotifications": { - "title": "", - "nameSettings": { - "title": "", - "description": "", - "nameLabel": "", - "namePlaceholder": "" - }, - "typeSettings": { - "title": "", - "description": "", - "typeLabel": "" - }, - "emailSettings": { - "title": "", - "description": "", - "emailLabel": "", - "emailPlaceholder": "" - }, - "slackSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "pagerdutySettings": { - "title": "", - "description": "", - "integrationKeyLabel": "", - "integrationKeyPlaceholder": "" - }, - "discordSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "webhookSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - } - }, - "notificationConfig": { - "title": "", - "description": "" - }, - "monitorStatus": { - "checkingEvery": "", - "withCaptureAgent": "", - "up": "", - "down": "", - "paused": "" - }, - "advancedMatching": "", - "sendTestNotifications": "", - "testNotificationsDisabled": "", - "selectAll": "", - "showAdminLoginLink": "", - "logsPage": { - "title": "", - "description": "", - "tabs": { - "queue": "", - "logs": "" - }, - "toast": { - "fetchLogsSuccess": "" - }, - "logLevelSelect": { - "title": "", - "values": { - "all": "", - "info": "", - "warn": "", - "error": "", - "debug": "" - } - } - }, - "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", - "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "", - "activeHeader": "", - "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "", - "lastRunTookHeader": "" - }, - "metricsTable": { - "title": "", - "metricHeader": "", - "valueHeader": "" - }, - "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", - "failedAtHeader": "", - "failReasonHeader": "" - } - }, - "export": { - "title": "", - "success": "", - "failed": "" - }, - "monitorActions": { - "title": "", - "import": "", - "export": "" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "" - }, - "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" - }, - "emailSettings": { - "buttonSendTestEmail": "", - "description": "", - "descriptionTransport": "", - "labelAddress": "", - "labelConnectionHost": "", - "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", - "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", - "labelRequireTLS": "", - "labelSecure": "", - "labelTLSServername": "", - "labelUser": "", - "linkTransport": "", - "placeholderUser": "", - "title": "" - }, - "pageSpeedSettings": { - "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "" - }, - "saveButtonLabel": "", - "statsSettings": { - "clearAllStatsButton": "", - "clearAllStatsDescription": "", - "clearAllStatsDialogConfirm": "", - "clearAllStatsDialogDescription": "", - "clearAllStatsDialogTitle": "", - "description": "", - "labelTTL": "", - "labelTTLOptional": "", - "title": "" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", - "dialogDescription": "", - "dialogTitle": "", - "title": "" - }, - "timezoneSettings": { - "description": "", - "label": "", - "title": "" - }, - "title": "", - "uiSettings": { - "description": "", - "labelLanguage": "", - "labelTheme": "", - "title": "" - }, - "urlSettings": { - "description": "", - "label": "", - "selectDisabled": "", - "selectEnabled": "", - "title": "" - } - } -} diff --git a/client/src/locales/ar.json b/client/src/locales/ja.json similarity index 100% rename from client/src/locales/ar.json rename to client/src/locales/ja.json diff --git a/client/src/locales/es.json b/client/src/locales/uk.json similarity index 99% rename from client/src/locales/es.json rename to client/src/locales/uk.json index 0db51b102..b3b8fc4ce 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/uk.json @@ -1,6 +1,6 @@ { - "submit": "Enviar", - "title": "Titulo", + "submit": "", + "title": "", "distributedStatusHeaderText": "", "distributedStatusSubHeaderText": "", "settingsDisabled": "", diff --git a/client/src/locales/cs.json b/client/src/locales/vi.json similarity index 74% rename from client/src/locales/cs.json rename to client/src/locales/vi.json index 2bb03f5ad..b3b8fc4ce 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/vi.json @@ -1,11 +1,11 @@ { - "submit": "Odeslat", + "submit": "", "title": "", "distributedStatusHeaderText": "", "distributedStatusSubHeaderText": "", - "settingsDisabled": "Vypnuto", - "settingsSuccessSaved": "Nastavení bylo úspěšně uloženo", - "settingsFailedToSave": "Nepodařilo se uložit nastavení", + "settingsDisabled": "", + "settingsSuccessSaved": "", + "settingsFailedToSave": "", "settingsStatsCleared": "", "settingsFailedToClearStats": "", "settingsFailedToAddDemoMonitors": "", @@ -438,99 +438,99 @@ "port": "" }, "common": { - "appName": "Checkmate", - "monitoringAgentName": "Capture", + "appName": "", + "monitoringAgentName": "", "buttons": { - "toggleTheme": "Přepnout mezi světlým a tmavým motivem" + "toggleTheme": "" }, "toasts": { - "networkError": "Chyba připojení k síti", - "checkConnection": "Zkontrolujte prosím své připojení k síti", - "unknownError": "Nastala neznámá chyba" + "networkError": "", + "checkConnection": "", + "unknownError": "" } }, "auth": { "common": { "navigation": { - "continue": "Pokračovat", - "back": "Zpět" + "continue": "", + "back": "" }, "inputs": { "email": { - "label": "E-mail", - "placeholder": "jan.novak@domena.cz", + "label": "", + "placeholder": "", "errors": { - "empty": "Zadejte prosím e-mailovou adresu", - "invalid": "Překontrolujte si prosím správnost zadané e-mailové adresy" + "empty": "", + "invalid": "" } }, "password": { - "label": "Heslo", + "label": "", "rules": { "length": { - "beginning": "Heslo musí být dlouhé alespoň", - "highlighted": "8 znaků" + "beginning": "", + "highlighted": "" }, "special": { - "beginning": "Heslo musí obsahovat alespoň", - "highlighted": "1 speciální znak" + "beginning": "", + "highlighted": "" }, "number": { - "beginning": "Heslo musí obsahovat alespoň", - "highlighted": "1 číslo" + "beginning": "", + "highlighted": "" }, "uppercase": { - "beginning": "Heslo musí obsahovat alespoň", - "highlighted": "1 velké písmeno" + "beginning": "", + "highlighted": "" }, "lowercase": { - "beginning": "Heslo musí obsahovat alespoň", - "highlighted": "1 malé písmeno" + "beginning": "", + "highlighted": "" }, "match": { - "beginning": "Obě hesla se", - "highlighted": "musí shodovat" + "beginning": "", + "highlighted": "" } }, "errors": { - "empty": "Zadejte prosím heslo", - "length": "Heslo musí být dlouhé alespoň 8 znaků", - "uppercase": "Heslo musí obsahovat alespoň 1 velké písmeno", - "lowercase": "Heslo musí obsahovat alespoň 1 malé písmeno", - "number": "Heslo musí obsahovat alespoň 1 číslo", - "special": "Heslo musí obsahovat alespoň 1 speciální znak", - "incorrect": "Zadané heslo neodpovídá tomu, co bylo nastaveno" + "empty": "", + "length": "", + "uppercase": "", + "lowercase": "", + "number": "", + "special": "", + "incorrect": "" } }, "passwordConfirm": { - "label": "Potvrzení hesla", - "placeholder": "Pro ověření správnosti zadejte heslo ještě jednou", + "label": "", + "placeholder": "", "errors": { - "empty": "Zadejte prosím své heslo ještě jednou, aby mohla být potvrzena jeho správnost (odhalí překlepy)", - "different": "Zadaná hesla se liší, takže je nejspíše jedno z nich špatně zapsané" + "empty": "", + "different": "" } }, "firstName": { - "label": "Jméno", - "placeholder": "Jan", + "label": "", + "placeholder": "", "errors": { - "empty": "Zadejte prosím své křestní jméno", - "length": "Jméno se musí vejít do 50 znaků", - "pattern": "Součástí jména mohou být pouze písmena, mezery apostrofy nebo spojovníky" + "empty": "", + "length": "", + "pattern": "" } }, "lastName": { - "label": "Příjmení", - "placeholder": "Novák", + "label": "", + "placeholder": "", "errors": { - "empty": "Zadejte prosím své příjmení", - "length": "Příjmení se musí vejít do 50 znaků", - "pattern": "Součástí příjmení mohou být pouze písmena, mezery apostrofy nebo spojovníky" + "empty": "", + "length": "", + "pattern": "" } } }, "errors": { - "validation": "Nastala chyba při ověřování dat." + "validation": "" }, "fields": { "password": { @@ -541,20 +541,20 @@ } }, "login": { - "heading": "Přihlášení", + "heading": "", "subheadings": { - "stepOne": "Zadejte svůj e-mail", - "stepTwo": "Zadejte své heslo" + "stepOne": "", + "stepTwo": "" }, "links": { - "forgotPassword": "Zapomněli jste heslo? Obnovte si ho", - "register": "Ještě nemáte účet? Zaregistrujte se", + "forgotPassword": "", + "register": "", "forgotPasswordLink": "", "registerLink": "" }, "toasts": { - "success": "Vítejte zpět", - "incorrectPassword": "Nesprávné heslo" + "success": "", + "incorrectPassword": "" }, "errors": { "password": { @@ -564,72 +564,72 @@ }, "registration": { "heading": { - "superAdmin": "Vytvoření superadministrátora", - "user": "Registrace" + "superAdmin": "", + "user": "" }, "subheadings": { - "stepOne": "Zadejte své osobní údaje", - "stepTwo": "Zadejte svůj e-mail", - "stepThree": "Nastavte si heslo" + "stepOne": "", + "stepTwo": "", + "stepThree": "" }, "description": { - "superAdmin": "Pro začátek vytvořte účet superadministrátora", - "user": "Zaregistrujte se jako uživatel a požádejte svého superadministrátora, aby vám přidělil oprávnění k hlídačům" + "superAdmin": "", + "user": "" }, "gettingStartedButton": { - "superAdmin": "Vytvořit účet superadministrátora", - "user": "Zaregistrovat se jako běžný uživatel" + "superAdmin": "", + "user": "" }, - "termsAndPolicies": "Vytvořením účtu souhlasíte s našimi Podmínkami použití (anglicky) a Podmínkami zpracování osobních údajů (anglicky).", + "termsAndPolicies": "", "links": { - "login": "Máte již vytvořený účet? Přihlašte se" + "login": "" }, "toasts": { - "success": "Vítejte! Váš účet byl úspěšně vytvořen." + "success": "" } }, "forgotPassword": { - "heading": "Zapomenuté heslo", + "heading": "", "subheadings": { - "stepOne": "Nebojte se, zašleme vám pokyny pro obnovení hesla.", - "stepTwo": "Odkaz pro obnovení hesla byl odeslán na ", - "stepThree": "Nové heslo se musí lišit od těch, které jste již použili.", - "stepFour": "Heslo bylo úspěšně obnoveno. Klepněte na tlačítko a dojde k čarovnému přihlášení." + "stepOne": "", + "stepTwo": "", + "stepThree": "", + "stepFour": "" }, "buttons": { - "openEmail": "Otevřít e-mailovou aplikaci", - "resetPassword": "Obnovit heslo" + "openEmail": "", + "resetPassword": "" }, "imageAlts": { - "passwordKey": "Ikonka přístupového klíče", - "email": "Ikonka e-mailu", - "lock": "Ikonka zámku", - "passwordConfirm": "Ikonka potvrzeného hesla" + "passwordKey": "", + "email": "", + "lock": "", + "passwordConfirm": "" }, "links": { - "login": "Vrátit se k Přihlášení", - "resend": "Nedorazil vám e-mail? Klepněte pro opětovné zaslání" + "login": "", + "resend": "" }, "toasts": { - "sent": "Instrukce vám dorazí na .", - "emailNotFound": "Nepodařilo se nalézt e-mailovou adresu.", - "redirect": "Přesměrování proběhne za ...", - "success": "Heslo bylo úspěšně obnoveno.", - "error": "Obnova hesla se nepodařila. Zkuste to prosím znovu nebo kontaktujte podporu." + "sent": "", + "emailNotFound": "", + "redirect": "", + "success": "", + "error": "" } } }, "errorPages": { "serverUnreachable": { "toasts": { - "reconnected": "Podařilo se úspěšně obnovit spojení se serverem", - "stillUnreachable": "Server je stále nedosažitelný. Zkuste to prosím později." + "reconnected": "", + "stillUnreachable": "" }, - "alertBox": "Chyba připojení k serveru", - "description": "Nepodařilo se připojit k serveru. Zkontrolujte si prosím internetové připojení a pokud problém přetrvává, ověřte konfiguraci aplikace Checkmate.", + "alertBox": "", + "description": "", "retryButton": { - "default": "Zkusit se znovu připojit", - "processing": "Probíhá připojování…" + "default": "", + "processing": "" } } }, From 4921b17f11eab7d9d666b4321187a54f265d9a3e Mon Sep 17 00:00:00 2001 From: Miguel Rodriguez Date: Sun, 20 Jul 2025 12:57:16 -0400 Subject: [PATCH 013/226] fix: docker.sock doesn't exist on many K8S Servers --- charts/helm/checkmate/templates/server-deployment.yaml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/charts/helm/checkmate/templates/server-deployment.yaml b/charts/helm/checkmate/templates/server-deployment.yaml index 8548495dd..6f21f86f3 100644 --- a/charts/helm/checkmate/templates/server-deployment.yaml +++ b/charts/helm/checkmate/templates/server-deployment.yaml @@ -20,11 +20,3 @@ spec: envFrom: - secretRef: name: checkmate-secrets - volumeMounts: - - name: docker-sock - mountPath: /var/run/docker.sock - volumes: - - name: docker-sock - hostPath: - path: /var/run/docker.sock - type: Socket From 37655a64c0ccd962b0f0529ad43998c3a697efd2 Mon Sep 17 00:00:00 2001 From: Miguel Rodriguez Date: Sun, 20 Jul 2025 13:22:01 -0400 Subject: [PATCH 014/226] fix: Set CORS headers with a split setup on helm. --- charts/helm/checkmate/templates/server-ingress.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/charts/helm/checkmate/templates/server-ingress.yaml b/charts/helm/checkmate/templates/server-ingress.yaml index acf64f95f..06f3a4edd 100644 --- a/charts/helm/checkmate/templates/server-ingress.yaml +++ b/charts/helm/checkmate/templates/server-ingress.yaml @@ -5,6 +5,11 @@ metadata: name: server-ingress annotations: nginx.ingress.kubernetes.io/rewrite-target: / + nginx.ingress.kubernetes.io/enable-cors: "true" + nginx.ingress.kubernetes.io/cors-allow-origin: "http://{{ .Values.client.ingress.host }}" + nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS" + nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" + nginx.ingress.kubernetes.io/cors-allow-credentials: "true" spec: rules: - host: {{ .Values.server.ingress.host }} From 2d437e2016bef77a04fe894319fa2070899d34f3 Mon Sep 17 00:00:00 2001 From: Miguel Rodriguez Date: Sun, 20 Jul 2025 13:30:01 -0400 Subject: [PATCH 015/226] coderabbit fix: Add HTTPS to CORS origins --- charts/helm/checkmate/templates/server-ingress.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/helm/checkmate/templates/server-ingress.yaml b/charts/helm/checkmate/templates/server-ingress.yaml index 06f3a4edd..c1982d451 100644 --- a/charts/helm/checkmate/templates/server-ingress.yaml +++ b/charts/helm/checkmate/templates/server-ingress.yaml @@ -6,7 +6,7 @@ metadata: annotations: nginx.ingress.kubernetes.io/rewrite-target: / nginx.ingress.kubernetes.io/enable-cors: "true" - nginx.ingress.kubernetes.io/cors-allow-origin: "http://{{ .Values.client.ingress.host }}" + nginx.ingress.kubernetes.io/cors-allow-origin: "http://{{ .Values.client.ingress.host }},https://{{ .Values.client.ingress.host }}" nginx.ingress.kubernetes.io/cors-allow-methods: "GET, PUT, POST, DELETE, PATCH, OPTIONS" nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" nginx.ingress.kubernetes.io/cors-allow-credentials: "true" From 0c04db3299e72cf77ac1996f93007c6bf54ae692 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 09:55:49 -0700 Subject: [PATCH 016/226] reorder routes for correctness --- server/middleware/verifyJWT.js | 17 ++-- server/routes/checkRoute.js | 14 +-- server/routes/diagnosticRoute.js | 5 +- server/routes/inviteRoute.js | 13 ++- server/routes/maintenanceWindowRoute.js | 20 ++--- server/routes/monitorRoute.js | 103 +++++++++++----------- server/routes/notificationRoute.js | 7 +- server/routes/queueRoute.js | 30 +++---- server/routes/statusPageRoute.js | 3 +- server/templates/addReview.mjml | 2 +- server/templates/employeeActivation.mjml | 6 +- server/templates/hardwareIncident.mjml | 6 +- server/templates/noIncidentsThisWeek.mjml | 2 +- server/templates/passwordReset.mjml | 4 +- server/templates/serverIsDown.mjml | 8 +- server/templates/serverIsUp.mjml | 8 +- server/templates/welcomeEmail.mjml | 2 +- 17 files changed, 123 insertions(+), 127 deletions(-) diff --git a/server/middleware/verifyJWT.js b/server/middleware/verifyJWT.js index bd17edcfd..bab832502 100755 --- a/server/middleware/verifyJWT.js +++ b/server/middleware/verifyJWT.js @@ -39,13 +39,16 @@ const verifyJWT = (req, res, next) => { const { jwtSecret } = ServiceRegistry.get(SettingsService.SERVICE_NAME).getSettings(); jwt.verify(parsedToken, jwtSecret, (err, decoded) => { if (err) { - if (err) { - const errorMessage = - err.name === "TokenExpiredError" - ? stringService.expiredAuthToken - : stringService.invalidAuthToken; - return res.status(401).json({ success: false, msg: errorMessage }); - } + const errorMessage = + err.name === "TokenExpiredError" + ? stringService.expiredAuthToken + : stringService.invalidAuthToken; + err.details = { msg: errorMessage }; + err.status = 401; + err.service = SERVICE_NAME; + err.method = "verifyJWT"; + next(err); + return; } else { // Token is valid, carry on req.user = decoded; diff --git a/server/routes/checkRoute.js b/server/routes/checkRoute.js index f215cbe37..7165d73f1 100755 --- a/server/routes/checkRoute.js +++ b/server/routes/checkRoute.js @@ -13,23 +13,24 @@ class CheckRoutes { } initRoutes() { - this.router.get("/team", this.checkController.getChecksByTeam); this.router.get("/team/summary", this.checkController.getChecksSummaryByTeamId); + this.router.get("/team", this.checkController.getChecksByTeam); + this.router.put( + "/team/ttl", + isAllowed(["admin", "superadmin"]), + this.checkController.updateChecksTTL + ); this.router.delete( "/team", isAllowed(["admin", "superadmin"]), this.checkController.deleteChecksByTeamId ); + this.router.put( "/check/:checkId", verifyTeamAccess(Check, "checkId"), this.checkController.ackCheck ); - this.router.put( - "/team/ttl", - isAllowed(["admin", "superadmin"]), - this.checkController.updateChecksTTL - ); this.router.get("/:monitorId", this.checkController.getChecksByMonitor); this.router.post( @@ -42,6 +43,7 @@ class CheckRoutes { verifyOwnership(Monitor, "monitorId"), this.checkController.deleteChecks ); + this.router.put("/:path/:monitorId?", this.checkController.ackAllChecks); } diff --git a/server/routes/diagnosticRoute.js b/server/routes/diagnosticRoute.js index 0d8f4df84..920eddebf 100755 --- a/server/routes/diagnosticRoute.js +++ b/server/routes/diagnosticRoute.js @@ -7,13 +7,12 @@ class DiagnosticRoutes { this.initRoutes(); } initRoutes() { + this.router.post("/db/stats", this.diagnosticController.getDbStats); + this.router.get("/system", this.diagnosticController.getSystemStats); this.router.get( "/db/get-monitors-by-team-id/:teamId", this.diagnosticController.getMonitorsByTeamIdExecutionStats ); - - this.router.post("/db/stats", this.diagnosticController.getDbStats); - this.router.get("/system", this.diagnosticController.getSystemStats); } getRouter() { diff --git a/server/routes/inviteRoute.js b/server/routes/inviteRoute.js index 301959389..4c14400ba 100755 --- a/server/routes/inviteRoute.js +++ b/server/routes/inviteRoute.js @@ -10,15 +10,14 @@ class InviteRoutes { } initRoutes() { - this.router.post( - "/", - isAllowed(["admin", "superadmin"]), - verifyJWT, - this.inviteController.getInviteToken - ); - this.router.post("/send", this.inviteController.sendInviteEmail); this.router.post("/verify", this.inviteController.inviteVerifyController); + this.router.post( + "/", + verifyJWT, + isAllowed(["admin", "superadmin"]), + this.inviteController.getInviteToken + ); } getRouter() { diff --git a/server/routes/maintenanceWindowRoute.js b/server/routes/maintenanceWindowRoute.js index 36f982e25..03160ad76 100755 --- a/server/routes/maintenanceWindowRoute.js +++ b/server/routes/maintenanceWindowRoute.js @@ -6,35 +6,29 @@ import MaintenanceWindow from "../db/models/MaintenanceWindow.js"; class MaintenanceWindowRoutes { constructor(maintenanceWindowController) { this.router = Router(); - this.maintenanceWindowController = maintenanceWindowController; + this.mwController = maintenanceWindowController; this.initRoutes(); } initRoutes() { - this.router.post("/", this.maintenanceWindowController.createMaintenanceWindows); + this.router.post("/", this.mwController.createMaintenanceWindows); + this.router.get("/team/", this.mwController.getMaintenanceWindowsByTeamId); this.router.get( "/monitor/:monitorId", verifyOwnership(Monitor, "monitorId"), - this.maintenanceWindowController.getMaintenanceWindowsByMonitorId + this.mwController.getMaintenanceWindowsByMonitorId ); - this.router.get( - "/team/", - this.maintenanceWindowController.getMaintenanceWindowsByTeamId - ); - - this.router.get("/:id", this.maintenanceWindowController.getMaintenanceWindowById); - + this.router.get("/:id", this.mwController.getMaintenanceWindowById); this.router.put( "/:id", verifyTeamAccess(MaintenanceWindow, "id"), - this.maintenanceWindowController.editMaintenanceWindow + this.mwController.editMaintenanceWindow ); - this.router.delete( "/:id", verifyTeamAccess(MaintenanceWindow, "id"), - this.maintenanceWindowController.deleteMaintenanceWindow + this.mwController.deleteMaintenanceWindow ); } diff --git a/server/routes/monitorRoute.js b/server/routes/monitorRoute.js index 168f8b88d..3e39f4459 100755 --- a/server/routes/monitorRoute.js +++ b/server/routes/monitorRoute.js @@ -17,22 +17,43 @@ class MonitorRoutes { } initRoutes() { - this.router.get("/", this.monitorController.getAllMonitors); + // Team routes + this.router.get("/team", this.monitorController.getMonitorsByTeamId); + this.router.get( + "/team/with-checks", + this.monitorController.getMonitorsWithChecksByTeamId + ); + this.router.get( + "/summary/team", + this.monitorController.getMonitorsAndSummaryByTeamId + ); // TODO should be /team/summary + + // Uptime routes this.router.get("/uptime", this.monitorController.getAllMonitorsWithUptimeStats); this.router.get( - "/export", - isAllowed(["admin", "superadmin"]), - this.monitorController.exportMonitorsToCSV + "/uptime/details/:monitorId", + this.monitorController.getUptimeDetailsById ); - this.router.get("/stats/:monitorId", this.monitorController.getMonitorStatsById); + + // Hardware routes this.router.get( "/hardware/details/:monitorId", this.monitorController.getHardwareDetailsById ); + // General monitor routes + this.router.post( + "/pause/:monitorId", + isAllowed(["admin", "superadmin"]), + this.monitorController.pauseMonitor + ); + this.router.get("/stats/:monitorId", this.monitorController.getMonitorStatsById); + + // Util routes this.router.get( - "/uptime/details/:monitorId", - this.monitorController.getUptimeDetailsById + "/resolution/url", + isAllowed(["admin", "superadmin"]), + this.monitorController.checkEndpointResolution ); this.router.get("/certificate/:monitorId", (req, res, next) => { this.monitorController.getMonitorCertificate( @@ -42,78 +63,58 @@ class MonitorRoutes { fetchMonitorCertificate ); }); - this.router.get("/team", this.monitorController.getMonitorsByTeamId); - - this.router.get("/:monitorId", this.monitorController.getMonitorById); - - this.router.get( - "/summary/team", - this.monitorController.getMonitorsAndSummaryByTeamId - ); - - this.router.get( - "/team/with-checks", - this.monitorController.getMonitorsWithChecksByTeamId - ); - - this.router.get( - "/resolution/url", - isAllowed(["admin", "superadmin"]), - this.monitorController.checkEndpointResolution - ); - - this.router.delete( - "/:monitorId", - verifyOwnership(Monitor, "monitorId"), - isAllowed(["admin", "superadmin"]), - this.monitorController.deleteMonitor - ); + // General monitor CRUD routes + this.router.get("/", this.monitorController.getAllMonitors); this.router.post( "/", isAllowed(["admin", "superadmin"]), this.monitorController.createMonitor ); - - this.router.put( - "/:monitorId", - verifyTeamAccess(Monitor, "monitorId"), - isAllowed(["admin", "superadmin"]), - this.monitorController.editMonitor - ); - this.router.delete( "/", isAllowed(["superadmin"]), this.monitorController.deleteAllMonitors ); - this.router.post( - "/pause/:monitorId", - isAllowed(["admin", "superadmin"]), - this.monitorController.pauseMonitor - ); - + // Other static routes this.router.post( "/demo", isAllowed(["admin", "superadmin"]), this.monitorController.addDemoMonitors ); - + this.router.get( + "/export", + isAllowed(["admin", "superadmin"]), + this.monitorController.exportMonitorsToCSV + ); + this.router.post("/seed", isAllowed(["superadmin"]), this.monitorController.seedDb); this.router.post( "/bulk", isAllowed(["admin", "superadmin"]), upload.single("csvFile"), this.monitorController.createBulkMonitors ); - - this.router.post("/seed", isAllowed(["superadmin"]), this.monitorController.seedDb); - this.router.post( "/test-email", isAllowed(["admin", "superadmin"]), this.monitorController.sendTestEmail ); + + // Individual monitor CRUD routes + this.router.get("/:monitorId", this.monitorController.getMonitorById); + this.router.put( + "/:monitorId", + verifyTeamAccess(Monitor, "monitorId"), + isAllowed(["admin", "superadmin"]), + this.monitorController.editMonitor + ); + this.router.delete( + "/:monitorId", + verifyOwnership(Monitor, "monitorId"), + isAllowed(["admin", "superadmin"]), + this.monitorController.deleteMonitor + ); } getRouter() { diff --git a/server/routes/notificationRoute.js b/server/routes/notificationRoute.js index 09becd16c..9d3e95e63 100755 --- a/server/routes/notificationRoute.js +++ b/server/routes/notificationRoute.js @@ -13,11 +13,11 @@ class NotificationRoutes { initializeRoutes() { this.router.use(verifyJWT); - this.router.post("/test", this.notificationController.testNotification); - this.router.post("/test/all", this.notificationController.testAllNotifications); - this.router.post("/", this.notificationController.createNotification); + this.router.post("/test/all", this.notificationController.testAllNotifications); + this.router.post("/test", this.notificationController.testNotification); + this.router.get("/team", this.notificationController.getNotificationsByTeamId); this.router.delete( @@ -25,7 +25,6 @@ class NotificationRoutes { verifyOwnership(Notification, "id"), this.notificationController.deleteNotification ); - this.router.get("/:id", this.notificationController.getNotificationById); this.router.put( "/:id", diff --git a/server/routes/queueRoute.js b/server/routes/queueRoute.js index c40d21528..e54378d7a 100755 --- a/server/routes/queueRoute.js +++ b/server/routes/queueRoute.js @@ -7,41 +7,37 @@ class QueueRoutes { this.initRoutes(); } initRoutes() { - this.router.get( - "/metrics", - isAllowed(["admin", "superadmin"]), - this.queueController.getMetrics - ); - this.router.get( "/jobs", isAllowed(["admin", "superadmin"]), this.queueController.getJobs ); - - this.router.get( - "/all-metrics", - isAllowed(["admin", "superadmin"]), - this.queueController.getAllMetrics - ); - this.router.post( "/jobs", isAllowed(["admin", "superadmin"]), this.queueController.addJob ); - this.router.post( - "/flush", + this.router.get( + "/metrics", isAllowed(["admin", "superadmin"]), - this.queueController.flushQueue + this.queueController.getMetrics ); - this.router.get( "/health", isAllowed(["admin", "superadmin"]), this.queueController.checkQueueHealth ); + this.router.get( + "/all-metrics", + isAllowed(["admin", "superadmin"]), + this.queueController.getAllMetrics + ); + this.router.post( + "/flush", + isAllowed(["admin", "superadmin"]), + this.queueController.flushQueue + ); } getRouter() { diff --git a/server/routes/statusPageRoute.js b/server/routes/statusPageRoute.js index 98b3a3433..b348e9f82 100755 --- a/server/routes/statusPageRoute.js +++ b/server/routes/statusPageRoute.js @@ -13,7 +13,6 @@ class StatusPageRoutes { initRoutes() { this.router.get("/", this.statusPageController.getStatusPage); this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId); - this.router.get("/:url", this.statusPageController.getStatusPageByUrl); this.router.post( "/", @@ -27,6 +26,8 @@ class StatusPageRoutes { verifyJWT, this.statusPageController.updateStatusPage ); + + this.router.get("/:url", this.statusPageController.getStatusPageByUrl); this.router.delete("/:url(*)", verifyJWT, this.statusPageController.deleteStatusPage); } diff --git a/server/templates/addReview.mjml b/server/templates/addReview.mjml index 9d00be7d0..644970f6e 100755 --- a/server/templates/addReview.mjml +++ b/server/templates/addReview.mjml @@ -30,7 +30,7 @@ -

Hello {{name}}!

+

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 diff --git a/server/templates/employeeActivation.mjml b/server/templates/employeeActivation.mjml index 3e35db6e8..317832c1f 100755 --- a/server/templates/employeeActivation.mjml +++ b/server/templates/employeeActivation.mjml @@ -29,10 +29,12 @@ -

Hello {{name}}!

+

Hello {{ name }}!

One of the admins created an account for you on the Checkmate server.

You can go ahead and create your account using this link.

-

{{link}}

+

+ {{ link }} +

Thank you.

diff --git a/server/templates/hardwareIncident.mjml b/server/templates/hardwareIncident.mjml index 19fb589fc..3f50a3d58 100755 --- a/server/templates/hardwareIncident.mjml +++ b/server/templates/hardwareIncident.mjml @@ -47,10 +47,10 @@ -

Hello {{name}}!

-

{{monitor}} at {{url}} has the following infrastructure alerts:

+

Hello {{ name }}!

+

{{ monitor }} at {{ url }} has the following infrastructure alerts:

{{#each alerts}} -

• {{this}}

+

• {{ this }}

{{/each}}
diff --git a/server/templates/noIncidentsThisWeek.mjml b/server/templates/noIncidentsThisWeek.mjml index d31d63d10..d160e44de 100755 --- a/server/templates/noIncidentsThisWeek.mjml +++ b/server/templates/noIncidentsThisWeek.mjml @@ -45,7 +45,7 @@ -

Hello {{name}}!

+

Hello {{ name }}!

There were no incidents this week. Good job!

Current monitors:

Google: 100% availability

diff --git a/server/templates/passwordReset.mjml b/server/templates/passwordReset.mjml index e0b85a4f4..5da29b341 100755 --- a/server/templates/passwordReset.mjml +++ b/server/templates/passwordReset.mjml @@ -31,10 +31,10 @@ -

Hello {{name}}!

+

Hello {{ name }}!

You are receiving this email because a password reset request has been made - for {{email}}. Please use the link below on the site to reset your password. + for {{ email }}. Please use the link below on the site to reset your password.

Reset Password

If you didn't request this, please ignore this email.

diff --git a/server/templates/serverIsDown.mjml b/server/templates/serverIsDown.mjml index b75d970e6..d46011070 100755 --- a/server/templates/serverIsDown.mjml +++ b/server/templates/serverIsDown.mjml @@ -36,7 +36,7 @@ font-size="18px" color="red" > - {{monitor}} is down + {{ monitor }} is down
-

Hello {{name}}!

+

Hello {{ name }}!

We detected an incident on one of your monitors. Your service is currently down. We'll send a message to you once it is up again.

-

Monitor name: {{monitor}}

-

URL: {{url}}

+

Monitor name: {{ monitor }}

+

URL: {{ url }}

diff --git a/server/templates/serverIsUp.mjml b/server/templates/serverIsUp.mjml index beb34481d..074c5e3dd 100755 --- a/server/templates/serverIsUp.mjml +++ b/server/templates/serverIsUp.mjml @@ -36,7 +36,7 @@ font-size="18px" color="green" > - {{monitor}} is up + {{ monitor }} is up
-

Hello {{name}}!

+

Hello {{ name }}!

Your latest incident is resolved and your monitored service is up again.

-

Monitor name: {{monitor}}

-

URL: {{url}}

+

Monitor name: {{ monitor }}

+

URL: {{ url }}

diff --git a/server/templates/welcomeEmail.mjml b/server/templates/welcomeEmail.mjml index 663518ec2..372e4c8ae 100755 --- a/server/templates/welcomeEmail.mjml +++ b/server/templates/welcomeEmail.mjml @@ -30,7 +30,7 @@ -

Hello {{name}}!

+

Hello {{ name }}!

Thank you for trying out Checkmate! We developed it with great care to meet our own needs, and we're excited to share it with you. From 846564a17e630c7750077fd921cc24091026e8e3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 10:34:04 -0700 Subject: [PATCH 017/226] reorder routes --- server/.prettierrc | 2 +- server/routes/authRoute.js | 36 +++------------ server/routes/monitorRoute.js | 83 ++++++---------------------------- server/routes/settingsRoute.js | 12 +---- 4 files changed, 24 insertions(+), 109 deletions(-) diff --git a/server/.prettierrc b/server/.prettierrc index 5c345e37a..681669aa7 100755 --- a/server/.prettierrc +++ b/server/.prettierrc @@ -1,5 +1,5 @@ { - "printWidth": 90, + "printWidth": 150, "useTabs": true, "tabWidth": 2, "singleQuote": false, diff --git a/server/routes/authRoute.js b/server/routes/authRoute.js index aabd8de89..127456c02 100755 --- a/server/routes/authRoute.js +++ b/server/routes/authRoute.js @@ -15,40 +15,18 @@ class AuthRoutes { } initRoutes() { - this.router.post( - "/register", - upload.single("profileImage"), - this.authController.registerUser - ); + this.router.post("/register", upload.single("profileImage"), this.authController.registerUser); this.router.post("/login", this.authController.loginUser); - this.router.put( - "/user/:userId", - upload.single("profileImage"), - verifyJWT, - this.authController.editUser - ); - - this.router.get("/users/superadmin", this.authController.checkSuperadminExists); - - this.router.get( - "/users", - verifyJWT, - isAllowed(["admin", "superadmin"]), - this.authController.getAllUsers - ); - - this.router.delete( - "/user/:userId", - verifyJWT, - verifyOwnership(User, "userId"), - this.authController.deleteUser - ); - - // Recovery routes this.router.post("/recovery/request", this.authController.requestRecovery); this.router.post("/recovery/validate", this.authController.validateRecovery); this.router.post("/recovery/reset/", this.authController.resetPassword); + + this.router.get("/users/superadmin", this.authController.checkSuperadminExists); + this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers); + + this.router.put("/user/:userId", verifyJWT, upload.single("profileImage"), this.authController.editUser); + this.router.delete("/user/:userId", verifyJWT, verifyOwnership(User, "userId"), this.authController.deleteUser); } getRouter() { diff --git a/server/routes/monitorRoute.js b/server/routes/monitorRoute.js index 3e39f4459..2911bf9d7 100755 --- a/server/routes/monitorRoute.js +++ b/server/routes/monitorRoute.js @@ -19,96 +19,41 @@ class MonitorRoutes { initRoutes() { // Team routes this.router.get("/team", this.monitorController.getMonitorsByTeamId); - this.router.get( - "/team/with-checks", - this.monitorController.getMonitorsWithChecksByTeamId - ); - this.router.get( - "/summary/team", - this.monitorController.getMonitorsAndSummaryByTeamId - ); // TODO should be /team/summary + this.router.get("/team/with-checks", this.monitorController.getMonitorsWithChecksByTeamId); + this.router.get("/summary/team", this.monitorController.getMonitorsAndSummaryByTeamId); // TODO should be /team/summary // Uptime routes this.router.get("/uptime", this.monitorController.getAllMonitorsWithUptimeStats); - this.router.get( - "/uptime/details/:monitorId", - this.monitorController.getUptimeDetailsById - ); + this.router.get("/uptime/details/:monitorId", this.monitorController.getUptimeDetailsById); // Hardware routes - this.router.get( - "/hardware/details/:monitorId", - this.monitorController.getHardwareDetailsById - ); + this.router.get("/hardware/details/:monitorId", this.monitorController.getHardwareDetailsById); // General monitor routes - this.router.post( - "/pause/:monitorId", - isAllowed(["admin", "superadmin"]), - this.monitorController.pauseMonitor - ); + this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor); this.router.get("/stats/:monitorId", this.monitorController.getMonitorStatsById); // Util routes - this.router.get( - "/resolution/url", - isAllowed(["admin", "superadmin"]), - this.monitorController.checkEndpointResolution - ); + this.router.get("/resolution/url", isAllowed(["admin", "superadmin"]), this.monitorController.checkEndpointResolution); this.router.get("/certificate/:monitorId", (req, res, next) => { - this.monitorController.getMonitorCertificate( - req, - res, - next, - fetchMonitorCertificate - ); + this.monitorController.getMonitorCertificate(req, res, next, fetchMonitorCertificate); }); // General monitor CRUD routes this.router.get("/", this.monitorController.getAllMonitors); - this.router.post( - "/", - isAllowed(["admin", "superadmin"]), - this.monitorController.createMonitor - ); - this.router.delete( - "/", - isAllowed(["superadmin"]), - this.monitorController.deleteAllMonitors - ); + this.router.post("/", isAllowed(["admin", "superadmin"]), this.monitorController.createMonitor); + this.router.delete("/", isAllowed(["superadmin"]), this.monitorController.deleteAllMonitors); // Other static routes - this.router.post( - "/demo", - isAllowed(["admin", "superadmin"]), - this.monitorController.addDemoMonitors - ); - this.router.get( - "/export", - isAllowed(["admin", "superadmin"]), - this.monitorController.exportMonitorsToCSV - ); + this.router.post("/demo", isAllowed(["admin", "superadmin"]), this.monitorController.addDemoMonitors); + this.router.get("/export", isAllowed(["admin", "superadmin"]), this.monitorController.exportMonitorsToCSV); this.router.post("/seed", isAllowed(["superadmin"]), this.monitorController.seedDb); - this.router.post( - "/bulk", - isAllowed(["admin", "superadmin"]), - upload.single("csvFile"), - this.monitorController.createBulkMonitors - ); - this.router.post( - "/test-email", - isAllowed(["admin", "superadmin"]), - this.monitorController.sendTestEmail - ); + this.router.post("/bulk", isAllowed(["admin", "superadmin"]), upload.single("csvFile"), this.monitorController.createBulkMonitors); + this.router.post("/test-email", isAllowed(["admin", "superadmin"]), this.monitorController.sendTestEmail); // Individual monitor CRUD routes this.router.get("/:monitorId", this.monitorController.getMonitorById); - this.router.put( - "/:monitorId", - verifyTeamAccess(Monitor, "monitorId"), - isAllowed(["admin", "superadmin"]), - this.monitorController.editMonitor - ); + this.router.put("/:monitorId", verifyTeamAccess(Monitor, "monitorId"), isAllowed(["admin", "superadmin"]), this.monitorController.editMonitor); this.router.delete( "/:monitorId", verifyOwnership(Monitor, "monitorId"), diff --git a/server/routes/settingsRoute.js b/server/routes/settingsRoute.js index f293fd660..88eb67e65 100755 --- a/server/routes/settingsRoute.js +++ b/server/routes/settingsRoute.js @@ -10,16 +10,8 @@ class SettingsRoutes { initRoutes() { this.router.get("/", this.settingsController.getAppSettings); - this.router.put( - "/", - isAllowed(["admin", "superadmin"]), - this.settingsController.updateAppSettings - ); - this.router.post( - "/test-email", - isAllowed(["admin", "superadmin"]), - this.settingsController.sendTestEmail - ); + this.router.put("/", isAllowed(["admin", "superadmin"]), this.settingsController.updateAppSettings); + this.router.post("/test-email", isAllowed(["admin", "superadmin"]), this.settingsController.sendTestEmail); } getRouter() { From bfa6832beb2a42d1766b77ec9f9ca7240d1916b1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 10:34:16 -0700 Subject: [PATCH 018/226] format --- server/controllers/authController.js | 22 ++-- server/controllers/checkController.js | 3 +- server/controllers/diagnosticController.js | 3 +- server/controllers/inviteController.js | 16 +-- .../maintenanceWindowController.js | 14 +-- server/controllers/monitorController.js | 12 +- server/controllers/statusPageController.js | 7 +- server/db/models/Monitor.js | 5 +- server/db/mongo/MongoDB.js | 3 +- server/db/mongo/modules/checkModule.js | 47 ++------ server/db/mongo/modules/checkModuleQueries.js | 12 +- server/db/mongo/modules/diagnosticModule.js | 14 +-- .../db/mongo/modules/hardwareCheckModule.js | 8 +- .../mongo/modules/maintenanceWindowModule.js | 17 +-- server/db/mongo/modules/monitorModule.js | 111 +++--------------- .../db/mongo/modules/monitorModuleQueries.js | 76 ++---------- server/db/mongo/modules/networkCheckModule.js | 4 +- server/db/mongo/modules/settingsModule.js | 4 +- server/db/mongo/modules/statusPageModule.js | 25 +--- server/db/mongo/modules/userModule.js | 28 +---- server/index.js | 43 ++----- server/middleware/isAllowed.js | 4 +- server/middleware/languageMiddleware.js | 39 +++--- server/middleware/verifyJWT.js | 5 +- server/middleware/verifySuperAdmin.js | 4 +- server/openapi.json | 9 +- server/routes/announcementsRoute.js | 7 +- server/routes/checkRoute.js | 30 +---- server/routes/diagnosticRoute.js | 5 +- server/routes/inviteRoute.js | 7 +- server/routes/maintenanceWindowRoute.js | 18 +-- server/routes/notificationRoute.js | 12 +- server/routes/queueRoute.js | 36 +----- server/routes/statusPageRoute.js | 14 +-- server/service/JobQueue/JobQueue.js | 6 +- server/service/JobQueue/JobQueueHelper.js | 17 +-- server/service/PulseQueue/PulseQueue.js | 7 +- server/service/PulseQueue/PulseQueueHelper.js | 6 +- server/service/emailService.js | 5 +- server/service/networkService.js | 35 ++---- server/service/notificationService.js | 20 +--- server/service/notificationUtils.js | 36 ++---- server/service/settingsService.js | 8 +- server/service/statusService.js | 12 +- server/service/stringService.js | 21 +--- server/service/translationService.js | 4 +- server/templates/addReview.mjml | 15 +-- server/templates/passwordReset.mjml | 4 +- server/templates/serverIsDown.mjml | 5 +- server/templates/welcomeEmail.mjml | 10 +- .../tests/controllers/authController.test.js | 23 +--- .../tests/controllers/checkController.test.js | 9 +- .../tests/controllers/controllerUtils.test.js | 6 +- .../controllers/inviteController.test.js | 5 +- .../controllers/monitorController.test.js | 16 +-- .../tests/controllers/queueController.test.js | 7 +- .../controllers/settingsController.test.js | 5 +- .../controllers/statusPageController.test.js | 5 +- server/tests/db/checkModule.test.js | 6 +- server/tests/db/hardwareCheckModule.test.js | 5 +- server/tests/db/inviteModule.test.js | 11 +- .../tests/db/maintenanceWindowModule.test.js | 48 ++------ server/tests/db/monitorModule.test.js | 106 ++++------------- server/tests/db/notificationModule.test.js | 6 +- server/tests/db/pageSpeedCheckModule.test.js | 5 +- server/tests/db/recoveryModule.test.js | 17 +-- server/tests/db/settingsModule.test.js | 5 +- server/tests/db/statusPageModule.test.js | 5 +- server/tests/db/userModule.test.js | 14 +-- server/tests/services/emailService.test.js | 58 ++------- server/tests/services/jobQueue.test.js | 31 +---- server/tests/services/networkService.test.js | 32 ++--- .../services/notificationService.test.js | 33 ++---- server/tests/services/settingsService.test.js | 4 +- server/tests/services/statusService.test.js | 8 +- server/tests/utils/dataUtils.test.js | 8 +- server/tests/utils/imageProcessing.test.js | 8 +- server/tests/utils/messages.test.js | 12 +- server/utils/dataUtils.js | 28 +---- server/utils/errorUtils.js | 62 ++-------- server/utils/logger.js | 77 ++++++------ server/utils/utils.js | 10 +- server/validation/joi.js | 37 ++---- 83 files changed, 338 insertions(+), 1249 deletions(-) diff --git a/server/controllers/authController.js b/server/controllers/authController.js index e4a682fec..a5c7d6cac 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -91,16 +91,14 @@ class AuthController { const html = await this.emailService.buildEmail("welcomeEmailTemplate", { name: newUser.firstName, }); - this.emailService - .sendEmail(newUser.email, "Welcome to Uptime Monitor", html) - .catch((error) => { - this.logger.warn({ - message: error.message, - service: SERVICE_NAME, - method: "registerUser", - stack: error.stack, - }); + this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error) => { + this.logger.warn({ + message: error.message, + service: SERVICE_NAME, + method: "registerUser", + stack: error.stack, }); + }); } catch (error) { this.logger.warn({ message: error.message, @@ -274,11 +272,7 @@ class AuthController { email, url, }); - const msgId = await this.emailService.sendEmail( - email, - "Checkmate Password Reset", - html - ); + const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html); return res.success({ msg: this.stringService.authCreateRecoveryToken, diff --git a/server/controllers/checkController.js b/server/controllers/checkController.js index 2968614e2..bbd5c08bb 100755 --- a/server/controllers/checkController.js +++ b/server/controllers/checkController.js @@ -46,8 +46,7 @@ class CheckController { await getChecksQueryValidation.validateAsync(req.query); const { monitorId } = req.params; - let { type, sortOrder, dateRange, filter, ack, page, rowsPerPage, status } = - req.query; + let { type, sortOrder, dateRange, filter, ack, page, rowsPerPage, status } = req.query; const result = await this.db.getChecksByMonitor({ monitorId, type, diff --git a/server/controllers/diagnosticController.js b/server/controllers/diagnosticController.js index a350a452b..774af8d7d 100755 --- a/server/controllers/diagnosticController.js +++ b/server/controllers/diagnosticController.js @@ -12,8 +12,7 @@ obs.observe({ entryTypes: ["measure"] }); class DiagnosticController { constructor(db) { this.db = db; - this.getMonitorsByTeamIdExecutionStats = - this.getMonitorsByTeamIdExecutionStats.bind(this); + this.getMonitorsByTeamIdExecutionStats = this.getMonitorsByTeamIdExecutionStats.bind(this); this.getDbStats = this.getDbStats.bind(this); } diff --git a/server/controllers/inviteController.js b/server/controllers/inviteController.js index abb6b8123..f7f33c5d3 100755 --- a/server/controllers/inviteController.js +++ b/server/controllers/inviteController.js @@ -1,8 +1,4 @@ -import { - inviteRoleValidation, - inviteBodyValidation, - inviteVerificationBodyValidation, -} from "../validation/joi.js"; +import { inviteRoleValidation, inviteBodyValidation, inviteVerificationBodyValidation } from "../validation/joi.js"; import jwt from "jsonwebtoken"; import { getTokenFromHeaders } from "../utils/utils.js"; import { asyncHandler, createServerError } from "../utils/errorUtils.js"; @@ -65,15 +61,9 @@ class InviteController { name: firstname, link: `${clientHost}/register/${inviteToken.token}`, }); - const result = await this.emailService.sendEmail( - req.body.email, - "Welcome to Uptime Monitor", - html - ); + const result = await this.emailService.sendEmail(req.body.email, "Welcome to Uptime Monitor", html); if (!result) { - throw createServerError( - "Failed to send invite e-mail... Please verify your settings." - ); + throw createServerError("Failed to send invite e-mail... Please verify your settings."); } return res.success({ diff --git a/server/controllers/maintenanceWindowController.js b/server/controllers/maintenanceWindowController.js index 8a19fc79d..322ba8a90 100755 --- a/server/controllers/maintenanceWindowController.js +++ b/server/controllers/maintenanceWindowController.js @@ -63,10 +63,7 @@ class MaintenanceWindowController { await getMaintenanceWindowsByTeamIdQueryValidation.validateAsync(req.query); const { teamId } = req.user; - const maintenanceWindows = await this.db.getMaintenanceWindowsByTeamId( - teamId, - req.query - ); + const maintenanceWindows = await this.db.getMaintenanceWindowsByTeamId(teamId, req.query); return res.success({ msg: this.stringService.maintenanceWindowGetByTeam, @@ -81,9 +78,7 @@ class MaintenanceWindowController { async (req, res, next) => { await getMaintenanceWindowsByMonitorIdParamValidation.validateAsync(req.params); - const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId( - req.params.monitorId - ); + const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(req.params.monitorId); return res.success({ msg: this.stringService.maintenanceWindowGetByUser, @@ -110,10 +105,7 @@ class MaintenanceWindowController { async (req, res, next) => { await editMaintenanceWindowByIdParamValidation.validateAsync(req.params); await editMaintenanceByIdWindowBodyValidation.validateAsync(req.body); - const editedMaintenanceWindow = await this.db.editMaintenanceWindowById( - req.params.id, - req.body - ); + const editedMaintenanceWindow = await this.db.editMaintenanceWindowById(req.params.id, req.body); return res.success({ msg: this.stringService.maintenanceWindowEdit, data: editedMaintenanceWindow, diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index f80fa29e5..79579e521 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -463,14 +463,10 @@ class MonitorController { const monitorId = req.params.monitorId; const monitor = await this.db.pauseMonitor({ monitorId }); - monitor.isActive === true - ? await this.jobQueue.resumeJob(monitor._id, monitor) - : await this.jobQueue.pauseJob(monitor); + monitor.isActive === true ? await this.jobQueue.resumeJob(monitor._id, monitor) : await this.jobQueue.pauseJob(monitor); return res.success({ - msg: monitor.isActive - ? this.stringService.monitorResume - : this.stringService.monitorPause, + msg: monitor.isActive ? this.stringService.monitorResume : this.stringService.monitorPause, data: monitor, }); }, @@ -493,9 +489,7 @@ class MonitorController { async (req, res, next) => { const { _id, teamId } = req.user; const demoMonitors = await this.db.addDemoMonitors(_id, teamId); - await Promise.all( - demoMonitors.map((monitor) => this.jobQueue.addJob(monitor._id, monitor)) - ); + await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor._id, monitor))); return res.success({ msg: this.stringService.monitorDemoAdded, diff --git a/server/controllers/statusPageController.js b/server/controllers/statusPageController.js index 1d3dd96fb..8333e8949 100755 --- a/server/controllers/statusPageController.js +++ b/server/controllers/statusPageController.js @@ -1,9 +1,4 @@ -import { - createStatusPageBodyValidation, - getStatusPageParamValidation, - getStatusPageQueryValidation, - imageValidation, -} from "../validation/joi.js"; +import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation, imageValidation } from "../validation/joi.js"; import { asyncHandler } from "../utils/errorUtils.js"; const SERVICE_NAME = "statusPageController"; diff --git a/server/db/models/Monitor.js b/server/db/models/Monitor.js index c71ea5f18..e8e19ac29 100755 --- a/server/db/models/Monitor.js +++ b/server/db/models/Monitor.js @@ -156,10 +156,7 @@ MonitorSchema.pre("deleteMany", async function (next) { } else { await Check.deleteMany({ monitorId: monitor._id }); } - await StatusPage.updateMany( - { monitors: monitor._id }, - { $pull: { monitors: monitor._id } } - ); + await StatusPage.updateMany({ monitors: monitor._id }, { $pull: { monitors: monitor._id } }); await MonitorStats.deleteMany({ monitorId: monitor._id.toString() }); } next(); diff --git a/server/db/mongo/MongoDB.js b/server/db/mongo/MongoDB.js index 5e5e7aec7..6f6a13d64 100755 --- a/server/db/mongo/MongoDB.js +++ b/server/db/mongo/MongoDB.js @@ -89,8 +89,7 @@ class MongoDB { connect = async () => { try { - const connectionString = - this.appSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db"; + const connectionString = this.appSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db"; await mongoose.connect(connectionString); // If there are no AppSettings, create one await AppSettings.findOneAndUpdate( diff --git a/server/db/mongo/modules/checkModule.js b/server/db/mongo/modules/checkModule.js index fb0cc539e..731d168bf 100755 --- a/server/db/mongo/modules/checkModule.js +++ b/server/db/mongo/modules/checkModule.js @@ -58,26 +58,13 @@ const createChecks = async (checks) => { * @returns {Promise>} * @throws {Error} */ -const getChecksByMonitor = async ({ - monitorId, - type, - sortOrder, - dateRange, - filter, - ack, - page, - rowsPerPage, - status, -}) => { +const getChecksByMonitor = async ({ monitorId, type, sortOrder, dateRange, filter, ack, page, rowsPerPage, status }) => { try { status = status === "true" ? true : status === "false" ? false : undefined; page = parseInt(page); rowsPerPage = parseInt(rowsPerPage); - const ackStage = - ack === "true" - ? { ack: true } - : { $or: [{ ack: false }, { ack: { $exists: false } }] }; + const ackStage = ack === "true" ? { ack: true } : { $or: [{ ack: false }, { ack: { $exists: false } }] }; // Match const matchStage = { @@ -158,23 +145,12 @@ const getChecksByMonitor = async ({ } }; -const getChecksByTeam = async ({ - sortOrder, - dateRange, - filter, - ack, - page, - rowsPerPage, - teamId, -}) => { +const getChecksByTeam = async ({ sortOrder, dateRange, filter, ack, page, rowsPerPage, teamId }) => { try { page = parseInt(page); rowsPerPage = parseInt(rowsPerPage); - const ackStage = - ack === "true" - ? { ack: true } - : { $or: [{ ack: false }, { ack: { $exists: false } }] }; + const ackStage = ack === "true" ? { ack: true } : { $or: [{ ack: false }, { ack: { $exists: false } }] }; const matchStage = { teamId: new ObjectId(teamId), @@ -264,11 +240,7 @@ const getChecksByTeam = async ({ */ const ackCheck = async (checkId, teamId, ack) => { try { - const updatedCheck = await Check.findOneAndUpdate( - { _id: checkId, teamId: teamId }, - { $set: { ack, ackAt: new Date() } }, - { new: true } - ); + const updatedCheck = await Check.findOneAndUpdate({ _id: checkId, teamId: teamId }, { $set: { ack, ackAt: new Date() } }, { new: true }); if (!updatedCheck) { throw new Error("Check not found"); @@ -293,10 +265,7 @@ const ackCheck = async (checkId, teamId, ack) => { */ const ackAllChecks = async (monitorId, teamId, ack, path) => { try { - const updatedChecks = await Check.updateMany( - path === "monitor" ? { monitorId } : { teamId }, - { $set: { ack, ackAt: new Date() } } - ); + const updatedChecks = await Check.updateMany(path === "monitor" ? { monitorId } : { teamId }, { $set: { ack, ackAt: new Date() } }); return updatedChecks.modifiedCount; } catch (error) { error.service = SERVICE_NAME; @@ -317,9 +286,7 @@ const getChecksSummaryByTeamId = async ({ teamId }) => { const matchStage = { teamId: new ObjectId(teamId), }; - const checks = await Check.aggregate( - buildChecksSummaryByTeamIdPipeline({ matchStage }) - ); + const checks = await Check.aggregate(buildChecksSummaryByTeamIdPipeline({ matchStage })); return checks[0].summary; } catch (error) { error.service = SERVICE_NAME; diff --git a/server/db/mongo/modules/checkModuleQueries.js b/server/db/mongo/modules/checkModuleQueries.js index ffea43ba5..f2ad2a922 100644 --- a/server/db/mongo/modules/checkModuleQueries.js +++ b/server/db/mongo/modules/checkModuleQueries.js @@ -10,20 +10,12 @@ const buildChecksSummaryByTeamIdPipeline = ({ matchStage }) => { totalChecks: { $sum: { $cond: [{ $eq: ["$status", false] }, 1, 0] } }, resolvedChecks: { $sum: { - $cond: [ - { $and: [{ $eq: ["$ack", true] }, { $eq: ["$status", false] }] }, - 1, - 0, - ], + $cond: [{ $and: [{ $eq: ["$ack", true] }, { $eq: ["$status", false] }] }, 1, 0], }, }, downChecks: { $sum: { - $cond: [ - { $and: [{ $eq: ["$ack", false] }, { $eq: ["$status", false] }] }, - 1, - 0, - ], + $cond: [{ $and: [{ $eq: ["$ack", false] }, { $eq: ["$status", false] }] }, 1, 0], }, }, cannotResolveChecks: { diff --git a/server/db/mongo/modules/diagnosticModule.js b/server/db/mongo/modules/diagnosticModule.js index e59830518..8717720a3 100755 --- a/server/db/mongo/modules/diagnosticModule.js +++ b/server/db/mongo/modules/diagnosticModule.js @@ -2,11 +2,7 @@ import Monitor from "../../models/Monitor.js"; import { ObjectId } from "mongodb"; const SERVICE_NAME = "diagnosticModule"; -import { - buildMonitorSummaryByTeamIdPipeline, - buildMonitorsByTeamIdPipeline, - buildFilteredMonitorsByTeamIdPipeline, -} from "./monitorModuleQueries.js"; +import { buildMonitorSummaryByTeamIdPipeline, buildMonitorsByTeamIdPipeline, buildFilteredMonitorsByTeamIdPipeline } from "./monitorModuleQueries.js"; const getMonitorsByTeamIdExecutionStats = async (req) => { try { @@ -24,13 +20,9 @@ const getMonitorsByTeamIdExecutionStats = async (req) => { matchStage.type = Array.isArray(type) ? { $in: type } : type; } - const summary = await Monitor.aggregate( - buildMonitorSummaryByTeamIdPipeline({ matchStage }) - ).explain("executionStats"); + const summary = await Monitor.aggregate(buildMonitorSummaryByTeamIdPipeline({ matchStage })).explain("executionStats"); - const monitors = await Monitor.aggregate( - buildMonitorsByTeamIdPipeline({ matchStage, field, order }) - ).explain("executionStats"); + const monitors = await Monitor.aggregate(buildMonitorsByTeamIdPipeline({ matchStage, field, order })).explain("executionStats"); const filteredMonitors = await Monitor.aggregate( buildFilteredMonitorsByTeamIdPipeline({ diff --git a/server/db/mongo/modules/hardwareCheckModule.js b/server/db/mongo/modules/hardwareCheckModule.js index 868c83958..bfb9b0730 100755 --- a/server/db/mongo/modules/hardwareCheckModule.js +++ b/server/db/mongo/modules/hardwareCheckModule.js @@ -23,14 +23,10 @@ const createHardwareCheck = async (hardwareCheckData) => { if (monitor.uptimePercentage === undefined) { newUptimePercentage = status === true ? 1 : 0; } else { - newUptimePercentage = - (monitor.uptimePercentage * (n - 1) + (status === true ? 1 : 0)) / n; + newUptimePercentage = (monitor.uptimePercentage * (n - 1) + (status === true ? 1 : 0)) / n; } - await Monitor.findOneAndUpdate( - { _id: monitorId }, - { uptimePercentage: newUptimePercentage } - ); + await Monitor.findOneAndUpdate({ _id: monitorId }, { uptimePercentage: newUptimePercentage }); const hardwareCheck = await new HardwareCheck({ ...hardwareCheckData, diff --git a/server/db/mongo/modules/maintenanceWindowModule.js b/server/db/mongo/modules/maintenanceWindowModule.js index 959f3f961..4f16fceb6 100755 --- a/server/db/mongo/modules/maintenanceWindowModule.js +++ b/server/db/mongo/modules/maintenanceWindowModule.js @@ -63,8 +63,7 @@ const getMaintenanceWindowsByTeamId = async (teamId, query) => { if (active !== undefined) maintenanceQuery.active = active; - const maintenanceWindowCount = - await MaintenanceWindow.countDocuments(maintenanceQuery); + const maintenanceWindowCount = await MaintenanceWindow.countDocuments(maintenanceQuery); // Pagination let skip = 0; @@ -78,10 +77,7 @@ const getMaintenanceWindowsByTeamId = async (teamId, query) => { sort[field] = order === "asc" ? 1 : -1; } - const maintenanceWindows = await MaintenanceWindow.find(maintenanceQuery) - .skip(skip) - .limit(rowsPerPage) - .sort(sort); + const maintenanceWindows = await MaintenanceWindow.find(maintenanceQuery).skip(skip).limit(rowsPerPage).sort(sort); return { maintenanceWindows, maintenanceWindowCount }; } catch (error) { @@ -122,8 +118,7 @@ const getMaintenanceWindowsByMonitorId = async (monitorId) => { */ const deleteMaintenanceWindowById = async (maintenanceWindowId) => { try { - const maintenanceWindow = - await MaintenanceWindow.findByIdAndDelete(maintenanceWindowId); + const maintenanceWindow = await MaintenanceWindow.findByIdAndDelete(maintenanceWindowId); return maintenanceWindow; } catch (error) { error.service = SERVICE_NAME; @@ -174,11 +169,7 @@ const deleteMaintenanceWindowByUserId = async (userId) => { const editMaintenanceWindowById = async (maintenanceWindowId, maintenanceWindowData) => { try { - const editedMaintenanceWindow = await MaintenanceWindow.findByIdAndUpdate( - maintenanceWindowId, - maintenanceWindowData, - { new: true } - ); + const editedMaintenanceWindow = await MaintenanceWindow.findByIdAndUpdate(maintenanceWindowId, maintenanceWindowData, { new: true }); return editedMaintenanceWindow; } catch (error) { error.service = SERVICE_NAME; diff --git a/server/db/mongo/modules/monitorModule.js b/server/db/mongo/modules/monitorModule.js index bc31915c2..94829002b 100755 --- a/server/db/mongo/modules/monitorModule.js +++ b/server/db/mongo/modules/monitorModule.js @@ -285,10 +285,7 @@ const groupChecksByTime = (checks, dateRange) => { return acc; } - const time = - dateRange === "day" - ? checkDate.setMinutes(0, 0, 0) - : checkDate.toISOString().split("T")[0]; + const time = dateRange === "day" ? checkDate.setMinutes(0, 0, 0) : checkDate.toISOString().split("T")[0]; if (!acc[time]) { acc[time] = { time, checks: [] }; @@ -306,9 +303,7 @@ const groupChecksByTime = (checks, dateRange) => { const calculateGroupStats = (group) => { const totalChecks = group.checks.length; - const checksWithResponseTime = group.checks.filter( - (check) => typeof check.responseTime === "number" && !Number.isNaN(check.responseTime) - ); + const checksWithResponseTime = group.checks.filter((check) => typeof check.responseTime === "number" && !Number.isNaN(check.responseTime)); return { time: group.time, @@ -317,8 +312,7 @@ const calculateGroupStats = (group) => { totalIncidents: group.checks.filter((check) => !check.status).length, avgResponseTime: checksWithResponseTime.length > 0 - ? checksWithResponseTime.reduce((sum, check) => sum + check.responseTime, 0) / - checksWithResponseTime.length + ? checksWithResponseTime.reduce((sum, check) => sum + check.responseTime, 0) / checksWithResponseTime.length : 0, }; }; @@ -344,29 +338,15 @@ const getUptimeDetailsById = async ({ monitorId, dateRange, normalize }) => { const dateString = formatLookup[dateRange]; - const results = await Check.aggregate( - buildUptimeDetailsPipeline(monitorId, dates, dateString) - ); + const results = await Check.aggregate(buildUptimeDetailsPipeline(monitorId, dates, dateString)); const monitorData = results[0]; - monitorData.groupedUpChecks = NormalizeDataUptimeDetails( - monitorData.groupedUpChecks, - 10, - 100 - ); + monitorData.groupedUpChecks = NormalizeDataUptimeDetails(monitorData.groupedUpChecks, 10, 100); - monitorData.groupedDownChecks = NormalizeDataUptimeDetails( - monitorData.groupedDownChecks, - 10, - 100 - ); + monitorData.groupedDownChecks = NormalizeDataUptimeDetails(monitorData.groupedDownChecks, 10, 100); - const normalizedGroupChecks = NormalizeDataUptimeDetails( - monitorData.groupedChecks, - 10, - 100 - ); + const normalizedGroupChecks = NormalizeDataUptimeDetails(monitorData.groupedChecks, 10, 100); monitorData.groupedChecks = normalizedGroupChecks; const monitorStats = await MonitorStats.findOne({ monitorId }); @@ -386,14 +366,7 @@ const getUptimeDetailsById = async ({ monitorId, dateRange, normalize }) => { * @returns {Promise} * @throws {Error} */ -const getMonitorStatsById = async ({ - monitorId, - limit, - sortOrder, - dateRange, - numToDisplay, - normalize, -}) => { +const getMonitorStatsById = async ({ monitorId, limit, sortOrder, dateRange, numToDisplay, normalize }) => { const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); try { // Get monitor, if we can't find it, abort with error @@ -408,12 +381,7 @@ const getMonitorStatsById = async ({ // Get Checks for monitor in date range requested const model = CHECK_MODEL_LOOKUP[monitor.type]; const dates = getDateRange(dateRange); - const { checksAll, checksForDateRange } = await getMonitorChecks( - monitorId, - model, - dates, - sort - ); + const { checksAll, checksForDateRange } = await getMonitorChecks(monitorId, model, dates, sort); // Build monitor stats const monitorStats = { @@ -423,20 +391,10 @@ const getMonitorStatsById = async ({ latestResponseTime: getLatestResponseTime(checksAll), periodIncidents: getIncidents(checksForDateRange), periodTotalChecks: checksForDateRange.length, - checks: processChecksForDisplay( - NormalizeData, - checksForDateRange, - numToDisplay, - normalize - ), + checks: processChecksForDisplay(NormalizeData, checksForDateRange, numToDisplay, normalize), }; - if ( - monitor.type === "http" || - monitor.type === "ping" || - monitor.type === "docker" || - monitor.type === "port" - ) { + if (monitor.type === "http" || monitor.type === "ping" || monitor.type === "docker" || monitor.type === "port") { // HTTP/PING Specific stats monitorStats.periodAvgResponseTime = getAverageResponseTime(checksForDateRange); monitorStats.periodUptime = getUptimePercentage(checksForDateRange); @@ -463,9 +421,7 @@ const getHardwareDetailsById = async ({ monitorId, dateRange }) => { month: "%Y-%m-%dT00:00:00Z", }; const dateString = formatLookup[dateRange]; - const hardwareStats = await HardwareCheck.aggregate( - buildHardwareDetailsPipeline(monitor, dates, dateString) - ); + const hardwareStats = await HardwareCheck.aggregate(buildHardwareDetailsPipeline(monitor, dates, dateString)); const monitorStats = { ...monitor.toObject(), @@ -505,16 +461,7 @@ const getMonitorById = async (monitorId) => { } }; -const getMonitorsByTeamId = async ({ - limit, - type, - page, - rowsPerPage, - filter, - field, - order, - teamId, -}) => { +const getMonitorsByTeamId = async ({ limit, type, page, rowsPerPage, filter, field, order, teamId }) => { limit = parseInt(limit); page = parseInt(page); rowsPerPage = parseInt(rowsPerPage); @@ -528,14 +475,10 @@ const getMonitorsByTeamId = async ({ matchStage.type = Array.isArray(type) ? { $in: type } : type; } - const summaryResult = await Monitor.aggregate( - buildMonitorSummaryByTeamIdPipeline({ matchStage }) - ); + const summaryResult = await Monitor.aggregate(buildMonitorSummaryByTeamIdPipeline({ matchStage })); const summary = summaryResult[0]; - const monitors = await Monitor.aggregate( - buildMonitorsByTeamIdPipeline({ matchStage, field, order }) - ); + const monitors = await Monitor.aggregate(buildMonitorsByTeamIdPipeline({ matchStage, field, order })); const filteredMonitors = await Monitor.aggregate( buildFilteredMonitorsByTeamIdPipeline({ @@ -569,14 +512,10 @@ const getMonitorsAndSummaryByTeamId = async ({ type, explain, teamId }) => { } if (explain === true) { - return Monitor.aggregate( - buildMonitorsAndSummaryByTeamIdPipeline({ matchStage }) - ).explain("executionStats"); + return Monitor.aggregate(buildMonitorsAndSummaryByTeamIdPipeline({ matchStage })).explain("executionStats"); } - const queryResult = await Monitor.aggregate( - buildMonitorsAndSummaryByTeamIdPipeline({ matchStage }) - ); + const queryResult = await Monitor.aggregate(buildMonitorsAndSummaryByTeamIdPipeline({ matchStage })); const { monitors, summary } = queryResult?.[0] ?? {}; return { monitors, summary }; } catch (error) { @@ -586,17 +525,7 @@ const getMonitorsAndSummaryByTeamId = async ({ type, explain, teamId }) => { } }; -const getMonitorsWithChecksByTeamId = async ({ - limit, - type, - page, - rowsPerPage, - filter, - field, - order, - teamId, - explain, -}) => { +const getMonitorsWithChecksByTeamId = async ({ limit, type, page, rowsPerPage, filter, field, order, teamId, explain }) => { try { limit = parseInt(limit); page = parseInt(page); @@ -684,9 +613,7 @@ const createMonitor = async ({ body, teamId, userId }) => { */ const createBulkMonitors = async (req) => { try { - const monitors = req.map( - (item) => new Monitor({ ...item, notifications: undefined }) - ); + const monitors = req.map((item) => new Monitor({ ...item, notifications: undefined })); await Monitor.bulkSave(monitors); return monitors; } catch (error) { diff --git a/server/db/mongo/modules/monitorModuleQueries.js b/server/db/mongo/modules/monitorModuleQueries.js index ab45eebb6..422362f30 100755 --- a/server/db/mongo/modules/monitorModuleQueries.js +++ b/server/db/mongo/modules/monitorModuleQueries.js @@ -31,11 +31,7 @@ const buildUptimeDetailsPipeline = (monitorId, dates, dateString) => { $project: { _id: 0, percentage: { - $cond: [ - { $eq: ["$totalChecks", 0] }, - 0, - { $divide: ["$upChecks", "$totalChecks"] }, - ], + $cond: [{ $eq: ["$totalChecks", 0] }, 0, { $divide: ["$upChecks", "$totalChecks"] }], }, }, }, @@ -235,11 +231,7 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { { $match: { $expr: { - $and: [ - { $eq: ["$monitorId", monitor._id] }, - { $gte: ["$createdAt", dates.start] }, - { $lte: ["$createdAt", dates.end] }, - ], + $and: [{ $eq: ["$monitorId", monitor._id] }, { $gte: ["$createdAt", dates.start] }, { $lte: ["$createdAt", dates.end] }], }, }, }, @@ -326,10 +318,7 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { input: "$disks", as: "diskArray", in: { - $arrayElemAt: [ - "$$diskArray.read_speed_bytes", - "$$diskIndex", - ], + $arrayElemAt: ["$$diskArray.read_speed_bytes", "$$diskIndex"], }, }, }, @@ -340,10 +329,7 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { input: "$disks", as: "diskArray", in: { - $arrayElemAt: [ - "$$diskArray.write_speed_bytes", - "$$diskIndex", - ], + $arrayElemAt: ["$$diskArray.write_speed_bytes", "$$diskIndex"], }, }, }, @@ -354,10 +340,7 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { input: "$disks", as: "diskArray", in: { - $arrayElemAt: [ - "$$diskArray.total_bytes", - "$$diskIndex", - ], + $arrayElemAt: ["$$diskArray.total_bytes", "$$diskIndex"], }, }, }, @@ -379,10 +362,7 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { input: "$disks", as: "diskArray", in: { - $arrayElemAt: [ - "$$diskArray.usage_percent", - "$$diskIndex", - ], + $arrayElemAt: ["$$diskArray.usage_percent", "$$diskIndex"], }, }, }, @@ -556,26 +536,14 @@ const buildMonitorsAndSummaryByTeamIdPipeline = ({ matchStage }) => { ]; }; -const buildMonitorsWithChecksByTeamIdPipeline = ({ - matchStage, - filter, - page, - rowsPerPage, - field, - order, - limit, - type, -}) => { +const buildMonitorsWithChecksByTeamIdPipeline = ({ matchStage, filter, page, rowsPerPage, field, order, limit, type }) => { const skip = page && rowsPerPage ? page * rowsPerPage : 0; const sort = { [field]: order === "asc" ? 1 : -1 }; const limitStage = rowsPerPage ? [{ $limit: rowsPerPage }] : []; // Match name if (typeof filter !== "undefined" && field === "name") { - matchStage.$or = [ - { name: { $regex: filter, $options: "i" } }, - { url: { $regex: filter, $options: "i" } }, - ]; + matchStage.$or = [{ name: { $regex: filter, $options: "i" } }, { url: { $regex: filter, $options: "i" } }]; } // Match isActive @@ -667,37 +635,20 @@ const buildMonitorsWithChecksByTeamIdPipeline = ({ return pipeline; }; -const buildFilteredMonitorsByTeamIdPipeline = ({ - matchStage, - filter, - page, - rowsPerPage, - field, - order, - limit, - type, -}) => { +const buildFilteredMonitorsByTeamIdPipeline = ({ matchStage, filter, page, rowsPerPage, field, order, limit, type }) => { const skip = page && rowsPerPage ? page * rowsPerPage : 0; const sort = { [field]: order === "asc" ? 1 : -1 }; const limitStage = rowsPerPage ? [{ $limit: rowsPerPage }] : []; if (typeof filter !== "undefined" && field === "name") { - matchStage.$or = [ - { name: { $regex: filter, $options: "i" } }, - { url: { $regex: filter, $options: "i" } }, - ]; + matchStage.$or = [{ name: { $regex: filter, $options: "i" } }, { url: { $regex: filter, $options: "i" } }]; } if (typeof filter !== "undefined" && field === "status") { matchStage.status = filter === "true"; } - const pipeline = [ - { $match: matchStage }, - { $sort: sort }, - { $skip: skip }, - ...limitStage, - ]; + const pipeline = [{ $match: matchStage }, { $sort: sort }, { $skip: skip }, ...limitStage]; // Add checks if (limit) { @@ -792,10 +743,7 @@ const buildGetMonitorsByTeamIdPipeline = (req) => { ? [ { $match: { - $or: [ - { name: { $regex: filter, $options: "i" } }, - { url: { $regex: filter, $options: "i" } }, - ], + $or: [{ name: { $regex: filter, $options: "i" } }, { url: { $regex: filter, $options: "i" } }], }, }, ] diff --git a/server/db/mongo/modules/networkCheckModule.js b/server/db/mongo/modules/networkCheckModule.js index 53c55c7e3..12a86e73b 100644 --- a/server/db/mongo/modules/networkCheckModule.js +++ b/server/db/mongo/modules/networkCheckModule.js @@ -32,9 +32,7 @@ const createNetworkCheck = async (networkCheckData) => { */ const getNetworkChecksByMonitorId = async (monitorId, limit = 100) => { try { - const networkChecks = await NetworkCheck.find({ monitorId }) - .sort({ createdAt: -1 }) - .limit(limit); + const networkChecks = await NetworkCheck.find({ monitorId }).sort({ createdAt: -1 }).limit(limit); return networkChecks; } catch (error) { error.service = SERVICE_NAME; diff --git a/server/db/mongo/modules/settingsModule.js b/server/db/mongo/modules/settingsModule.js index 4fd0d1634..df325d2fa 100755 --- a/server/db/mongo/modules/settingsModule.js +++ b/server/db/mongo/modules/settingsModule.js @@ -29,9 +29,7 @@ const updateAppSettings = async (newSettings) => { await AppSettings.findOneAndUpdate({}, update, { upsert: true, }); - const settings = await AppSettings.findOne() - .select("-__v -_id -createdAt -updatedAt -singleton") - .lean(); + const settings = await AppSettings.findOne().select("-__v -_id -createdAt -updatedAt -singleton").lean(); return settings; } catch (error) { error.service = SERVICE_NAME; diff --git a/server/db/mongo/modules/statusPageModule.js b/server/db/mongo/modules/statusPageModule.js index 8a682d567..c126d913f 100755 --- a/server/db/mongo/modules/statusPageModule.js +++ b/server/db/mongo/modules/statusPageModule.js @@ -48,13 +48,9 @@ const updateStatusPage = async (statusPageData, image) => { if (statusPageData.deleteSubmonitors === "true") { statusPageData.subMonitors = []; } - const statusPage = await StatusPage.findOneAndUpdate( - { url: statusPageData.url }, - statusPageData, - { - new: true, - } - ); + const statusPage = await StatusPage.findOneAndUpdate({ url: statusPageData.url }, statusPageData, { + new: true, + }); return statusPage; } catch (error) { @@ -98,19 +94,8 @@ const getStatusPage = async (url) => { } if (!preliminaryStatusPage.monitors || preliminaryStatusPage.monitors.length === 0) { - const { - _id, - color, - companyName, - isPublished, - logo, - originalMonitors, - showCharts, - showUptimePercentage, - timezone, - showAdminLoginLink, - url, - } = preliminaryStatusPage; + const { _id, color, companyName, isPublished, logo, originalMonitors, showCharts, showUptimePercentage, timezone, showAdminLoginLink, url } = + preliminaryStatusPage; return { statusPage: { _id, diff --git a/server/db/mongo/modules/userModule.js b/server/db/mongo/modules/userModule.js index 4069be8c6..ffac2e4c0 100755 --- a/server/db/mongo/modules/userModule.js +++ b/server/db/mongo/modules/userModule.js @@ -16,11 +16,7 @@ const SERVICE_NAME = "userModule"; * @returns {Promise} * @throws {Error} */ -const insertUser = async ( - userData, - imageFile, - generateAvatarImage = GenerateAvatarImage -) => { +const insertUser = async (userData, imageFile, generateAvatarImage = GenerateAvatarImage) => { const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); try { if (imageFile) { @@ -47,9 +43,7 @@ const insertUser = async ( const newUser = new UserModel(userData); await newUser.save(); - return await UserModel.findOne({ _id: newUser._id }) - .select("-password") - .select("-profileImage"); // .select() doesn't work with create, need to save then find + return await UserModel.findOne({ _id: newUser._id }).select("-password").select("-profileImage"); // .select() doesn't work with create, need to save then find } catch (error) { if (error.code === DUPLICATE_KEY_CODE) { error.message = stringService.dbUserExists; @@ -98,12 +92,7 @@ const getUserByEmail = async (email) => { * @throws {Error} */ -const updateUser = async ( - req, - res, - parseBoolean = ParseBoolean, - generateAvatarImage = GenerateAvatarImage -) => { +const updateUser = async (req, res, parseBoolean = ParseBoolean, generateAvatarImage = GenerateAvatarImage) => { const candidateUserId = req.params.userId; try { const candidateUser = { ...req.body }; @@ -220,13 +209,4 @@ const logoutUser = async (userId) => { } }; -export { - insertUser, - getUserByEmail, - updateUser, - deleteUser, - deleteTeam, - deleteAllOtherUsers, - getAllUsers, - logoutUser, -}; +export { insertUser, getUserByEmail, updateUser, deleteUser, deleteTeam, deleteAllOtherUsers, getAllUsers, logoutUser }; diff --git a/server/index.js b/server/index.js index 4b74ef46c..e8aff74d6 100755 --- a/server/index.js +++ b/server/index.js @@ -99,9 +99,7 @@ const SHUTDOWN_TIMEOUT = 1000; let isShuttingDown = false; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const openApiSpec = JSON.parse( - fs.readFileSync(path.join(__dirname, "openapi.json"), "utf8") -); +const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "openapi.json"), "utf8")); const frontendPath = path.join(__dirname, "public"); @@ -156,25 +154,8 @@ const startApp = async () => { // Set allowed origin const allowedOrigin = appSettings.clientHost; - const networkService = new NetworkService( - axios, - ping, - logger, - http, - Docker, - net, - stringService, - settingsService - ); - const emailService = new EmailService( - settingsService, - fs, - path, - compile, - mjml2html, - nodemailer, - logger - ); + const networkService = new NetworkService(axios, ping, logger, http, Docker, net, stringService, settingsService); + const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); const bufferService = new BufferService({ db, logger }); const statusService = new StatusService({ db, logger, buffer: bufferService }); const notificationUtils = new NotificationUtils({ @@ -292,17 +273,11 @@ const startApp = async () => { ServiceRegistry.get(StringService.SERVICE_NAME) ); - const queueController = new QueueController( - ServiceRegistry.get(JobQueue.SERVICE_NAME), - ServiceRegistry.get(StringService.SERVICE_NAME) - ); + const queueController = new QueueController(ServiceRegistry.get(JobQueue.SERVICE_NAME), ServiceRegistry.get(StringService.SERVICE_NAME)); const logController = new LogController(logger); - const statusPageController = new StatusPageController( - ServiceRegistry.get(MongoDB.SERVICE_NAME), - ServiceRegistry.get(StringService.SERVICE_NAME) - ); + const statusPageController = new StatusPageController(ServiceRegistry.get(MongoDB.SERVICE_NAME), ServiceRegistry.get(StringService.SERVICE_NAME)); const notificationController = new NotificationController({ notificationService: ServiceRegistry.get(NotificationService.SERVICE_NAME), @@ -311,9 +286,7 @@ const startApp = async () => { db: ServiceRegistry.get(MongoDB.SERVICE_NAME), }); - const diagnosticController = new DiagnosticController( - ServiceRegistry.get(MongoDB.SERVICE_NAME) - ); + const diagnosticController = new DiagnosticController(ServiceRegistry.get(MongoDB.SERVICE_NAME)); //Create routes const authRoutes = new AuthRoutes(authController); @@ -321,9 +294,7 @@ const startApp = async () => { const settingsRoutes = new SettingsRoutes(settingsController); const checkRoutes = new CheckRoutes(checkController); const inviteRoutes = new InviteRoutes(inviteController); - const maintenanceWindowRoutes = new MaintenanceWindowRoutes( - maintenanceWindowController - ); + const maintenanceWindowRoutes = new MaintenanceWindowRoutes(maintenanceWindowController); const queueRoutes = new QueueRoutes(queueController); const logRoutes = new LogRoutes(logController); const statusPageRoutes = new StatusPageRoutes(statusPageController); diff --git a/server/middleware/isAllowed.js b/server/middleware/isAllowed.js index 4c6f89aa3..27fb875ed 100755 --- a/server/middleware/isAllowed.js +++ b/server/middleware/isAllowed.js @@ -29,9 +29,7 @@ const isAllowed = (allowedRoles) => { // Parse the token try { const parsedToken = token.slice(TOKEN_PREFIX.length, token.length); - const { jwtSecret } = ServiceRegistry.get( - SettingsService.SERVICE_NAME - ).getSettings(); + const { jwtSecret } = ServiceRegistry.get(SettingsService.SERVICE_NAME).getSettings(); var decoded = jwt.verify(parsedToken, jwtSecret); const userRoles = decoded.role; diff --git a/server/middleware/languageMiddleware.js b/server/middleware/languageMiddleware.js index 1449e203f..effd49cc7 100755 --- a/server/middleware/languageMiddleware.js +++ b/server/middleware/languageMiddleware.js @@ -1,28 +1,27 @@ import logger from "../utils/logger.js"; -const languageMiddleware = - (stringService, translationService) => async (req, res, next) => { - try { - const acceptLanguage = req.headers["accept-language"] || "en"; - const language = acceptLanguage.split(",")[0].slice(0, 2).toLowerCase(); +const languageMiddleware = (stringService, translationService) => async (req, res, next) => { + try { + const acceptLanguage = req.headers["accept-language"] || "en"; + const language = acceptLanguage.split(",")[0].slice(0, 2).toLowerCase(); - translationService.setLanguage(language); - stringService.setLanguage(language); + translationService.setLanguage(language); + stringService.setLanguage(language); - next(); - } catch (error) { - logger.error({ - message: error.message, - service: "languageMiddleware", - }); - const acceptLanguage = req.headers["accept-language"] || "en"; - const language = acceptLanguage.split(",")[0].slice(0, 2).toLowerCase(); + next(); + } catch (error) { + logger.error({ + message: error.message, + service: "languageMiddleware", + }); + const acceptLanguage = req.headers["accept-language"] || "en"; + const language = acceptLanguage.split(",")[0].slice(0, 2).toLowerCase(); - translationService.setLanguage(language); - stringService.setLanguage(language); + translationService.setLanguage(language); + stringService.setLanguage(language); - next(); - } - }; + next(); + } +}; export default languageMiddleware; diff --git a/server/middleware/verifyJWT.js b/server/middleware/verifyJWT.js index bab832502..46611c0dd 100755 --- a/server/middleware/verifyJWT.js +++ b/server/middleware/verifyJWT.js @@ -39,10 +39,7 @@ const verifyJWT = (req, res, next) => { const { jwtSecret } = ServiceRegistry.get(SettingsService.SERVICE_NAME).getSettings(); jwt.verify(parsedToken, jwtSecret, (err, decoded) => { if (err) { - const errorMessage = - err.name === "TokenExpiredError" - ? stringService.expiredAuthToken - : stringService.invalidAuthToken; + const errorMessage = err.name === "TokenExpiredError" ? stringService.expiredAuthToken : stringService.invalidAuthToken; err.details = { msg: errorMessage }; err.status = 401; err.service = SERVICE_NAME; diff --git a/server/middleware/verifySuperAdmin.js b/server/middleware/verifySuperAdmin.js index 98c336fad..a5a93eede 100755 --- a/server/middleware/verifySuperAdmin.js +++ b/server/middleware/verifySuperAdmin.js @@ -47,9 +47,7 @@ const verifySuperAdmin = (req, res, next) => { stack: err.stack, details: stringService.invalidAuthToken, }); - return res - .status(401) - .json({ success: false, msg: stringService.invalidAuthToken }); + return res.status(401).json({ success: false, msg: stringService.invalidAuthToken }); } if (decoded.role.includes("superadmin") === false) { diff --git a/server/openapi.json b/server/openapi.json index 7b7bb3cb3..465bf948a 100755 --- a/server/openapi.json +++ b/server/openapi.json @@ -100,14 +100,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "firstName", - "lastName", - "email", - "password", - "role", - "teamId" - ], + "required": ["firstName", "lastName", "email", "password", "role", "teamId"], "properties": { "firstName": { "type": "string" diff --git a/server/routes/announcementsRoute.js b/server/routes/announcementsRoute.js index 0b234cb96..011dfac4a 100755 --- a/server/routes/announcementsRoute.js +++ b/server/routes/announcementsRoute.js @@ -15,12 +15,7 @@ class AnnouncementRoutes { * @desc Create a new announcement * @access Private (Requires JWT verification) */ - this.router.post( - "/", - verifyJWT, - isAllowed(["admin", "superadmin"]), - this.announcementController.createAnnouncement - ); + this.router.post("/", verifyJWT, isAllowed(["admin", "superadmin"]), this.announcementController.createAnnouncement); /** * @route GET / diff --git a/server/routes/checkRoute.js b/server/routes/checkRoute.js index 7165d73f1..57d79c7a2 100755 --- a/server/routes/checkRoute.js +++ b/server/routes/checkRoute.js @@ -15,34 +15,14 @@ class CheckRoutes { initRoutes() { this.router.get("/team/summary", this.checkController.getChecksSummaryByTeamId); this.router.get("/team", this.checkController.getChecksByTeam); - this.router.put( - "/team/ttl", - isAllowed(["admin", "superadmin"]), - this.checkController.updateChecksTTL - ); - this.router.delete( - "/team", - isAllowed(["admin", "superadmin"]), - this.checkController.deleteChecksByTeamId - ); + this.router.put("/team/ttl", isAllowed(["admin", "superadmin"]), this.checkController.updateChecksTTL); + this.router.delete("/team", isAllowed(["admin", "superadmin"]), this.checkController.deleteChecksByTeamId); - this.router.put( - "/check/:checkId", - verifyTeamAccess(Check, "checkId"), - this.checkController.ackCheck - ); + this.router.put("/check/:checkId", verifyTeamAccess(Check, "checkId"), this.checkController.ackCheck); this.router.get("/:monitorId", this.checkController.getChecksByMonitor); - this.router.post( - "/:monitorId", - verifyOwnership(Monitor, "monitorId"), - this.checkController.createCheck - ); - this.router.delete( - "/:monitorId", - verifyOwnership(Monitor, "monitorId"), - this.checkController.deleteChecks - ); + this.router.post("/:monitorId", verifyOwnership(Monitor, "monitorId"), this.checkController.createCheck); + this.router.delete("/:monitorId", verifyOwnership(Monitor, "monitorId"), this.checkController.deleteChecks); this.router.put("/:path/:monitorId?", this.checkController.ackAllChecks); } diff --git a/server/routes/diagnosticRoute.js b/server/routes/diagnosticRoute.js index 920eddebf..28f31a622 100755 --- a/server/routes/diagnosticRoute.js +++ b/server/routes/diagnosticRoute.js @@ -9,10 +9,7 @@ class DiagnosticRoutes { initRoutes() { this.router.post("/db/stats", this.diagnosticController.getDbStats); this.router.get("/system", this.diagnosticController.getSystemStats); - this.router.get( - "/db/get-monitors-by-team-id/:teamId", - this.diagnosticController.getMonitorsByTeamIdExecutionStats - ); + this.router.get("/db/get-monitors-by-team-id/:teamId", this.diagnosticController.getMonitorsByTeamIdExecutionStats); } getRouter() { diff --git a/server/routes/inviteRoute.js b/server/routes/inviteRoute.js index 4c14400ba..c50eae9e3 100755 --- a/server/routes/inviteRoute.js +++ b/server/routes/inviteRoute.js @@ -12,12 +12,7 @@ class InviteRoutes { initRoutes() { this.router.post("/send", this.inviteController.sendInviteEmail); this.router.post("/verify", this.inviteController.inviteVerifyController); - this.router.post( - "/", - verifyJWT, - isAllowed(["admin", "superadmin"]), - this.inviteController.getInviteToken - ); + this.router.post("/", verifyJWT, isAllowed(["admin", "superadmin"]), this.inviteController.getInviteToken); } getRouter() { diff --git a/server/routes/maintenanceWindowRoute.js b/server/routes/maintenanceWindowRoute.js index 03160ad76..7ba3ac855 100755 --- a/server/routes/maintenanceWindowRoute.js +++ b/server/routes/maintenanceWindowRoute.js @@ -13,23 +13,11 @@ class MaintenanceWindowRoutes { this.router.post("/", this.mwController.createMaintenanceWindows); this.router.get("/team/", this.mwController.getMaintenanceWindowsByTeamId); - this.router.get( - "/monitor/:monitorId", - verifyOwnership(Monitor, "monitorId"), - this.mwController.getMaintenanceWindowsByMonitorId - ); + this.router.get("/monitor/:monitorId", verifyOwnership(Monitor, "monitorId"), this.mwController.getMaintenanceWindowsByMonitorId); this.router.get("/:id", this.mwController.getMaintenanceWindowById); - this.router.put( - "/:id", - verifyTeamAccess(MaintenanceWindow, "id"), - this.mwController.editMaintenanceWindow - ); - this.router.delete( - "/:id", - verifyTeamAccess(MaintenanceWindow, "id"), - this.mwController.deleteMaintenanceWindow - ); + this.router.put("/:id", verifyTeamAccess(MaintenanceWindow, "id"), this.mwController.editMaintenanceWindow); + this.router.delete("/:id", verifyTeamAccess(MaintenanceWindow, "id"), this.mwController.deleteMaintenanceWindow); } getRouter() { diff --git a/server/routes/notificationRoute.js b/server/routes/notificationRoute.js index 9d3e95e63..6c77d8519 100755 --- a/server/routes/notificationRoute.js +++ b/server/routes/notificationRoute.js @@ -20,17 +20,9 @@ class NotificationRoutes { this.router.get("/team", this.notificationController.getNotificationsByTeamId); - this.router.delete( - "/:id", - verifyOwnership(Notification, "id"), - this.notificationController.deleteNotification - ); + this.router.delete("/:id", verifyOwnership(Notification, "id"), this.notificationController.deleteNotification); this.router.get("/:id", this.notificationController.getNotificationById); - this.router.put( - "/:id", - verifyTeamAccess(Notification, "id"), - this.notificationController.editNotification - ); + this.router.put("/:id", verifyTeamAccess(Notification, "id"), this.notificationController.editNotification); } getRouter() { diff --git a/server/routes/queueRoute.js b/server/routes/queueRoute.js index e54378d7a..55e829b91 100755 --- a/server/routes/queueRoute.js +++ b/server/routes/queueRoute.js @@ -7,37 +7,13 @@ class QueueRoutes { this.initRoutes(); } initRoutes() { - this.router.get( - "/jobs", - isAllowed(["admin", "superadmin"]), - this.queueController.getJobs - ); - this.router.post( - "/jobs", - isAllowed(["admin", "superadmin"]), - this.queueController.addJob - ); + this.router.get("/jobs", isAllowed(["admin", "superadmin"]), this.queueController.getJobs); + this.router.post("/jobs", isAllowed(["admin", "superadmin"]), this.queueController.addJob); - this.router.get( - "/metrics", - isAllowed(["admin", "superadmin"]), - this.queueController.getMetrics - ); - this.router.get( - "/health", - isAllowed(["admin", "superadmin"]), - this.queueController.checkQueueHealth - ); - this.router.get( - "/all-metrics", - isAllowed(["admin", "superadmin"]), - this.queueController.getAllMetrics - ); - this.router.post( - "/flush", - isAllowed(["admin", "superadmin"]), - this.queueController.flushQueue - ); + this.router.get("/metrics", isAllowed(["admin", "superadmin"]), this.queueController.getMetrics); + this.router.get("/health", isAllowed(["admin", "superadmin"]), this.queueController.checkQueueHealth); + this.router.get("/all-metrics", isAllowed(["admin", "superadmin"]), this.queueController.getAllMetrics); + this.router.post("/flush", isAllowed(["admin", "superadmin"]), this.queueController.flushQueue); } getRouter() { diff --git a/server/routes/statusPageRoute.js b/server/routes/statusPageRoute.js index b348e9f82..53eb21fba 100755 --- a/server/routes/statusPageRoute.js +++ b/server/routes/statusPageRoute.js @@ -14,18 +14,8 @@ class StatusPageRoutes { this.router.get("/", this.statusPageController.getStatusPage); this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId); - this.router.post( - "/", - upload.single("logo"), - verifyJWT, - this.statusPageController.createStatusPage - ); - this.router.put( - "/", - upload.single("logo"), - verifyJWT, - this.statusPageController.updateStatusPage - ); + this.router.post("/", upload.single("logo"), verifyJWT, this.statusPageController.createStatusPage); + this.router.put("/", upload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage); this.router.get("/:url", this.statusPageController.getStatusPageByUrl); this.router.delete("/:url(*)", verifyJWT, this.statusPageController.deleteStatusPage); diff --git a/server/service/JobQueue/JobQueue.js b/server/service/JobQueue/JobQueue.js index 38cca7d4d..6e316c616 100644 --- a/server/service/JobQueue/JobQueue.js +++ b/server/service/JobQueue/JobQueue.js @@ -150,11 +150,7 @@ class JobQueue { }; const schedulerId = getSchedulerId(monitor); - await queue.upsertJobScheduler( - schedulerId, - { every: monitor?.interval ?? 60000 }, - jobTemplate - ); + await queue.upsertJobScheduler(schedulerId, { every: monitor?.interval ?? 60000 }, jobTemplate); } async deleteJob(monitor) { diff --git a/server/service/JobQueue/JobQueueHelper.js b/server/service/JobQueue/JobQueueHelper.js index d286b7b14..4107966e8 100644 --- a/server/service/JobQueue/JobQueueHelper.js +++ b/server/service/JobQueue/JobQueueHelper.js @@ -1,16 +1,7 @@ const SERVICE_NAME = "JobQueueHelper"; class JobQueueHelper { - constructor({ - redisService, - Queue, - Worker, - logger, - db, - networkService, - statusService, - notificationService, - }) { + constructor({ redisService, Queue, Worker, logger, db, networkService, statusService, notificationService }) { this.db = db; this.redisService = redisService; this.Queue = Queue; @@ -265,11 +256,7 @@ class JobQueueHelper { // Handle status change await job.updateProgress(60); - const { - monitor: updatedMonitor, - statusChanged, - prevStatus, - } = await this.statusService.updateStatus(networkResponse); + const { monitor: updatedMonitor, statusChanged, prevStatus } = await this.statusService.updateStatus(networkResponse); // Handle notifications await job.updateProgress(80); this.notificationService diff --git a/server/service/PulseQueue/PulseQueue.js b/server/service/PulseQueue/PulseQueue.js index 50a959559..85d14ab07 100644 --- a/server/service/PulseQueue/PulseQueue.js +++ b/server/service/PulseQueue/PulseQueue.js @@ -22,8 +22,7 @@ class PulseQueue { // **************************************** init = async () => { try { - const mongoConnectionString = - this.appSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db"; + const mongoConnectionString = this.appSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db"; this.pulse = new Pulse({ db: { address: mongoConnectionString } }); await this.pulse.start(); this.pulse.define("monitor-job", this.pulseQueueHelper.getMonitorJob(), {}); @@ -186,9 +185,7 @@ class PulseQueue { failReason: job.attrs.failReason, lastRunAt: job.attrs.lastRunAt, lastFinishedAt: job.attrs.lastFinishedAt, - lastRunTook: job.attrs.lockedAt - ? null - : job.attrs.lastFinishedAt - job.attrs.lastRunAt, + lastRunTook: job.attrs.lockedAt ? null : job.attrs.lastFinishedAt - job.attrs.lastRunAt, lastFailedAt: job.attrs.failedAt, }; }); diff --git a/server/service/PulseQueue/PulseQueueHelper.js b/server/service/PulseQueue/PulseQueueHelper.js index 959c12e3b..c3b8992d8 100644 --- a/server/service/PulseQueue/PulseQueueHelper.js +++ b/server/service/PulseQueue/PulseQueueHelper.js @@ -34,11 +34,7 @@ class PulseQueueHelper { throw new Error("No network response"); } - const { - monitor: updatedMonitor, - statusChanged, - prevStatus, - } = await this.statusService.updateStatus(networkResponse); + const { monitor: updatedMonitor, statusChanged, prevStatus } = await this.statusService.updateStatus(networkResponse); this.notificationService .handleNotifications({ diff --git a/server/service/emailService.js b/server/service/emailService.js index 433fd0871..b6b0eb4af 100755 --- a/server/service/emailService.js +++ b/server/service/emailService.js @@ -42,10 +42,7 @@ class EmailService { */ this.loadTemplate = (templateName) => { try { - const templatePath = this.path.join( - __dirname, - `../templates/${templateName}.mjml` - ); + const templatePath = this.path.join(__dirname, `../templates/${templateName}.mjml`); const templateContent = this.fs.readFileSync(templatePath, "utf8"); return this.compile(templateContent); } catch (error) { diff --git a/server/service/networkService.js b/server/service/networkService.js index 08b2f065e..70d7cc932 100755 --- a/server/service/networkService.js +++ b/server/service/networkService.js @@ -78,9 +78,7 @@ class NetworkService { async requestPing(monitor) { try { const url = monitor.url; - const { response, responseTime, error } = await this.timeRequest(() => - this.ping.promise.probe(url) - ); + const { response, responseTime, error } = await this.timeRequest(() => this.ping.promise.probe(url)); const pingResponse = { monitorId: monitor._id, @@ -133,18 +131,7 @@ class NetworkService { */ async requestHttp(monitor) { try { - const { - url, - secret, - _id, - name, - teamId, - type, - ignoreTlsErrors, - jsonPath, - matchMethod, - expectedValue, - } = monitor; + const { url, secret, _id, name, teamId, type, ignoreTlsErrors, jsonPath, matchMethod, expectedValue } = monitor; const config = {}; secret !== undefined && (config.headers = { Authorization: `Bearer ${secret}` }); @@ -155,9 +142,7 @@ class NetworkService { }); } - const { response, responseTime, error } = await this.timeRequest(() => - this.axios.get(url, config) - ); + const { response, responseTime, error } = await this.timeRequest(() => this.axios.get(url, config)); const httpResponse = { monitorId: _id, @@ -171,8 +156,7 @@ class NetworkService { const code = error.response?.status || this.NETWORK_ERROR; httpResponse.code = code; httpResponse.status = false; - httpResponse.message = - this.http.STATUS_CODES[code] || this.stringService.httpNetworkError; + httpResponse.message = this.http.STATUS_CODES[code] || this.stringService.httpNetworkError; return httpResponse; } @@ -227,9 +211,7 @@ class NetworkService { else match = result === expectedValue; httpResponse.status = match; - httpResponse.message = match - ? this.stringService.httpMatchSuccess - : this.stringService.httpMatchFail; + httpResponse.message = match ? this.stringService.httpMatchSuccess : this.stringService.httpMatchFail; return httpResponse; } catch (error) { error.service = this.SERVICE_NAME; @@ -320,9 +302,7 @@ class NetworkService { } const container = docker.getContainer(monitor.url); - const { response, responseTime, error } = await this.timeRequest(() => - container.inspect() - ); + const { response, responseTime, error } = await this.timeRequest(() => container.inspect()); const dockerResponse = { monitorId: monitor._id, @@ -333,8 +313,7 @@ class NetworkService { if (error) { dockerResponse.status = false; dockerResponse.code = error.statusCode || this.NETWORK_ERROR; - dockerResponse.message = - error.reason || "Failed to fetch Docker container information"; + dockerResponse.message = error.reason || "Failed to fetch Docker container information"; return dockerResponse; } dockerResponse.status = response?.State?.Status === "running" ? true : false; diff --git a/server/service/notificationService.js b/server/service/notificationService.js index a6048b8cd..f253cf760 100644 --- a/server/service/notificationService.js +++ b/server/service/notificationService.js @@ -3,14 +3,7 @@ const SERVICE_NAME = "NotificationService"; class NotificationService { static SERVICE_NAME = SERVICE_NAME; - constructor({ - emailService, - db, - logger, - networkService, - stringService, - notificationUtils, - }) { + constructor({ emailService, db, logger, networkService, stringService, notificationUtils }) { this.emailService = emailService; this.db = db; this.logger = logger; @@ -69,20 +62,15 @@ class NotificationService { const alerts = await this.notificationUtils.buildHardwareAlerts(networkResponse); if (alerts.length === 0) return false; - const { subject, html } = await this.notificationUtils.buildHardwareEmail( - networkResponse, - alerts - ); - const content = - await this.notificationUtils.buildHardwareNotificationMessage(alerts); + const { subject, html } = await this.notificationUtils.buildHardwareEmail(networkResponse, alerts); + const content = await this.notificationUtils.buildHardwareNotificationMessage(alerts); const success = await this.notifyAll({ notificationIDs, subject, html, content }); return success; } // Status monitors - const { subject, html } = - await this.notificationUtils.buildStatusEmail(networkResponse); + const { subject, html } = await this.notificationUtils.buildStatusEmail(networkResponse); const content = await this.notificationUtils.buildWebhookMessage(networkResponse); const success = this.notifyAll({ notificationIDs, subject, html, content }); return success; diff --git a/server/service/notificationUtils.js b/server/service/notificationUtils.js index 61c3ec36a..d070a9995 100644 --- a/server/service/notificationUtils.js +++ b/server/service/notificationUtils.js @@ -26,10 +26,7 @@ class NotificationUtils { const date = new Date(timestamp); // Get timezone abbreviation and format the date - const timeZoneAbbr = date - .toLocaleTimeString("en-US", { timeZoneName: "short" }) - .split(" ") - .pop(); + const timeZoneAbbr = date.toLocaleTimeString("en-US", { timeZoneName: "short" }).split(" ").pop(); // Format the date with readable format return ( @@ -50,9 +47,7 @@ class NotificationUtils { }; // Get formatted time - const formattedTime = timestamp - ? formatTime(timestamp) - : formatTime(new Date().getTime()); + const formattedTime = timestamp ? formatTime(timestamp) : formatTime(new Date().getTime()); // Create different messages based on status with extra spacing let messageText; @@ -73,29 +68,15 @@ class NotificationUtils { buildHardwareAlerts = async (networkResponse) => { const monitor = networkResponse?.monitor; const thresholds = networkResponse?.monitor?.thresholds; - const { - usage_cpu: cpuThreshold = -1, - usage_memory: memoryThreshold = -1, - usage_disk: diskThreshold = -1, - } = thresholds; + const { usage_cpu: cpuThreshold = -1, usage_memory: memoryThreshold = -1, usage_disk: diskThreshold = -1 } = thresholds; const metrics = networkResponse?.payload?.data; - const { - cpu: { usage_percent: cpuUsage = -1 } = {}, - memory: { usage_percent: memoryUsage = -1 } = {}, - disk = [], - } = metrics; + const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics; const alerts = { cpu: cpuThreshold !== -1 && cpuUsage > cpuThreshold ? true : false, memory: memoryThreshold !== -1 && memoryUsage > memoryThreshold ? true : false, - disk: - disk?.some( - (d) => - diskThreshold !== -1 && - typeof d?.usage_percent === "number" && - d?.usage_percent > diskThreshold - ) ?? false, + disk: disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold) ?? false, }; const alertsToSend = []; @@ -110,16 +91,13 @@ class NotificationUtils { monitor[`${type}AlertThreshold`] = monitor.alertThreshold; const formatAlert = { - cpu: () => - `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, + cpu: () => `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, memory: () => `Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`, disk: () => `Your current disk usage: ${disk .map((d, idx) => `(Disk${idx}: ${(d.usage_percent * 100).toFixed(0)}%)`) - .join( - ", " - )} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`, + .join(", ")} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`, }; alertsToSend.push(formatAlert[type]()); } diff --git a/server/service/settingsService.js b/server/service/settingsService.js index de035922c..d2c1a17ae 100755 --- a/server/service/settingsService.js +++ b/server/service/settingsService.js @@ -62,14 +62,10 @@ class SettingsService { // Remove any old settings await this.AppSettings.deleteMany({ version: { $exists: false } }); - let settings = await this.AppSettings.findOne({ singleton: true }) - .select("-__v -_id -createdAt -updatedAt -singleton") - .lean(); + let settings = await this.AppSettings.findOne({ singleton: true }).select("-__v -_id -createdAt -updatedAt -singleton").lean(); if (settings === null) { await this.AppSettings.create({}); - settings = await this.AppSettings.findOne({ singleton: true }) - .select("-__v -_id -createdAt -updatedAt -singleton") - .lean(); + settings = await this.AppSettings.findOne({ singleton: true }).select("-__v -_id -createdAt -updatedAt -singleton").lean(); } return settings; } diff --git a/server/service/statusService.js b/server/service/statusService.js index 3d7bb4543..1982bbb30 100755 --- a/server/service/statusService.js +++ b/server/service/statusService.js @@ -48,9 +48,7 @@ class StatusService { if (avgResponseTime === 0) { avgResponseTime = responseTime; } else { - avgResponseTime = - (avgResponseTime * (stats.totalChecks - 1) + responseTime) / - stats.totalChecks; + avgResponseTime = (avgResponseTime * (stats.totalChecks - 1) + responseTime) / stats.totalChecks; } } stats.avgResponseTime = avgResponseTime; @@ -138,9 +136,7 @@ class StatusService { // Monitor status changed, save prev status and update monitor this.logger.info({ service: this.SERVICE_NAME, - message: `${monitor.name} went from ${this.getStatusString( - monitor.status - )} to ${this.getStatusString(status)}`, + message: `${monitor.name} went from ${this.getStatusString(monitor.status)} to ${this.getStatusString(status)}`, prevStatus: monitor.status, newStatus: status, }); @@ -279,9 +275,7 @@ class StatusService { message: error.message, service: error.service || this.SERVICE_NAME, method: error.method || "insertCheck", - details: - error.details || - `Error inserting check for monitor: ${networkResponse?.monitorId}`, + details: error.details || `Error inserting check for monitor: ${networkResponse?.monitorId}`, stack: error.stack, }); } diff --git a/server/service/stringService.js b/server/service/stringService.js index 6f9ad8cd8..b88e73917 100755 --- a/server/service/stringService.js +++ b/server/service/stringService.js @@ -211,23 +211,16 @@ class StringService { } getWebhookUnsupportedPlatform(platform) { - return this.translationService - .getTranslation("webhookUnsupportedPlatform") - .replace("{platform}", platform); + return this.translationService.getTranslation("webhookUnsupportedPlatform").replace("{platform}", platform); } getWebhookSendError(platform) { - return this.translationService - .getTranslation("webhookSendError") - .replace("{platform}", platform); + return this.translationService.getTranslation("webhookSendError").replace("{platform}", platform); } getMonitorStatus(name, status, url) { const translationKey = status === true ? "monitorStatusUp" : "monitorStatusDown"; - return this.translationService - .getTranslation(translationKey) - .replace("{name}", name) - .replace("{url}", url); + return this.translationService.getTranslation(translationKey).replace("{name}", name).replace("{url}", url); } // Error Messages @@ -378,9 +371,7 @@ class StringService { } getDeletedCount(count) { - return this.translationService - .getTranslation("deletedCount") - .replace("{count}", count); + return this.translationService.getTranslation("deletedCount").replace("{count}", count); } get pingSuccess() { @@ -424,9 +415,7 @@ class StringService { } getDbFindMonitorById(monitorId) { - return this.translationService - .getTranslation("dbFindMonitorById") - .replace("${monitorId}", monitorId); + return this.translationService.getTranslation("dbFindMonitorById").replace("${monitorId}", monitorId); } get dbUserExists() { diff --git a/server/service/translationService.js b/server/service/translationService.js index de29b5eb1..38346f35d 100755 --- a/server/service/translationService.js +++ b/server/service/translationService.js @@ -38,9 +38,7 @@ class TranslationService { return false; } - const files = fs - .readdirSync(this.localesDir) - .filter((file) => file.endsWith(".json")); + const files = fs.readdirSync(this.localesDir).filter((file) => file.endsWith(".json")); if (files.length === 0) { return false; diff --git a/server/templates/addReview.mjml b/server/templates/addReview.mjml index 644970f6e..e193ac1ba 100755 --- a/server/templates/addReview.mjml +++ b/server/templates/addReview.mjml @@ -32,20 +32,15 @@

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. + 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. + 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! -

+

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

Checkmate Team
diff --git a/server/templates/passwordReset.mjml b/server/templates/passwordReset.mjml index 5da29b341..568b363ee 100755 --- a/server/templates/passwordReset.mjml +++ b/server/templates/passwordReset.mjml @@ -33,8 +33,8 @@

Hello {{ name }}!

- You are receiving this email because a password reset request has been made - for {{ email }}. Please use the link below on the site to reset your password. + You are receiving this email because a password reset request has been made for {{ email }}. Please use the link below on the site to + reset your password.

Reset Password

If you didn't request this, please ignore this email.

diff --git a/server/templates/serverIsDown.mjml b/server/templates/serverIsDown.mjml index d46011070..bf6424a6b 100755 --- a/server/templates/serverIsDown.mjml +++ b/server/templates/serverIsDown.mjml @@ -48,10 +48,7 @@

Hello {{ name }}!

-

- We detected an incident on one of your monitors. Your service is currently - down. We'll send a message to you once it is up again. -

+

We detected an incident on one of your monitors. Your service is currently down. We'll send a message to you once it is up again.

Monitor name: {{ monitor }}

URL: {{ url }}

diff --git a/server/templates/welcomeEmail.mjml b/server/templates/welcomeEmail.mjml index 372e4c8ae..e9f569e3f 100755 --- a/server/templates/welcomeEmail.mjml +++ b/server/templates/welcomeEmail.mjml @@ -31,14 +31,8 @@

Hello {{ name }}!

-

- Thank you for trying out Checkmate! We developed it with great care to meet - our own needs, and we're excited to share it with you. -

-

- Checkmate is an automated way of checking whether a service such as a website - or an application is available or not. -

+

Thank you for trying out Checkmate! We developed it with great care to meet our own needs, and we're excited to share it with you.

+

Checkmate is an automated way of checking whether a service such as a website or an application is available or not.

We hope you find our service as valuable as we do.

Thank you.

diff --git a/server/tests/controllers/authController.test.js b/server/tests/controllers/authController.test.js index 2990afe22..341c8eda6 100755 --- a/server/tests/controllers/authController.test.js +++ b/server/tests/controllers/authController.test.js @@ -28,9 +28,7 @@ describe("Auth Controller - issueToken", function () { stub = sinon.stub(jwt, "sign").throws(error); const payload = { id: "123" }; const appSettings = { jwtSecret: "my_secret" }; - expect(() => issueToken(payload, tokenType.ACCESS_TOKEN, appSettings)).to.throw( - error - ); + expect(() => issueToken(payload, tokenType.ACCESS_TOKEN, appSettings)).to.throw(error); }); it("should return a token if jwt.sign is successful and appSettings.jwtTTL is not defined", function () { @@ -163,9 +161,7 @@ describe("Auth Controller - registerUser", function () { req.db.checkSuperadmin.resolves(false); req.db.updateAppSettings.resolves(); req.db.insertUser.resolves({ _id: "123" }); - req.settingsService.getSettings.rejects( - new Error("settingsService.getSettings error") - ); + req.settingsService.getSettings.rejects(new Error("settingsService.getSettings error")); await registerUser(req, res, next); expect(next.firstCall.args[0]).to.be.an("error"); expect(next.firstCall.args[0].message).to.equal("settingsService.getSettings error"); @@ -304,9 +300,7 @@ describe("Auth Controller - loginUser", function () { user.comparePassword.resolves(false); await loginUser(req, res, next); expect(next.firstCall.args[0]).to.be.an("error"); - expect(next.firstCall.args[0].message).to.equal( - errorMessages.AUTH_INCORRECT_PASSWORD - ); + expect(next.firstCall.args[0].message).to.equal(errorMessages.AUTH_INCORRECT_PASSWORD); }); }); @@ -370,9 +364,7 @@ describe("Auth Controller - refreshAuthToken", function () { }); it("should reject if settingsService.getSettings fails", async function () { - req.settingsService.getSettings.rejects( - new Error("settingsService.getSettings error") - ); + req.settingsService.getSettings.rejects(new Error("settingsService.getSettings error")); await refreshAuthToken(req, res, next); expect(next.firstCall.args[0]).to.be.an("error"); @@ -475,9 +467,7 @@ describe("Auth Controller - editUser", function () { await editUser(req, res, next); expect(next.firstCall.args[0]).to.be.an("error"); expect(next.firstCall.args[0].status).to.equal(401); - expect(next.firstCall.args[0].message).to.equal( - errorMessages.AUTH_INCORRECT_PASSWORD - ); + expect(next.firstCall.args[0].message).to.equal(errorMessages.AUTH_INCORRECT_PASSWORD); }); it("should edit a user if it receives a proper request", async function () { @@ -863,8 +853,7 @@ describe("Auth Controller - deleteUser", function () { ).to.be.true; expect(req.jobQueue.deleteJob.calledOnceWith(monitors[0])).to.be.true; expect(req.db.deleteChecks.calledOnceWith("monitor_id")).to.be.true; - expect(req.db.deletePageSpeedChecksByMonitorId.calledOnceWith("monitor_id")).to.be - .true; + expect(req.db.deletePageSpeedChecksByMonitorId.calledOnceWith("monitor_id")).to.be.true; expect(req.db.deleteNotificationsByMonitorId.calledOnceWith("monitor_id")).to.be.true; expect(req.db.deleteTeam.calledOnceWith("team_id")).to.be.true; expect(req.db.deleteAllOtherUsers.calledOnce).to.be.true; diff --git a/server/tests/controllers/checkController.test.js b/server/tests/controllers/checkController.test.js index 322e6c8ca..c5fb57d50 100755 --- a/server/tests/controllers/checkController.test.js +++ b/server/tests/controllers/checkController.test.js @@ -1,11 +1,4 @@ -import { - createCheck, - getChecks, - getTeamChecks, - deleteChecks, - deleteChecksByTeamId, - updateChecksTTL, -} from "../../controllers/checkController.js"; +import { createCheck, getChecks, getTeamChecks, deleteChecks, deleteChecksByTeamId, updateChecksTTL } from "../../controllers/checkController.js"; import jwt from "jsonwebtoken"; import { errorMessages, successMessages } from "../../utils/messages.js"; import sinon from "sinon"; diff --git a/server/tests/controllers/controllerUtils.test.js b/server/tests/controllers/controllerUtils.test.js index d594e932d..a55318857 100755 --- a/server/tests/controllers/controllerUtils.test.js +++ b/server/tests/controllers/controllerUtils.test.js @@ -1,10 +1,6 @@ import sinon from "sinon"; -import { - handleValidationError, - handleError, - fetchMonitorCertificate, -} from "../../controllers/controllerUtils.js"; +import { handleValidationError, handleError, fetchMonitorCertificate } from "../../controllers/controllerUtils.js"; import { expect } from "chai"; import sslChecker from "ssl-checker"; import { afterEach } from "node:test"; diff --git a/server/tests/controllers/inviteController.test.js b/server/tests/controllers/inviteController.test.js index 3311f715e..635bdf653 100755 --- a/server/tests/controllers/inviteController.test.js +++ b/server/tests/controllers/inviteController.test.js @@ -1,7 +1,4 @@ -import { - issueInvitation, - inviteVerifyController, -} from "../../controllers/inviteController.js"; +import { issueInvitation, inviteVerifyController } from "../../controllers/inviteController.js"; import jwt from "jsonwebtoken"; import sinon from "sinon"; import joi from "joi"; diff --git a/server/tests/controllers/monitorController.test.js b/server/tests/controllers/monitorController.test.js index 5aa68b514..963862579 100755 --- a/server/tests/controllers/monitorController.test.js +++ b/server/tests/controllers/monitorController.test.js @@ -623,9 +623,7 @@ describe("Monitor Controller - deleteMonitor", function () { req.jobQueue.deleteJob.rejects(error); await deleteMonitor(req, res, next); expect(logger.error.calledOnce).to.be.true; - expect(logger.error.firstCall.args[0].message).to.equal( - `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` - ); + expect(logger.error.firstCall.args[0].message).to.equal(`Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`); }); it("should log an error if deleteChecks throws an error", async function () { @@ -635,9 +633,7 @@ describe("Monitor Controller - deleteMonitor", function () { req.db.deleteChecks.rejects(error); await deleteMonitor(req, res, next); expect(logger.error.calledOnce).to.be.true; - expect(logger.error.firstCall.args[0].message).to.equal( - `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` - ); + expect(logger.error.firstCall.args[0].message).to.equal(`Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`); }); it("should log an error if deletePageSpeedChecksByMonitorId throws an error", async function () { @@ -647,9 +643,7 @@ describe("Monitor Controller - deleteMonitor", function () { req.db.deletePageSpeedChecksByMonitorId.rejects(error); await deleteMonitor(req, res, next); expect(logger.error.calledOnce).to.be.true; - expect(logger.error.firstCall.args[0].message).to.equal( - `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` - ); + expect(logger.error.firstCall.args[0].message).to.equal(`Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`); }); it("should log an error if deleteNotificationsByMonitorId throws an error", async function () { @@ -659,9 +653,7 @@ describe("Monitor Controller - deleteMonitor", function () { req.db.deleteNotificationsByMonitorId.rejects(error); await deleteMonitor(req, res, next); expect(logger.error.calledOnce).to.be.true; - expect(logger.error.firstCall.args[0].message).to.equal( - `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` - ); + expect(logger.error.firstCall.args[0].message).to.equal(`Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`); }); it("should return success message if all operations succeed", async function () { diff --git a/server/tests/controllers/queueController.test.js b/server/tests/controllers/queueController.test.js index 72450a0bd..6201bdb5a 100755 --- a/server/tests/controllers/queueController.test.js +++ b/server/tests/controllers/queueController.test.js @@ -1,10 +1,5 @@ import { afterEach } from "node:test"; -import { - getMetrics, - getJobs, - addJob, - obliterateQueue, -} from "../../controllers/queueController.js"; +import { getMetrics, getJobs, addJob, obliterateQueue } from "../../controllers/queueController.js"; import { successMessages } from "../../utils/messages.js"; import sinon from "sinon"; diff --git a/server/tests/controllers/settingsController.test.js b/server/tests/controllers/settingsController.test.js index 0f8e628af..721c41c02 100755 --- a/server/tests/controllers/settingsController.test.js +++ b/server/tests/controllers/settingsController.test.js @@ -1,8 +1,5 @@ import { afterEach } from "node:test"; -import { - getAppSettings, - updateAppSettings, -} from "../../controllers/settingsController.js"; +import { getAppSettings, updateAppSettings } from "../../controllers/settingsController.js"; import { successMessages } from "../../utils/messages.js"; import sinon from "sinon"; diff --git a/server/tests/controllers/statusPageController.test.js b/server/tests/controllers/statusPageController.test.js index 3247ca975..cada662e0 100755 --- a/server/tests/controllers/statusPageController.test.js +++ b/server/tests/controllers/statusPageController.test.js @@ -1,8 +1,5 @@ import sinon from "sinon"; -import { - createStatusPage, - getStatusPageByUrl, -} from "../../controllers/statusPageController.js"; +import { createStatusPage, getStatusPageByUrl } from "../../controllers/statusPageController.js"; describe("statusPageController", function () { let req, res, next; diff --git a/server/tests/db/checkModule.test.js b/server/tests/db/checkModule.test.js index 7ef2f5b77..6eef12445 100755 --- a/server/tests/db/checkModule.test.js +++ b/server/tests/db/checkModule.test.js @@ -216,11 +216,7 @@ describe("checkModule", function () { const result = await getChecksCount(req); expect(result).to.equal(4); - expect(checkCountDocumentStub.firstCall.args[0]).to.have.all.keys( - "monitorId", - "createdAt", - "status" - ); + expect(checkCountDocumentStub.firstCall.args[0]).to.have.all.keys("monitorId", "createdAt", "status"); }); }); diff --git a/server/tests/db/hardwareCheckModule.test.js b/server/tests/db/hardwareCheckModule.test.js index 566c45a8e..20ce197fb 100755 --- a/server/tests/db/hardwareCheckModule.test.js +++ b/server/tests/db/hardwareCheckModule.test.js @@ -52,10 +52,7 @@ const mockMonitor = { }; describe("HardwareCheckModule", function () { - let hardwareCheckSaveStub, - hardwareCheckCountDocumentsStub, - monitorFindByIdStub, - loggerStub; + let hardwareCheckSaveStub, hardwareCheckCountDocumentsStub, monitorFindByIdStub, loggerStub; beforeEach(function () { loggerStub = sinon.stub(logger, "error"); diff --git a/server/tests/db/inviteModule.test.js b/server/tests/db/inviteModule.test.js index 9b3aab9cc..e8913c983 100755 --- a/server/tests/db/inviteModule.test.js +++ b/server/tests/db/inviteModule.test.js @@ -1,10 +1,6 @@ import sinon from "sinon"; import InviteToken from "../../db/models/InviteToken.js"; -import { - requestInviteToken, - getInviteToken, - getInviteTokenAndDelete, -} from "../../db/mongo/modules/inviteModule.js"; +import { requestInviteToken, getInviteToken, getInviteTokenAndDelete } from "../../db/mongo/modules/inviteModule.js"; import { errorMessages } from "../../utils/messages.js"; describe("Invite Module", function () { @@ -15,10 +11,7 @@ describe("Invite Module", function () { token: "123", }; const mockInviteToken = { _id: 123, time: 123 }; - let inviteTokenDeleteManyStub, - inviteTokenSaveStub, - inviteTokenFindOneStub, - inviteTokenFindOneAndDeleteStub; + let inviteTokenDeleteManyStub, inviteTokenSaveStub, inviteTokenFindOneStub, inviteTokenFindOneAndDeleteStub; beforeEach(function () { inviteTokenDeleteManyStub = sinon.stub(InviteToken, "deleteMany"); diff --git a/server/tests/db/maintenanceWindowModule.test.js b/server/tests/db/maintenanceWindowModule.test.js index 842c283c8..576c97f84 100755 --- a/server/tests/db/maintenanceWindowModule.test.js +++ b/server/tests/db/maintenanceWindowModule.test.js @@ -40,15 +40,9 @@ describe("MaintenanceWindow Module", function () { }), }), }); - maintenanceWindowFindByIdAndDeleteStub = sinon.stub( - MaintenanceWindow, - "findByIdAndDelete" - ); + maintenanceWindowFindByIdAndDeleteStub = sinon.stub(MaintenanceWindow, "findByIdAndDelete"); maintenanceWindowDeleteManyStub = sinon.stub(MaintenanceWindow, "deleteMany"); - maintenanceWindowFindByIdAndUpdateStub = sinon.stub( - MaintenanceWindow, - "findByIdAndUpdate" - ); + maintenanceWindowFindByIdAndUpdateStub = sinon.stub(MaintenanceWindow, "findByIdAndUpdate"); }); afterEach(function () { @@ -110,10 +104,7 @@ describe("MaintenanceWindow Module", function () { it("should return a list of maintenance windows and count", async function () { maintenanceWindowCountDocumentsStub.resolves(1); - const result = await getMaintenanceWindowsByTeamId( - mockMaintenanceWindow.teamId, - query - ); + const result = await getMaintenanceWindowsByTeamId(mockMaintenanceWindow.teamId, query); expect(result).to.deep.equal({ maintenanceWindows: mockMaintenanceWindows, maintenanceWindowCount: 1, @@ -123,10 +114,7 @@ describe("MaintenanceWindow Module", function () { it("should return a list of maintenance windows and count with empty query", async function () { query = undefined; maintenanceWindowCountDocumentsStub.resolves(1); - const result = await getMaintenanceWindowsByTeamId( - mockMaintenanceWindow.teamId, - query - ); + const result = await getMaintenanceWindowsByTeamId(mockMaintenanceWindow.teamId, query); expect(result).to.deep.equal({ maintenanceWindows: mockMaintenanceWindows, maintenanceWindowCount: 1, @@ -137,10 +125,7 @@ describe("MaintenanceWindow Module", function () { query.page = undefined; query.rowsPerPage = undefined; maintenanceWindowCountDocumentsStub.resolves(1); - const result = await getMaintenanceWindowsByTeamId( - mockMaintenanceWindow.teamId, - query - ); + const result = await getMaintenanceWindowsByTeamId(mockMaintenanceWindow.teamId, query); expect(result).to.deep.equal({ maintenanceWindows: mockMaintenanceWindows, maintenanceWindowCount: 1, @@ -150,10 +135,7 @@ describe("MaintenanceWindow Module", function () { it("should return a list of maintenance windows and count with field and desc order", async function () { query.order = "desc"; maintenanceWindowCountDocumentsStub.resolves(1); - const result = await getMaintenanceWindowsByTeamId( - mockMaintenanceWindow.teamId, - query - ); + const result = await getMaintenanceWindowsByTeamId(mockMaintenanceWindow.teamId, query); expect(result).to.deep.equal({ maintenanceWindows: mockMaintenanceWindows, maintenanceWindowCount: 1, @@ -163,10 +145,7 @@ describe("MaintenanceWindow Module", function () { it("should return a list of maintenance windows and count no field", async function () { query.field = undefined; maintenanceWindowCountDocumentsStub.resolves(1); - const result = await getMaintenanceWindowsByTeamId( - mockMaintenanceWindow.teamId, - query - ); + const result = await getMaintenanceWindowsByTeamId(mockMaintenanceWindow.teamId, query); expect(result).to.deep.equal({ maintenanceWindows: mockMaintenanceWindows, maintenanceWindowCount: 1, @@ -187,9 +166,7 @@ describe("MaintenanceWindow Module", function () { describe("getMaintenanceWindowsByMonitorId", function () { it("should return a list of maintenance windows", async function () { maintenanceWindowFindStub.resolves(mockMaintenanceWindows); - const result = await getMaintenanceWindowsByMonitorId( - mockMaintenanceWindow.monitorId - ); + const result = await getMaintenanceWindowsByMonitorId(mockMaintenanceWindow.monitorId); expect(result).to.deep.equal(mockMaintenanceWindows); }); @@ -225,9 +202,7 @@ describe("MaintenanceWindow Module", function () { describe("deleteMaintenanceWindowByMonitorId", function () { it("should return the number of documents deleted", async function () { maintenanceWindowDeleteManyStub.resolves({ deletedCount: 1 }); - const result = await deleteMaintenanceWindowByMonitorId( - mockMaintenanceWindow.monitorId - ); + const result = await deleteMaintenanceWindowByMonitorId(mockMaintenanceWindow.monitorId); expect(result).to.deep.equal({ deletedCount: 1 }); }); @@ -263,10 +238,7 @@ describe("MaintenanceWindow Module", function () { describe("editMaintenanceWindowById", function () { it("should return the updated maintenance window", async function () { maintenanceWindowFindByIdAndUpdateStub.resolves(mockMaintenanceWindow); - const result = await editMaintenanceWindowById( - mockMaintenanceWindow.id, - mockMaintenanceWindow - ); + const result = await editMaintenanceWindowById(mockMaintenanceWindow.id, mockMaintenanceWindow); expect(result).to.deep.equal(mockMaintenanceWindow); }); diff --git a/server/tests/db/monitorModule.test.js b/server/tests/db/monitorModule.test.js index 3d9bb5324..e59c352cf 100755 --- a/server/tests/db/monitorModule.test.js +++ b/server/tests/db/monitorModule.test.js @@ -120,12 +120,7 @@ describe("monitorModule", function () { }, ]; - const mockChecks = [ - { status: true }, - { status: true }, - { status: false }, - { status: true }, - ]; + const mockChecks = [{ status: true }, { status: true }, { status: false }, { status: true }]; monitorFindStub.resolves(mockMonitors); checkFindStub.resolves(mockChecks); @@ -166,12 +161,7 @@ describe("monitorModule", function () { }, ]; - const mockChecks = [ - { status: true }, - { status: true }, - { status: false }, - { status: true }, - ]; + const mockChecks = [{ status: true }, { status: true }, { status: false }, { status: true }]; monitorFindStub.resolves(mockMonitors); pageSpeedCheckFindStub.resolves(mockChecks); @@ -212,12 +202,7 @@ describe("monitorModule", function () { }, ]; - const mockChecks = [ - { status: true }, - { status: true }, - { status: false }, - { status: true }, - ]; + const mockChecks = [{ status: true }, { status: true }, { status: false }, { status: true }]; monitorFindStub.resolves(mockMonitors); hardwareCheckFindStub.resolves(mockChecks); @@ -400,10 +385,7 @@ describe("monitorModule", function () { }); it("should handle missing responseTime in checks", function () { - const checks = [ - { createdAt: "2024-01-01T11:30:00Z" }, - { responseTime: 200, createdAt: "2024-01-01T11:00:00Z" }, - ]; + const checks = [{ createdAt: "2024-01-01T11:30:00Z" }, { responseTime: 200, createdAt: "2024-01-01T11:00:00Z" }]; expect(getLatestResponseTime(checks)).to.equal(0); }); @@ -441,10 +423,7 @@ describe("monitorModule", function () { }); it("should return 0 when no checks have responseTime", function () { - const checks = [ - { createdAt: "2024-01-01T11:30:00Z" }, - { createdAt: "2024-01-01T11:00:00Z" }, - ]; + const checks = [{ createdAt: "2024-01-01T11:30:00Z" }, { createdAt: "2024-01-01T11:00:00Z" }]; expect(getAverageResponseTime(checks)).to.equal(0); }); @@ -470,12 +449,7 @@ describe("monitorModule", function () { }); it("should calculate correct percentage for mixed status checks", function () { - const checks = [ - { status: true }, - { status: false }, - { status: true }, - { status: true }, - ]; + const checks = [{ status: true }, { status: false }, { status: true }, { status: true }]; // 3 up out of 4 total = 75% expect(getUptimePercentage(checks)).to.equal(75); }); @@ -507,23 +481,12 @@ describe("monitorModule", function () { }); it("should count correct number of incidents for mixed status checks", function () { - const checks = [ - { status: true }, - { status: false }, - { status: true }, - { status: false }, - { status: true }, - ]; + const checks = [{ status: true }, { status: false }, { status: true }, { status: false }, { status: true }]; expect(getIncidents(checks)).to.equal(2); }); it("should handle undefined status values", function () { - const checks = [ - { status: true }, - { status: undefined }, - { status: false }, - { status: false }, - ]; + const checks = [{ status: true }, { status: undefined }, { status: false }, { status: false }]; // Only counts explicit false values expect(getIncidents(checks)).to.equal(2); }); @@ -628,12 +591,8 @@ describe("monitorModule", function () { ); // Assert - expect(result.checksAll[0].createdAt).to.be.greaterThan( - result.checksAll[1].createdAt - ); - expect(result.checksForDateRange[0].createdAt).to.be.greaterThan( - result.checksForDateRange[1].createdAt - ); + expect(result.checksAll[0].createdAt).to.be.greaterThan(result.checksAll[1].createdAt); + expect(result.checksForDateRange[0].createdAt).to.be.greaterThan(result.checksForDateRange[1].createdAt); }); }); @@ -753,10 +712,7 @@ describe("monitorModule", function () { }); it("should handle checks in same time group", function () { - const checksInSameHour = [ - { createdAt: "2024-01-15T10:15:00Z" }, - { createdAt: "2024-01-15T10:45:00Z" }, - ]; + const checksInSameHour = [{ createdAt: "2024-01-15T10:15:00Z" }, { createdAt: "2024-01-15T10:45:00Z" }]; const result = groupChecksByTime(checksInSameHour, "day"); @@ -816,11 +772,7 @@ describe("monitorModule", function () { it("should handle missing responseTime values", function () { const mockGroup = { time: "2024-01-15", - checks: [ - { status: true }, - { status: false, responseTime: 200 }, - { status: true, responseTime: undefined }, - ], + checks: [{ status: true }, { status: false, responseTime: 200 }, { status: true, responseTime: undefined }], }; const result = calculateGroupStats(mockGroup, uptimePercentageStub); @@ -1413,10 +1365,7 @@ describe("monitorModule", function () { expect(Monitor.find.firstCall.args[0]).to.deep.equal({ teamId: "team123", - $or: [ - { name: { $regex: "search", $options: "i" } }, - { url: { $regex: "search", $options: "i" } }, - ], + $or: [{ name: { $regex: "search", $options: "i" } }, { url: { $regex: "search", $options: "i" } }], }); }); @@ -1816,14 +1765,9 @@ describe("monitorModule", function () { // Assert expect(result).to.deep.equal(mockUpdatedMonitor); - sinon.assert.calledWith( - monitorFindByIdAndUpdateStub, - candidateId, - expectedUpdateData, - { - new: true, - } - ); + sinon.assert.calledWith(monitorFindByIdAndUpdateStub, candidateId, expectedUpdateData, { + new: true, + }); }); it("should return null when monitor not found", async function () { @@ -1840,12 +1784,7 @@ describe("monitorModule", function () { // Assert expect(result).to.be.null; - sinon.assert.calledWith( - monitorFindByIdAndUpdateStub, - candidateId, - { name: "Updated Monitor", notifications: undefined }, - { new: true } - ); + sinon.assert.calledWith(monitorFindByIdAndUpdateStub, candidateId, { name: "Updated Monitor", notifications: undefined }, { new: true }); }); it("should remove notifications from update data", async function () { @@ -1870,14 +1809,9 @@ describe("monitorModule", function () { await editMonitor(candidateId, candidateMonitor); // Assert - sinon.assert.calledWith( - monitorFindByIdAndUpdateStub, - candidateId, - expectedUpdateData, - { - new: true, - } - ); + sinon.assert.calledWith(monitorFindByIdAndUpdateStub, candidateId, expectedUpdateData, { + new: true, + }); }); it("should handle database errors", async function () { diff --git a/server/tests/db/notificationModule.test.js b/server/tests/db/notificationModule.test.js index 3128130f8..fbb97f33a 100755 --- a/server/tests/db/notificationModule.test.js +++ b/server/tests/db/notificationModule.test.js @@ -1,10 +1,6 @@ import sinon from "sinon"; import Notification from "../../db/models/Notification.js"; -import { - createNotification, - getNotificationsByMonitorId, - deleteNotificationsByMonitorId, -} from "../../db/mongo/modules/notificationModule.js"; +import { createNotification, getNotificationsByMonitorId, deleteNotificationsByMonitorId } from "../../db/mongo/modules/notificationModule.js"; describe("notificationModule", function () { const mockNotification = { diff --git a/server/tests/db/pageSpeedCheckModule.test.js b/server/tests/db/pageSpeedCheckModule.test.js index 5ef0e1845..45135135d 100755 --- a/server/tests/db/pageSpeedCheckModule.test.js +++ b/server/tests/db/pageSpeedCheckModule.test.js @@ -1,9 +1,6 @@ import sinon from "sinon"; import PageSpeedCheck from "../../db/models/PageSpeedCheck.js"; -import { - createPageSpeedCheck, - deletePageSpeedChecksByMonitorId, -} from "../../db/mongo/modules/pageSpeedCheckModule.js"; +import { createPageSpeedCheck, deletePageSpeedChecksByMonitorId } from "../../db/mongo/modules/pageSpeedCheckModule.js"; const mockPageSpeedCheck = { monitorId: "monitorId", diff --git a/server/tests/db/recoveryModule.test.js b/server/tests/db/recoveryModule.test.js index 3f4e11c5c..88afa257a 100755 --- a/server/tests/db/recoveryModule.test.js +++ b/server/tests/db/recoveryModule.test.js @@ -1,11 +1,7 @@ import sinon from "sinon"; import RecoveryToken from "../../db/models/RecoveryToken.js"; import User from "../../db/models/User.js"; -import { - requestRecoveryToken, - validateRecoveryToken, - resetPassword, -} from "../../db/mongo/modules/recoveryModule.js"; +import { requestRecoveryToken, validateRecoveryToken, resetPassword } from "../../db/mongo/modules/recoveryModule.js"; import { errorMessages } from "../../utils/messages.js"; const mockRecoveryToken = { @@ -44,12 +40,7 @@ const createQueryChain = (finalResult, comparePasswordResult = false) => ({ }); describe("recoveryModule", function () { - let deleteManyStub, - saveStub, - findOneStub, - userCompareStub, - userSaveStub, - userFindOneStub; + let deleteManyStub, saveStub, findOneStub, userCompareStub, userSaveStub, userFindOneStub; let req, res; beforeEach(function () { @@ -153,9 +144,7 @@ describe("recoveryModule", function () { it("should throw an error if the passwords match", async function () { findOneStub.resolves(mockRecoveryToken); saveStub.resolves(); - userFindOneStub = sinon - .stub(User, "findOne") - .returns(createQueryChain(mockUser, true)); + userFindOneStub = sinon.stub(User, "findOne").returns(createQueryChain(mockUser, true)); try { await resetPassword(req, res); } catch (error) { diff --git a/server/tests/db/settingsModule.test.js b/server/tests/db/settingsModule.test.js index 7651adb4b..9d1a80c3e 100755 --- a/server/tests/db/settingsModule.test.js +++ b/server/tests/db/settingsModule.test.js @@ -1,8 +1,5 @@ import sinon from "sinon"; -import { - getAppSettings, - updateAppSettings, -} from "../../db/mongo/modules/settingsModule.js"; +import { getAppSettings, updateAppSettings } from "../../db/mongo/modules/settingsModule.js"; import AppSettings from "../../db/models/AppSettings.js"; const mockAppSettings = { diff --git a/server/tests/db/statusPageModule.test.js b/server/tests/db/statusPageModule.test.js index 60c356aa5..54e1a287f 100755 --- a/server/tests/db/statusPageModule.test.js +++ b/server/tests/db/statusPageModule.test.js @@ -1,8 +1,5 @@ import sinon from "sinon"; -import { - createStatusPage, - getStatusPageByUrl, -} from "../../db/mongo/modules/statusPageModule.js"; +import { createStatusPage, getStatusPageByUrl } from "../../db/mongo/modules/statusPageModule.js"; import StatusPage from "../../db/models/StatusPage.js"; import { errorMessages } from "../../utils/messages.js"; diff --git a/server/tests/db/userModule.test.js b/server/tests/db/userModule.test.js index 884956995..d0f5ecb3e 100755 --- a/server/tests/db/userModule.test.js +++ b/server/tests/db/userModule.test.js @@ -157,12 +157,7 @@ describe("userModule", function () { select: sinon.stub().resolves(mockUser), }), }); - const result = await updateUser( - req, - res, - parseBooleanStub, - generateAvatarImageStub - ); + const result = await updateUser(req, res, parseBooleanStub, generateAvatarImageStub); expect(result).to.deep.equal(mockUser); }); @@ -173,12 +168,7 @@ describe("userModule", function () { select: sinon.stub().resolves(mockUser), }), }); - const result = await updateUser( - req, - res, - parseBooleanStub, - generateAvatarImageStub - ); + const result = await updateUser(req, res, parseBooleanStub, generateAvatarImageStub); expect(result).to.deep.equal(mockUser); }); diff --git a/server/tests/services/emailService.test.js b/server/tests/services/emailService.test.js index 541789d99..cca86896a 100755 --- a/server/tests/services/emailService.test.js +++ b/server/tests/services/emailService.test.js @@ -48,15 +48,7 @@ describe("EmailService - Constructor", function () { }); it("should initialize template loaders and email transporter", function () { - const emailService = new EmailService( - settingsServiceMock, - fsMock, - pathMock, - compileMock, - mjml2htmlMock, - nodemailerMock, - loggerMock - ); + const emailService = new EmailService(settingsServiceMock, fsMock, pathMock, compileMock, mjml2htmlMock, nodemailerMock, loggerMock); // Verify that the settingsService is assigned correctly expect(emailService.settingsService).to.equal(settingsServiceMock); @@ -83,15 +75,7 @@ describe("EmailService - Constructor", function () { fsMock = { readFileSync: sinon.stub().throws(new Error("File read error")), }; - const emailService = new EmailService( - settingsServiceMock, - fsMock, - pathMock, - compileMock, - mjml2htmlMock, - nodemailerMock, - loggerMock - ); + const emailService = new EmailService(settingsServiceMock, fsMock, pathMock, compileMock, mjml2htmlMock, nodemailerMock, loggerMock); expect(loggerMock.error.called).to.be.true; expect(loggerMock.error.firstCall.args[0].message).to.equal("File read error"); }); @@ -139,15 +123,7 @@ describe("EmailService - buildAndSendEmail", function () { error: sinon.stub(), }; - emailService = new EmailService( - settingsServiceMock, - fsMock, - pathMock, - compileMock, - mjml2htmlMock, - nodemailerMock, - loggerMock - ); + emailService = new EmailService(settingsServiceMock, fsMock, pathMock, compileMock, mjml2htmlMock, nodemailerMock, loggerMock); }); afterEach(function () { @@ -155,12 +131,7 @@ describe("EmailService - buildAndSendEmail", function () { }); it("should build and send email successfully", async function () { - const messageId = await emailService.buildAndSendEmail( - "welcomeEmailTemplate", - {}, - "recipient@example.com", - "Welcome" - ); + const messageId = await emailService.buildAndSendEmail("welcomeEmailTemplate", {}, "recipient@example.com", "Welcome"); expect(messageId).to.equal("12345"); expect(nodemailerMock.createTransport().sendMail.calledOnce).to.be.true; @@ -169,24 +140,14 @@ describe("EmailService - buildAndSendEmail", function () { it("should log error if building HTML fails", async function () { mjml2htmlMock.throws(new Error("MJML error")); - const messageId = await emailService.buildAndSendEmail( - "welcomeEmailTemplate", - {}, - "recipient@example.com", - "Welcome" - ); + const messageId = await emailService.buildAndSendEmail("welcomeEmailTemplate", {}, "recipient@example.com", "Welcome"); expect(loggerMock.error.calledOnce).to.be.true; expect(loggerMock.error.getCall(0).args[0].message).to.equal("MJML error"); }); it("should log error if sending email fails", async function () { nodemailerMock.createTransport().sendMail.rejects(new Error("SMTP error")); - await emailService.buildAndSendEmail( - "welcomeEmailTemplate", - {}, - "recipient@example.com", - "Welcome" - ); + await emailService.buildAndSendEmail("welcomeEmailTemplate", {}, "recipient@example.com", "Welcome"); expect(loggerMock.error.calledOnce).to.be.true; expect(loggerMock.error.getCall(0).args[0].message).to.equal("SMTP error"); }); @@ -195,12 +156,7 @@ describe("EmailService - buildAndSendEmail", function () { mjml2htmlMock.throws(new Error("MJML error")); nodemailerMock.createTransport().sendMail.rejects(new Error("SMTP error")); - const messageId = await emailService.buildAndSendEmail( - "welcomeEmailTemplate", - {}, - "recipient@example.com", - "Welcome" - ); + const messageId = await emailService.buildAndSendEmail("welcomeEmailTemplate", {}, "recipient@example.com", "Welcome"); expect(messageId).to.be.undefined; expect(loggerMock.error.calledTwice).to.be.true; diff --git a/server/tests/services/jobQueue.test.js b/server/tests/services/jobQueue.test.js index 88d57f339..155f9119e 100755 --- a/server/tests/services/jobQueue.test.js +++ b/server/tests/services/jobQueue.test.js @@ -52,13 +52,7 @@ class WorkerStub { } describe("JobQueue", function () { - let settingsService, - logger, - db, - networkService, - statusService, - notificationService, - jobQueue; + let settingsService, logger, db, networkService, statusService, notificationService, jobQueue; beforeEach(async function () { settingsService = { getSettings: sinon.stub() }; @@ -71,16 +65,7 @@ describe("JobQueue", function () { getMaintenanceWindowsByMonitorId: sinon.stub().returns([]), }; networkService = { getStatus: sinon.stub() }; - jobQueue = await JobQueue.createJobQueue( - db, - networkService, - statusService, - notificationService, - settingsService, - logger, - QueueStub, - WorkerStub - ); + jobQueue = await JobQueue.createJobQueue(db, networkService, statusService, notificationService, settingsService, logger, QueueStub, WorkerStub); }); afterEach(function () { @@ -327,9 +312,7 @@ describe("JobQueue", function () { const handler = jobQueue.createJobHandler(); await handler({ data: { _id: 1 } }); expect(logger.info.calledOnce).to.be.true; - expect(logger.info.firstCall.args[0].message).to.equal( - "Monitor 1 is in maintenance window" - ); + expect(logger.info.firstCall.args[0].message).to.equal("Monitor 1 is in maintenance window"); }); it("should return if status has not changed", async function () { @@ -362,9 +345,7 @@ describe("JobQueue", function () { WorkerStub ); jobQueue.isInMaintenanceWindow = sinon.stub().returns(false); - statusService.updateStatus = sinon - .stub() - .returns({ statusChanged: true, prevStatus: undefined }); + statusService.updateStatus = sinon.stub().returns({ statusChanged: true, prevStatus: undefined }); const handler = jobQueue.createJobHandler(); await handler({ data: { _id: 1 } }); expect(jobQueue.notificationService.handleNotifications.notCalled).to.be.true; @@ -382,9 +363,7 @@ describe("JobQueue", function () { WorkerStub ); jobQueue.isInMaintenanceWindow = sinon.stub().returns(false); - statusService.updateStatus = sinon - .stub() - .returns({ statusChanged: true, prevStatus: false }); + statusService.updateStatus = sinon.stub().returns({ statusChanged: true, prevStatus: false }); const handler = jobQueue.createJobHandler(); await handler({ data: { _id: 1 } }); expect(jobQueue.notificationService.handleNotifications.calledOnce).to.be.true; diff --git a/server/tests/services/networkService.test.js b/server/tests/services/networkService.test.js index 44ddf8576..cb48ed9b2 100755 --- a/server/tests/services/networkService.test.js +++ b/server/tests/services/networkService.test.js @@ -26,9 +26,7 @@ describe("Network Service", function () { }; ping = { promise: { - probe: sinon - .stub() - .resolves({ response: { alive: true }, responseTime: 100, alive: true }), + probe: sinon.stub().resolves({ response: { alive: true }, responseTime: 100, alive: true }), }, }; logger = { error: sinon.stub() }; @@ -73,9 +71,7 @@ describe("Network Service", function () { it("should return a response object if ping unsuccessful", async function () { const error = new Error("Test error"); - networkService.timeRequest = sinon - .stub() - .resolves({ response: null, responseTime: 1, error }); + networkService.timeRequest = sinon.stub().resolves({ response: null, responseTime: 1, error }); const pingResult = await networkService.requestPing({ data: { url: "http://test.com", _id: "123" }, }); @@ -113,9 +109,7 @@ describe("Network Service", function () { it("should return a response object if http unsuccessful", async function () { const error = new Error("Test error"); error.response = { status: 404 }; - networkService.timeRequest = sinon - .stub() - .resolves({ response: null, responseTime: 1, error }); + networkService.timeRequest = sinon.stub().resolves({ response: null, responseTime: 1, error }); const job = { data: { url: "http://test.com", _id: "123", type: "http" } }; const httpResult = await networkService.requestHttp(job); expect(httpResult.monitorId).to.equal("123"); @@ -128,9 +122,7 @@ describe("Network Service", function () { it("should return a response object if http unsuccessful with unknown code", async function () { const error = new Error("Test error"); error.response = {}; - networkService.timeRequest = sinon - .stub() - .resolves({ response: null, responseTime: 1, error }); + networkService.timeRequest = sinon.stub().resolves({ response: null, responseTime: 1, error }); const job = { data: { url: "http://test.com", _id: "123", type: "http" } }; const httpResult = await networkService.requestHttp(job); expect(httpResult.monitorId).to.equal("123"); @@ -167,9 +159,7 @@ describe("Network Service", function () { it("should return a response object if pagespeed unsuccessful", async function () { const error = new Error("Test error"); error.response = { status: 404 }; - networkService.timeRequest = sinon - .stub() - .resolves({ response: null, responseTime: 1, error }); + networkService.timeRequest = sinon.stub().resolves({ response: null, responseTime: 1, error }); const job = { data: { url: "http://test.com", _id: "123", type: "pagespeed" } }; const pagespeedResult = await networkService.requestPagespeed(job); expect(pagespeedResult.monitorId).to.equal("123"); @@ -182,9 +172,7 @@ describe("Network Service", function () { it("should return a response object if pagespeed unsuccessful with an unknown code", async function () { const error = new Error("Test error"); error.response = {}; - networkService.timeRequest = sinon - .stub() - .resolves({ response: null, responseTime: 1, error }); + networkService.timeRequest = sinon.stub().resolves({ response: null, responseTime: 1, error }); const job = { data: { url: "http://test.com", _id: "123", type: "pagespeed" } }; const pagespeedResult = await networkService.requestPagespeed(job); expect(pagespeedResult.monitorId).to.equal("123"); @@ -237,9 +225,7 @@ describe("Network Service", function () { it("should return a response object if hardware unsuccessful", async function () { const error = new Error("Test error"); error.response = { status: 404 }; - networkService.timeRequest = sinon - .stub() - .resolves({ response: null, responseTime: 1, error }); + networkService.timeRequest = sinon.stub().resolves({ response: null, responseTime: 1, error }); const job = { data: { url: "http://test.com", _id: "123", type: "hardware" } }; const httpResult = await networkService.requestHardware(job); expect(httpResult.monitorId).to.equal("123"); @@ -252,9 +238,7 @@ describe("Network Service", function () { it("should return a response object if hardware unsuccessful with unknown code", async function () { const error = new Error("Test error"); error.response = {}; - networkService.timeRequest = sinon - .stub() - .resolves({ response: null, responseTime: 1, error }); + networkService.timeRequest = sinon.stub().resolves({ response: null, responseTime: 1, error }); const job = { data: { url: "http://test.com", _id: "123", type: "hardware" } }; const httpResult = await networkService.requestHardware(job); expect(httpResult.monitorId).to.equal("123"); diff --git a/server/tests/services/notificationService.test.js b/server/tests/services/notificationService.test.js index 207b40db0..211cc8b46 100755 --- a/server/tests/services/notificationService.test.js +++ b/server/tests/services/notificationService.test.js @@ -43,10 +43,7 @@ describe("NotificationService", function () { await notificationService.sendEmail(networkResponse, address); expect(notificationService.emailService.buildAndSendEmail.calledOnce).to.be.true; expect( - notificationService.emailService.buildAndSendEmail.calledWith( - "serverIsUpTemplate", - { monitor: "Test Monitor", url: "http://test.com" } - ) + notificationService.emailService.buildAndSendEmail.calledWith("serverIsUpTemplate", { monitor: "Test Monitor", url: "http://test.com" }) ); }); @@ -132,22 +129,14 @@ describe("NotificationService", function () { it("should send an email notification with Hardware Template", async function () { emailService.buildAndSendEmail.resolves(true); - const res = await notificationService.sendHardwareEmail( - networkResponse, - address, - alerts - ); + const res = await notificationService.sendHardwareEmail(networkResponse, address, alerts); expect(res).to.be.true; }); it("should return false if no alerts are provided", async function () { alerts = []; emailService.buildAndSendEmail.resolves(true); - const res = await notificationService.sendHardwareEmail( - networkResponse, - address, - alerts - ); + const res = await notificationService.sendHardwareEmail(networkResponse, address, alerts); expect(res).to.be.false; }); }); @@ -172,9 +161,7 @@ describe("NotificationService", function () { }); it("should handle status notifications", async function () { - db.getNotificationsByMonitorId.resolves([ - { type: "email", address: "test@test.com" }, - ]); + db.getNotificationsByMonitorId.resolves([{ type: "email", address: "test@test.com" }]); const res = await notificationService.handleStatusNotifications(networkResponse); expect(res).to.be.true; }); @@ -247,15 +234,13 @@ describe("NotificationService", function () { describe("it should return false if no thresholds are set", function () { it("should return false if no thresholds are set", async function () { networkResponse.monitor.thresholds = undefined; - const res = - await notificationService.handleHardwareNotifications(networkResponse); + const res = await notificationService.handleHardwareNotifications(networkResponse); expect(res).to.be.false; }); it("should return false if metrics are null", async function () { networkResponse.payload.data = null; - const res = - await notificationService.handleHardwareNotifications(networkResponse); + const res = await notificationService.handleHardwareNotifications(networkResponse); expect(res).to.be.false; }); @@ -271,8 +256,7 @@ describe("NotificationService", function () { save: sinon.stub().resolves(), }, ]); - const res = - await notificationService.handleHardwareNotifications(networkResponse); + const res = await notificationService.handleHardwareNotifications(networkResponse); expect(res).to.be.true; }); @@ -293,8 +277,7 @@ describe("NotificationService", function () { usage_memory: 0.01, usage_disk: 0.01, }; - const res = - await notificationService.handleHardwareNotifications(networkResponse); + const res = await notificationService.handleHardwareNotifications(networkResponse); expect(res).to.be.true; }); }); diff --git a/server/tests/services/settingsService.test.js b/server/tests/services/settingsService.test.js index 3e04ced38..6c9960e5e 100755 --- a/server/tests/services/settingsService.test.js +++ b/server/tests/services/settingsService.test.js @@ -13,9 +13,7 @@ describe("SettingsService", function () { sandbox.stub(process.env, "JWT_SECRET").value("secret"); sandbox.stub(process.env, "REFRESH_TOKEN_SECRET").value("refreshSecret"); sandbox.stub(process.env, "DB_TYPE").value("postgres"); - sandbox - .stub(process.env, "DB_CONNECTION_STRING") - .value("postgres://user:pass@localhost/db"); + sandbox.stub(process.env, "DB_CONNECTION_STRING").value("postgres://user:pass@localhost/db"); sandbox.stub(process.env, "REDIS_HOST").value("localhost"); sandbox.stub(process.env, "REDIS_PORT").value("6379"); sandbox.stub(process.env, "TOKEN_TTL").value("3600"); diff --git a/server/tests/services/statusService.test.js b/server/tests/services/statusService.test.js index aa8865024..289a36884 100755 --- a/server/tests/services/statusService.test.js +++ b/server/tests/services/statusService.test.js @@ -73,9 +73,7 @@ describe("StatusService", () => { }); it("should return {statusChanged: true} if status has changed from down to up", async function () { - statusService.db.getMonitorById = sinon - .stub() - .returns({ status: false, save: sinon.stub() }); + statusService.db.getMonitorById = sinon.stub().returns({ status: false, save: sinon.stub() }); const result = await statusService.updateStatus({ monitorId: "test", status: true, @@ -87,9 +85,7 @@ describe("StatusService", () => { }); it("should return {statusChanged: true} if status has changed from up to down", async function () { - statusService.db.getMonitorById = sinon - .stub() - .returns({ status: true, save: sinon.stub() }); + statusService.db.getMonitorById = sinon.stub().returns({ status: true, save: sinon.stub() }); const result = await statusService.updateStatus({ monitorId: "test", status: false, diff --git a/server/tests/utils/dataUtils.test.js b/server/tests/utils/dataUtils.test.js index e39af5a75..ec9eabb40 100755 --- a/server/tests/utils/dataUtils.test.js +++ b/server/tests/utils/dataUtils.test.js @@ -51,13 +51,7 @@ describe("NormalizeData", function () { describe("calculatePercentile", function () { it("should return the lower value when upper is greater than or equal to the length of the sorted array", function () { - const checks = [ - { responseTime: 10 }, - { responseTime: 20 }, - { responseTime: 30 }, - { responseTime: 40 }, - { responseTime: 50 }, - ]; + const checks = [{ responseTime: 10 }, { responseTime: 20 }, { responseTime: 30 }, { responseTime: 40 }, { responseTime: 50 }]; const percentile = 100; const result = calculatePercentile(checks, percentile); diff --git a/server/tests/utils/imageProcessing.test.js b/server/tests/utils/imageProcessing.test.js index 29e47af6a..09330c317 100755 --- a/server/tests/utils/imageProcessing.test.js +++ b/server/tests/utils/imageProcessing.test.js @@ -12,9 +12,7 @@ describe("imageProcessing - GenerateAvatarImage", function () { // Stub the sharp function const toBufferStub = sinon.stub().resolves(Buffer.from("resized image buffer")); const resizeStub = sinon.stub().returns({ toBuffer: toBufferStub }); - const sharpStub = sinon - .stub(sharp.prototype, "resize") - .returns({ toBuffer: toBufferStub }); + const sharpStub = sinon.stub(sharp.prototype, "resize").returns({ toBuffer: toBufferStub }); const result = await GenerateAvatarImage(file); @@ -38,9 +36,7 @@ describe("imageProcessing - GenerateAvatarImage", function () { // Stub the sharp function to throw an error const toBufferStub = sinon.stub().rejects(new Error("Resizing failed")); const resizeStub = sinon.stub().returns({ toBuffer: toBufferStub }); - const sharpStub = sinon - .stub(sharp.prototype, "resize") - .returns({ toBuffer: toBufferStub }); + const sharpStub = sinon.stub(sharp.prototype, "resize").returns({ toBuffer: toBufferStub }); try { await GenerateAvatarImage(file); diff --git a/server/tests/utils/messages.test.js b/server/tests/utils/messages.test.js index 719af1be0..a7e98c477 100755 --- a/server/tests/utils/messages.test.js +++ b/server/tests/utils/messages.test.js @@ -3,25 +3,19 @@ describe("Messages", function () { describe("messages - errorMessages", function () { it("should have a DB_FIND_MONITOR_BY_ID function", function () { const monitorId = "12345"; - expect(errorMessages.DB_FIND_MONITOR_BY_ID(monitorId)).to.equal( - `Monitor with id ${monitorId} not found` - ); + expect(errorMessages.DB_FIND_MONITOR_BY_ID(monitorId)).to.equal(`Monitor with id ${monitorId} not found`); }); it("should have a DB_DELETE_CHECKS function", function () { const monitorId = "12345"; - expect(errorMessages.DB_DELETE_CHECKS(monitorId)).to.equal( - `No checks found for monitor with id ${monitorId}` - ); + expect(errorMessages.DB_DELETE_CHECKS(monitorId)).to.equal(`No checks found for monitor with id ${monitorId}`); }); }); describe("messages - successMessages", function () { it("should have a MONITOR_GET_BY_USER_ID function", function () { const userId = "12345"; - expect(successMessages.MONITOR_GET_BY_USER_ID(userId)).to.equal( - `Got monitor for ${userId} successfully"` - ); + expect(successMessages.MONITOR_GET_BY_USER_ID(userId)).to.equal(`Got monitor for ${userId} successfully"`); }); // Add more tests for other success messages as needed diff --git a/server/utils/dataUtils.js b/server/utils/dataUtils.js index d164b92fd..e7fd334dd 100755 --- a/server/utils/dataUtils.js +++ b/server/utils/dataUtils.js @@ -15,9 +15,7 @@ const calculatePercentileUptimeDetails = (arr, percentile) => { const upper = lower + 1; const weight = index % 1; if (upper >= sorted.length) return sorted[lower].avgResponseTime; - return ( - sorted[lower].avgResponseTime * (1 - weight) + sorted[upper].avgResponseTime * weight - ); + return sorted[lower].avgResponseTime * (1 - weight) + sorted[upper].avgResponseTime * weight; }; const NormalizeData = (checks, rangeMin, rangeMax) => { @@ -28,15 +26,11 @@ const NormalizeData = (checks, rangeMin, rangeMax) => { const normalizedChecks = checks.map((check) => { const originalResponseTime = check.responseTime; // Normalize the response time between 1 and 100 - let normalizedResponseTime = - rangeMin + ((check.responseTime - min) * (rangeMax - rangeMin)) / (max - min); + let normalizedResponseTime = rangeMin + ((check.responseTime - min) * (rangeMax - rangeMin)) / (max - min); // Put a floor on the response times so we don't have extreme outliers // Better visuals - normalizedResponseTime = Math.max( - rangeMin, - Math.min(rangeMax, normalizedResponseTime) - ); + normalizedResponseTime = Math.max(rangeMin, Math.min(rangeMax, normalizedResponseTime)); return { ...check, responseTime: normalizedResponseTime, @@ -60,15 +54,11 @@ const NormalizeDataUptimeDetails = (checks, rangeMin, rangeMax) => { const normalizedChecks = checks.map((check) => { const originalResponseTime = check.avgResponseTime; // Normalize the response time between 1 and 100 - let normalizedResponseTime = - rangeMin + ((check.avgResponseTime - min) * (rangeMax - rangeMin)) / (max - min); + let normalizedResponseTime = rangeMin + ((check.avgResponseTime - min) * (rangeMax - rangeMin)) / (max - min); // Put a floor on the response times so we don't have extreme outliers // Better visuals - normalizedResponseTime = Math.max( - rangeMin, - Math.min(rangeMax, normalizedResponseTime) - ); + normalizedResponseTime = Math.max(rangeMin, Math.min(rangeMax, normalizedResponseTime)); return { ...check, avgResponseTime: normalizedResponseTime, @@ -107,10 +97,4 @@ const safelyParseFloat = (value, defaultValue = 0) => { return parsedValue; }; -export { - safelyParseFloat, - calculatePercentile, - NormalizeData, - calculatePercentileUptimeDetails, - NormalizeDataUptimeDetails, -}; +export { safelyParseFloat, calculatePercentile, NormalizeData, calculatePercentileUptimeDetails, NormalizeDataUptimeDetails }; diff --git a/server/utils/errorUtils.js b/server/utils/errorUtils.js index fc328690a..f913746b3 100644 --- a/server/utils/errorUtils.js +++ b/server/utils/errorUtils.js @@ -10,67 +10,31 @@ class AppError extends Error { } } -export const createError = ( - message, - status = 500, - service = null, - method = null, - details = null -) => { +export const createError = (message, status = 500, service = null, method = null, details = null) => { return new AppError(message, status, service, method, details); }; -export const createValidationError = ( - message, - details = null, - service = null, - method = null -) => { +export const createValidationError = (message, details = null, service = null, method = null) => { return createError(message, 422, service, method, details); }; -export const createAuthError = ( - message, - details = null, - service = null, - method = null -) => { +export const createAuthError = (message, details = null, service = null, method = null) => { return createError(message, 401, service, method, details); }; -export const createForbiddenError = ( - message, - details = null, - service = null, - method = null -) => { +export const createForbiddenError = (message, details = null, service = null, method = null) => { return createError(message, 403, service, method, details); }; -export const createNotFoundError = ( - message, - details = null, - service = null, - method = null -) => { +export const createNotFoundError = (message, details = null, service = null, method = null) => { return createError(message, 404, service, method, details); }; -export const createConflictError = ( - message, - details = null, - service = null, - method = null -) => { +export const createConflictError = (message, details = null, service = null, method = null) => { return createError(message, 409, service, method, details); }; -export const createServerError = ( - message, - details = null, - service = null, - method = null -) => { +export const createServerError = (message, details = null, service = null, method = null) => { return createError(message, 500, service, method, details); }; @@ -81,12 +45,7 @@ export const asyncHandler = (fn, serviceName, methodName) => { } catch (error) { // Handle validation errors if (error.isJoi || error.name === "ValidationError") { - const validationError = createValidationError( - error.message, - error.details, - serviceName, - methodName - ); + const validationError = createValidationError(error.message, error.details, serviceName, methodName); return next(validationError); } @@ -107,10 +66,7 @@ export const asyncHandler = (fn, serviceName, methodName) => { } // For unknown errors, create a server error - const appError = createServerError( - error.message || "An unexpected error occurred", - { originalError: error.message, stack: error.stack } - ); + const appError = createServerError(error.message || "An unexpected error occurred", { originalError: error.message, stack: error.stack }); appError.service = serviceName; appError.method = methodName; appError.stack = error.stack; // Preserve original stack diff --git a/server/utils/logger.js b/server/utils/logger.js index 49b875d96..6fc240e4c 100755 --- a/server/utils/logger.js +++ b/server/utils/logger.js @@ -6,43 +6,41 @@ class Logger { constructor() { this.logCache = []; this.maxCacheSize = 1000; - const consoleFormat = format.printf( - ({ level, message, service, method, details, timestamp, stack }) => { - if (message instanceof Object) { - message = JSON.stringify(message, null, 2); - } - - if (details instanceof Object) { - details = JSON.stringify(details, null, 2); - } - let msg = `${timestamp} ${level}:`; - service && (msg += ` [${service}]`); - method && (msg += `(${method})`); - message && (msg += ` ${message}`); - details && (msg += ` (details: ${details})`); - - if (typeof stack !== "undefined") { - const stackTrace = stack - ?.split("\n") - .slice(1) // Remove first line (error message) - .map((line) => { - const match = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/); - if (match) { - return { - function: match[1], - file: match[2], - line: parseInt(match[3]), - column: parseInt(match[4]), - }; - } - return line.trim(); - }); - stack && (msg += ` (stack: ${JSON.stringify(stackTrace, null, 2)})`); - } - - return msg; + const consoleFormat = format.printf(({ level, message, service, method, details, timestamp, stack }) => { + if (message instanceof Object) { + message = JSON.stringify(message, null, 2); } - ); + + if (details instanceof Object) { + details = JSON.stringify(details, null, 2); + } + let msg = `${timestamp} ${level}:`; + service && (msg += ` [${service}]`); + method && (msg += `(${method})`); + message && (msg += ` ${message}`); + details && (msg += ` (details: ${details})`); + + if (typeof stack !== "undefined") { + const stackTrace = stack + ?.split("\n") + .slice(1) // Remove first line (error message) + .map((line) => { + const match = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/); + if (match) { + return { + function: match[1], + file: match[2], + line: parseInt(match[3]), + column: parseInt(match[4]), + }; + } + return line.trim(); + }); + stack && (msg += ` (stack: ${JSON.stringify(stackTrace, null, 2)})`); + } + + return msg; + }); const logLevel = process.env.LOG_LEVEL || "info"; @@ -51,12 +49,7 @@ class Logger { format: format.combine(format.timestamp()), transports: [ new transports.Console({ - format: format.combine( - format.colorize(), - format.prettyPrint(), - format.json(), - consoleFormat - ), + format: format.combine(format.colorize(), format.prettyPrint(), format.json(), consoleFormat), }), new transports.File({ format: format.combine(format.json()), diff --git a/server/utils/utils.js b/server/utils/utils.js index 57d181886..bcb4d8e5e 100755 --- a/server/utils/utils.js +++ b/server/utils/utils.js @@ -6,12 +6,7 @@ const ParseBoolean = (value) => { if (value === true || value === "true") { return true; - } else if ( - value === false || - value === "false" || - value === null || - value === undefined - ) { + } else if (value === false || value === "false" || value === null || value === undefined) { return false; } }; @@ -21,8 +16,7 @@ const getTokenFromHeaders = (headers) => { if (!authorizationHeader) throw new Error("No auth headers"); const parts = authorizationHeader.split(" "); - if (parts.length !== 2 || parts[0] !== "Bearer") - throw new Error("Invalid auth headers"); + if (parts.length !== 2 || parts[0] !== "Bearer") throw new Error("Invalid auth headers"); return parts[1]; }; diff --git a/server/validation/joi.js b/server/validation/joi.js index f555756eb..030fa8605 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -7,9 +7,7 @@ import joi from "joi"; const roleValidatior = (role) => (value, helpers) => { const hasRole = role.some((role) => value.includes(role)); if (!hasRole) { - throw new joi.ValidationError( - `You do not have the required authorization. Required roles: ${role.join(", ")}` - ); + throw new joi.ValidationError(`You do not have the required authorization. Required roles: ${role.join(", ")}`); } return value; }; @@ -134,11 +132,7 @@ const getMonitorsByTeamIdQueryValidation = joi.object({ .alternatives() .try( joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port"), - joi - .array() - .items( - joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port") - ) + joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port")) ), page: joi.number(), rowsPerPage: joi.number(), @@ -454,8 +448,7 @@ const createStatusPageBodyValidation = joi.object({ .pattern(/^[a-zA-Z0-9_-]+$/) // Only allow alphanumeric, underscore, and hyphen .required() .messages({ - "string.pattern.base": - "URL can only contain letters, numbers, underscores, and hyphens", + "string.pattern.base": "URL can only contain letters, numbers, underscores, and hyphens", }), timezone: joi.string().optional(), color: joi.string().optional(), @@ -485,13 +478,9 @@ const imageValidation = joi 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)", - }), + 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", }), @@ -578,15 +567,11 @@ const createNotificationBodyValidation = joi.object({ "any.required": "Notification name is required", }), - type: joi - .string() - .valid("email", "webhook", "slack", "discord", "pager_duty") - .required() - .messages({ - "string.empty": "Notification type is required", - "any.required": "Notification type is required", - "any.only": "Notification type must be email, webhook, or pager_duty", - }), + type: joi.string().valid("email", "webhook", "slack", "discord", "pager_duty").required().messages({ + "string.empty": "Notification type is required", + "any.required": "Notification type is required", + "any.only": "Notification type must be email, webhook, or pager_duty", + }), address: joi.when("type", { is: "email", From 12b7819791df0111247bf06435aeb994d4e6bcb4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 11:05:10 -0700 Subject: [PATCH 019/226] remove user ID param from rotues --- client/src/Features/Auth/authSlice.js | 6 +----- client/src/Utils/NetworkService.js | 6 +++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/client/src/Features/Auth/authSlice.js b/client/src/Features/Auth/authSlice.js index 2fde49202..5ee31944a 100644 --- a/client/src/Features/Auth/authSlice.js +++ b/client/src/Features/Auth/authSlice.js @@ -44,7 +44,6 @@ export const login = createAsyncThunk("auth/login", async (form, thunkApi) => { export const update = createAsyncThunk("auth/update", async (data, thunkApi) => { const { localData: form } = data; - const user = thunkApi.getState().auth.user; try { const fd = new FormData(); form.firstName && fd.append("firstName", form.firstName); @@ -60,7 +59,6 @@ export const update = createAsyncThunk("auth/update", async (data, thunkApi) => form.deleteProfileImage && fd.append("deleteProfileImage", form.deleteProfileImage); const res = await networkService.updateUser({ - userId: user._id, form: fd, }); return res.data; @@ -77,10 +75,8 @@ export const update = createAsyncThunk("auth/update", async (data, thunkApi) => }); export const deleteUser = createAsyncThunk("auth/delete", async (_, thunkApi) => { - const user = thunkApi.getState().auth.user; - try { - const res = await networkService.deleteUser({ userId: user._id }); + const res = await networkService.deleteUser(); return res.data; } catch (error) { if (error.response && error.response.data) { diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index 895d90d30..54bb239a4 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -393,7 +393,7 @@ class NetworkService { * */ async updateUser(config) { - return this.axiosInstance.put(`/auth/user/${config.userId}`, config.form); + return this.axiosInstance.put(`/auth/user`, config.form); } /** @@ -406,8 +406,8 @@ class NetworkService { * @param {string} config.userId - The ID of the user to be deleted. * **/ - async deleteUser(config) { - return this.axiosInstance.delete(`/auth/user/${config.userId}`); + async deleteUser() { + return this.axiosInstance.delete(`/auth/user`); } /** From e2ddffe24bd9037c7825d621b0e3ee6697f203dd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 11:05:48 -0700 Subject: [PATCH 020/226] remove userId param from routes --- server/controllers/authController.js | 40 +++++++++++++++------------ server/db/mongo/modules/userModule.js | 26 ++++++++--------- server/routes/authRoute.js | 5 ++-- server/validation/joi.js | 5 ---- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/server/controllers/authController.js b/server/controllers/authController.js index a5c7d6cac..1cc1a747a 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -1,7 +1,6 @@ import { registrationBodyValidation, loginValidation, - editUserParamValidation, editUserBodyValidation, recoveryValidation, recoveryTokenValidation, @@ -186,14 +185,8 @@ class AuthController { */ editUser = asyncHandler( async (req, res, next) => { - await editUserParamValidation.validateAsync(req.params); await editUserBodyValidation.validateAsync(req.body); - // TODO is this neccessary any longer? Verify ownership middleware should handle this - if (req.params.userId !== req.user._id.toString()) { - throw createAuthError(this.stringService.unauthorized); - } - // Change Password check if (req.body.password && req.body.newPassword) { // Get token from headers @@ -216,7 +209,7 @@ class AuthController { req.body.password = req.body.newPassword; } - const updatedUser = await this.db.updateUser(req, res); + const updatedUser = await this.db.updateUser({ userId: req?.user?._id, user: req.body, file: req.file }); res.success({ msg: this.stringService.authUpdateUser, data: updatedUser, @@ -343,20 +336,31 @@ class AuthController { */ deleteUser = asyncHandler( async (req, res, next) => { - const token = getTokenFromHeaders(req.headers); - const decodedToken = jwt.decode(token); - const { email } = decodedToken; - - // Check if the user exists - const user = await this.db.getUserByEmail(email); + const email = req?.user?.email; + if (!email) { + throw new Error("No email in request"); + } // 1. Find all the monitors associated with the team ID if superadmin + const teamId = req?.user?.teamId; + const userId = req?.user?._id; + + if (!teamId) { + throw new Error("No team ID in request"); + } + + if (!userId) { + throw new Error("No user ID in request"); + } + + const roles = req.user.role; + const result = await this.db.getMonitorsByTeamId({ - teamId: user.teamId.toString(), + teamId: teamId, }); - if (user.role.includes("superadmin")) { - //2. Remove all jobs, delete checks and alerts + if (roles.includes("superadmin")) { + // 2. Remove all jobs, delete checks and alerts result?.monitors.length > 0 && (await Promise.all( result.monitors.map(async (monitor) => { @@ -365,7 +369,7 @@ class AuthController { )); } // 6. Delete the user by id - await this.db.deleteUser(user._id); + await this.db.deleteUser(userId); return res.success({ msg: this.stringService.authDeleteUser, diff --git a/server/db/mongo/modules/userModule.js b/server/db/mongo/modules/userModule.js index ffac2e4c0..cd609bb5d 100755 --- a/server/db/mongo/modules/userModule.js +++ b/server/db/mongo/modules/userModule.js @@ -92,26 +92,26 @@ const getUserByEmail = async (email) => { * @throws {Error} */ -const updateUser = async (req, res, parseBoolean = ParseBoolean, generateAvatarImage = GenerateAvatarImage) => { - const candidateUserId = req.params.userId; - try { - const candidateUser = { ...req.body }; - // ****************************************** - // Handle profile image - // ****************************************** +const updateUser = async ({ userId, user, file }) => { + if (!userId) { + throw new Error("No user in request"); + } - if (parseBoolean(candidateUser.deleteProfileImage) === true) { + try { + const candidateUser = { ...user }; + + if (ParseBoolean(candidateUser.deleteProfileImage) === true) { candidateUser.profileImage = null; candidateUser.avatarImage = null; - } else if (req.file) { + } else if (file) { // 1. Save the full size image candidateUser.profileImage = { - data: req.file.buffer, - contentType: req.file.mimetype, + data: file.buffer, + contentType: file.mimetype, }; // 2. Get the avatar sized image - const avatar = await generateAvatarImage(req.file); + const avatar = await GenerateAvatarImage(file); candidateUser.avatarImage = avatar; } @@ -120,7 +120,7 @@ const updateUser = async (req, res, parseBoolean = ParseBoolean, generateAvatarI // ****************************************** const updatedUser = await UserModel.findByIdAndUpdate( - candidateUserId, + userId, candidateUser, { new: true } // Returns updated user instead of pre-update user ) diff --git a/server/routes/authRoute.js b/server/routes/authRoute.js index 127456c02..6d8af52f8 100755 --- a/server/routes/authRoute.js +++ b/server/routes/authRoute.js @@ -23,10 +23,11 @@ class AuthRoutes { this.router.post("/recovery/reset/", this.authController.resetPassword); this.router.get("/users/superadmin", this.authController.checkSuperadminExists); + this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers); - this.router.put("/user/:userId", verifyJWT, upload.single("profileImage"), this.authController.editUser); - this.router.delete("/user/:userId", verifyJWT, verifyOwnership(User, "userId"), this.authController.deleteUser); + this.router.put("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser); + this.router.delete("/user", verifyJWT, this.authController.deleteUser); } getRouter() { diff --git a/server/validation/joi.js b/server/validation/joi.js index 030fa8605..ec2274596 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -56,10 +56,6 @@ const registrationBodyValidation = joi.object({ inviteToken: joi.string().allow("").required(), }); -const editUserParamValidation = joi.object({ - userId: joi.string().required(), -}); - const editUserBodyValidation = joi.object({ firstName: nameValidation.required(), lastName: nameValidation.required(), @@ -644,7 +640,6 @@ export { editMonitorBodyValidation, pauseMonitorParamValidation, getMonitorURLByQueryValidation, - editUserParamValidation, editUserBodyValidation, createAlertParamValidation, createAlertBodyValidation, From 55e2d5c0e33e8532fab50f8861a9a86cd61e8ae7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 11:54:04 -0700 Subject: [PATCH 021/226] fix auth for check routes --- server/controllers/checkController.js | 143 ++++++++++++++++++++------ server/routes/checkRoute.js | 5 +- 2 files changed, 116 insertions(+), 32 deletions(-) diff --git a/server/controllers/checkController.js b/server/controllers/checkController.js index bbd5c08bb..96dabef80 100755 --- a/server/controllers/checkController.js +++ b/server/controllers/checkController.js @@ -1,6 +1,4 @@ import { - createCheckParamValidation, - createCheckBodyValidation, getChecksParamValidation, getChecksQueryValidation, getTeamChecksParamValidation, @@ -23,29 +21,40 @@ class CheckController { this.stringService = stringService; } - createCheck = asyncHandler( - async (req, res, next) => { - await createCheckParamValidation.validateAsync(req.params); - await createCheckBodyValidation.validateAsync(req.body); - - const checkData = { ...req.body }; - const check = await this.db.createCheck(checkData); - - return res.success({ - msg: this.stringService.checkCreate, - data: check, - }); - }, - SERVICE_NAME, - "createCheck" - ); - getChecksByMonitor = asyncHandler( async (req, res, next) => { await getChecksParamValidation.validateAsync(req.params); await getChecksQueryValidation.validateAsync(req.query); - const { monitorId } = req.params; + const monitorId = req?.params?.monitorId; + + if (!monitorId) { + throw new Error("No monitor ID in request"); + } + + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + + const monitor = await this.db.getMonitorById(monitorId); + + if (!monitor) { + const err = new Error("Monitor not found"); + err.status = 404; + err.service = SERVICE_NAME; + err.method = "getChecksByMonitor"; + throw err; + } + + if (!monitor.teamId.equals(teamId)) { + const err = new Error("Unauthorized"); + err.status = 403; + err.service = SERVICE_NAME; + err.method = "getChecksByMonitor"; + throw err; + } + let { type, sortOrder, dateRange, filter, ack, page, rowsPerPage, status } = req.query; const result = await this.db.getChecksByMonitor({ monitorId, @@ -74,7 +83,11 @@ class CheckController { await getTeamChecksQueryValidation.validateAsync(req.query); let { sortOrder, dateRange, filter, ack, page, rowsPerPage } = req.query; - const { teamId } = req.user; + const teamId = req?.user?.teamId; + + if (!teamId) { + throw new Error("No team ID in request"); + } const checkData = await this.db.getChecksByTeam({ sortOrder, @@ -96,7 +109,12 @@ class CheckController { getChecksSummaryByTeamId = asyncHandler( async (req, res, next) => { - const { teamId } = req.user; + const teamId = req?.user?.teamId; + + if (!teamId) { + throw new Error("No team ID in request"); + } + const summary = await this.db.getChecksSummaryByTeamId({ teamId }); return res.success({ msg: this.stringService.checkGetSummary, @@ -111,9 +129,17 @@ class CheckController { async (req, res, next) => { await ackCheckBodyValidation.validateAsync(req.body); - const { checkId } = req.params; - const { ack } = req.body; - const { teamId } = req.user; + const checkId = req?.params?.checkId; + const ack = req?.body?.ack; + const teamId = req?.user?.teamId; + + if (!checkId) { + throw new Error("No check ID in request"); + } + + if (!teamId) { + throw new Error("No team ID in request"); + } const updatedCheck = await this.db.ackCheck(checkId, teamId, ack); @@ -131,9 +157,35 @@ class CheckController { await ackAllChecksParamValidation.validateAsync(req.params); await ackAllChecksBodyValidation.validateAsync(req.body); - const { monitorId, path } = req.params; - const { ack } = req.body; - const { teamId } = req.user; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + + const monitorId = req?.params?.monitorId; + + const path = req?.params?.path; + + const ack = req?.body?.ack; + + if (path === "monitor") { + if (!monitorId) { + throw new Error("No monitor ID in request"); + } + + const monitor = await this.db.getMonitorById(monitorId); + if (!monitor) { + throw new Error("Monitor not found"); + } + + if (!monitor.teamId.equals(teamId)) { + const err = new Error("Unauthorized"); + err.status = 403; + err.service = SERVICE_NAME; + err.method = "ackAllChecks"; + throw err; + } + } const updatedChecks = await this.db.ackAllChecks(monitorId, teamId, ack, path); @@ -150,6 +202,35 @@ class CheckController { async (req, res, next) => { await deleteChecksParamValidation.validateAsync(req.params); + const monitorId = req?.params?.monitorId; + const teamId = req?.user?.teamId; + + if (!monitorId) { + throw new Error("No monitor ID in request"); + } + + if (!teamId) { + throw new Error("No team ID in request"); + } + + const monitor = await this.db.getMonitorById(monitorId); + + if (!monitor) { + const err = new Error("Monitor not found"); + err.status = 404; + err.service = SERVICE_NAME; + err.method = "deleteChecks"; + throw err; + } + + if (!monitor.teamId.equals(teamId)) { + const err = new Error("Unauthorized"); + err.status = 403; + err.service = SERVICE_NAME; + err.method = "deleteChecks"; + throw err; + } + const deletedCount = await this.db.deleteChecks(req.params.monitorId); return res.success({ @@ -165,7 +246,11 @@ class CheckController { async (req, res, next) => { await deleteChecksByTeamIdParamValidation.validateAsync(req.params); - const { teamId } = req.user; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + const deletedCount = await this.db.deleteChecksByTeamId(teamId); return res.success({ diff --git a/server/routes/checkRoute.js b/server/routes/checkRoute.js index 57d79c7a2..8bdb2d4fb 100755 --- a/server/routes/checkRoute.js +++ b/server/routes/checkRoute.js @@ -18,11 +18,10 @@ class CheckRoutes { this.router.put("/team/ttl", isAllowed(["admin", "superadmin"]), this.checkController.updateChecksTTL); this.router.delete("/team", isAllowed(["admin", "superadmin"]), this.checkController.deleteChecksByTeamId); - this.router.put("/check/:checkId", verifyTeamAccess(Check, "checkId"), this.checkController.ackCheck); + this.router.put("/check/:checkId", this.checkController.ackCheck); this.router.get("/:monitorId", this.checkController.getChecksByMonitor); - this.router.post("/:monitorId", verifyOwnership(Monitor, "monitorId"), this.checkController.createCheck); - this.router.delete("/:monitorId", verifyOwnership(Monitor, "monitorId"), this.checkController.deleteChecks); + this.router.delete("/:monitorId", this.checkController.deleteChecks); this.router.put("/:path/:monitorId?", this.checkController.ackAllChecks); } From 4d989746fda146c7bf6020a1c8e374f3f6e05da3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 12:04:16 -0700 Subject: [PATCH 022/226] remove unused routes, add authentication --- server/controllers/diagnosticController.js | 66 ++++------------------ server/routes/diagnosticRoute.js | 6 +- 2 files changed, 15 insertions(+), 57 deletions(-) diff --git a/server/controllers/diagnosticController.js b/server/controllers/diagnosticController.js index 774af8d7d..6ba880dcb 100755 --- a/server/controllers/diagnosticController.js +++ b/server/controllers/diagnosticController.js @@ -12,62 +12,20 @@ obs.observe({ entryTypes: ["measure"] }); class DiagnosticController { constructor(db) { this.db = db; - this.getMonitorsByTeamIdExecutionStats = this.getMonitorsByTeamIdExecutionStats.bind(this); - this.getDbStats = this.getDbStats.bind(this); } - getMonitorsByTeamIdExecutionStats = asyncHandler( - async (req, res, next) => { - const data = await this.db.getMonitorsByTeamIdExecutionStats(req); - return res.success({ - msg: "OK", - data, - }); - }, - SERVICE_NAME, - "getMonitorsByTeamIdExecutionStats" - ); - - getDbStats = asyncHandler( - async (req, res, next) => { - const { methodName, args = [] } = req.body; - if (!methodName || !this.db[methodName]) { - return res.error({ - msg: "Invalid method name or method doesn't exist", - status: 400, - }); - } - const explainMethod = await this.db[methodName].apply(this.db, args); - const stats = { - methodName, - timestamp: new Date(), - explain: explainMethod, - }; - return res.success({ - msg: "OK", - data: stats, - }); - }, - SERVICE_NAME, - "getDbStats" - ); - - getCPUUsage = asyncHandler( - async (req, res, next) => { - const startUsage = process.cpuUsage(); - const timingPeriod = 1000; // measured in ms - await new Promise((resolve) => setTimeout(resolve, timingPeriod)); - const endUsage = process.cpuUsage(startUsage); - const cpuUsage = { - userUsageMs: endUsage.user / 1000, - systemUsageMs: endUsage.system / 1000, - usagePercentage: ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100, - }; - return cpuUsage; - }, - SERVICE_NAME, - "getCPUUsage" - ); + getCPUUsage = async () => { + const startUsage = process.cpuUsage(); + const timingPeriod = 1000; // measured in ms + await new Promise((resolve) => setTimeout(resolve, timingPeriod)); + const endUsage = process.cpuUsage(startUsage); + const cpuUsage = { + userUsageMs: endUsage.user / 1000, + systemUsageMs: endUsage.system / 1000, + usagePercentage: ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100, + }; + return cpuUsage; + }; getSystemStats = asyncHandler( async (req, res, next) => { diff --git a/server/routes/diagnosticRoute.js b/server/routes/diagnosticRoute.js index 28f31a622..a15f5a038 100755 --- a/server/routes/diagnosticRoute.js +++ b/server/routes/diagnosticRoute.js @@ -1,4 +1,6 @@ import { Router } from "express"; +import { verifyJWT } from "../middleware/verifyJWT.js"; +import { isAllowed } from "../middleware/isAllowed.js"; class DiagnosticRoutes { constructor(diagnosticController) { @@ -7,9 +9,7 @@ class DiagnosticRoutes { this.initRoutes(); } initRoutes() { - this.router.post("/db/stats", this.diagnosticController.getDbStats); - this.router.get("/system", this.diagnosticController.getSystemStats); - this.router.get("/db/get-monitors-by-team-id/:teamId", this.diagnosticController.getMonitorsByTeamIdExecutionStats); + this.router.get("/system", verifyJWT, isAllowed(["admin", "superadmin"]), this.diagnosticController.getSystemStats); } getRouter() { From 5dbec377e563e345abdae81668960219c7d935a4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 12:26:38 -0700 Subject: [PATCH 023/226] reorder routes --- server/index.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/server/index.js b/server/index.js index e8aff74d6..5f391c754 100755 --- a/server/index.js +++ b/server/index.js @@ -343,16 +343,17 @@ const startApp = async () => { //routes app.use("/api/v1/auth", authRoutes.getRouter()); - app.use("/api/v1/settings", verifyJWT, settingsRoutes.getRouter()); - app.use("/api/v1/invite", inviteRoutes.getRouter()); - app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter()); app.use("/api/v1/checks", verifyJWT, checkRoutes.getRouter()); - app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRoutes.getRouter()); - app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter()); - app.use("/api/v1/logs", verifyJWT, logRoutes.getRouter()); - app.use("/api/v1/status-page", statusPageRoutes.getRouter()); - app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter()); + app.use("/api/v1/invite", inviteRoutes.getRouter()); + app.use("/api/v1/logs", verifyJWT, logRoutes.getRouter()); + app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRoutes.getRouter()); + app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter()); + app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); + app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter()); + app.use("/api/v1/settings", verifyJWT, settingsRoutes.getRouter()); + app.use("/api/v1/status-page", statusPageRoutes.getRouter()); + app.use("/api/v1/health", (req, res) => { res.json({ status: "OK", From 87f843df1cf515674777bb7266f1d22f8ccfd351 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 12:27:43 -0700 Subject: [PATCH 024/226] add getmonitorsbyids --- .../maintenanceWindowController.js | 60 +++++++++++++++++-- server/db/mongo/modules/monitorModule.js | 12 ++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/server/controllers/maintenanceWindowController.js b/server/controllers/maintenanceWindowController.js index 322ba8a90..9807392cc 100755 --- a/server/controllers/maintenanceWindowController.js +++ b/server/controllers/maintenanceWindowController.js @@ -22,8 +22,25 @@ class MaintenanceWindowController { async (req, res, next) => { await createMaintenanceWindowBodyValidation.validateAsync(req.body); - const { teamId } = req.user; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + const monitorIds = req.body.monitors; + + const monitors = await this.db.getMonitorsByIds(monitorIds); + + const unauthorizedMonitors = monitors.filter((monitor) => !monitor.teamId.equals(teamId)); + + if (unauthorizedMonitors.length > 0) { + const error = new Error("Unauthorized access to one or more monitors"); + error.status = 403; + error.service = SERVICE_NAME; + error.method = "createMaintenanceWindows"; + throw error; + } + const dbTransactions = monitorIds.map((monitorId) => { return this.db.createMaintenanceWindow({ teamId, @@ -48,7 +65,13 @@ class MaintenanceWindowController { getMaintenanceWindowById = asyncHandler( async (req, res, next) => { await getMaintenanceWindowByIdParamValidation.validateAsync(req.params); - const maintenanceWindow = await this.db.getMaintenanceWindowById(req.params.id); + + const teamId = req.user.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + const maintenanceWindow = await this.db.getMaintenanceWindowById({ id: req.params.id, teamId }); return res.success({ msg: this.stringService.maintenanceWindowGetById, data: maintenanceWindow, @@ -62,7 +85,12 @@ class MaintenanceWindowController { async (req, res, next) => { await getMaintenanceWindowsByTeamIdQueryValidation.validateAsync(req.query); - const { teamId } = req.user; + const teamId = req?.user?.teamId; + + if (!teamId) { + throw new Error("Team ID is required"); + } + const maintenanceWindows = await this.db.getMaintenanceWindowsByTeamId(teamId, req.query); return res.success({ @@ -78,7 +106,14 @@ class MaintenanceWindowController { async (req, res, next) => { await getMaintenanceWindowsByMonitorIdParamValidation.validateAsync(req.params); - const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(req.params.monitorId); + const monitorId = req.params.monitorId; + + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId({ monitorId, teamId }); return res.success({ msg: this.stringService.maintenanceWindowGetByUser, @@ -92,7 +127,13 @@ class MaintenanceWindowController { deleteMaintenanceWindow = asyncHandler( async (req, res, next) => { await deleteMaintenanceWindowByIdParamValidation.validateAsync(req.params); - await this.db.deleteMaintenanceWindowById(req.params.id); + + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + await this.db.deleteMaintenanceWindowById({ id: req.params.id, teamId }); return res.success({ msg: this.stringService.maintenanceWindowDelete, }); @@ -105,7 +146,14 @@ class MaintenanceWindowController { async (req, res, next) => { await editMaintenanceWindowByIdParamValidation.validateAsync(req.params); await editMaintenanceByIdWindowBodyValidation.validateAsync(req.body); - const editedMaintenanceWindow = await this.db.editMaintenanceWindowById(req.params.id, req.body); + + const teamId = req.user.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + const editedMaintenanceWindow = await this.db.editMaintenanceWindowById({ id: req.params.id, body: req.body, teamId }); + return res.success({ msg: this.stringService.maintenanceWindowEdit, data: editedMaintenanceWindow, diff --git a/server/db/mongo/modules/monitorModule.js b/server/db/mongo/modules/monitorModule.js index 94829002b..b331bc337 100755 --- a/server/db/mongo/modules/monitorModule.js +++ b/server/db/mongo/modules/monitorModule.js @@ -461,6 +461,17 @@ const getMonitorById = async (monitorId) => { } }; +const getMonitorsByIds = async (monitorIds) => { + try { + const objectIds = monitorIds.map((id) => new ObjectId(id)); + return await Monitor.find({ _id: { $in: objectIds } }, { _id: 1, teamId: 1 }).lean(); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMonitorsByIds"; + throw error; + } +}; + const getMonitorsByTeamId = async ({ limit, type, page, rowsPerPage, filter, field, order, teamId }) => { limit = parseInt(limit); page = parseInt(page); @@ -753,6 +764,7 @@ export { getAllMonitorsWithUptimeStats, getMonitorStatsById, getMonitorById, + getMonitorsByIds, getMonitorsByTeamId, getMonitorsAndSummaryByTeamId, getMonitorsWithChecksByTeamId, From d6d3b53846c64517407ab6a93015756add08fe4d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 12:27:53 -0700 Subject: [PATCH 025/226] add team auth to mw routes --- .../mongo/modules/maintenanceWindowModule.js | 18 ++++++++++-------- server/routes/maintenanceWindowRoute.js | 9 +++------ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/server/db/mongo/modules/maintenanceWindowModule.js b/server/db/mongo/modules/maintenanceWindowModule.js index 4f16fceb6..f7fea4636 100755 --- a/server/db/mongo/modules/maintenanceWindowModule.js +++ b/server/db/mongo/modules/maintenanceWindowModule.js @@ -34,10 +34,11 @@ const createMaintenanceWindow = async (maintenanceWindowData) => { } }; -const getMaintenanceWindowById = async (maintenanceWindowId) => { +const getMaintenanceWindowById = async ({ id, teamId }) => { try { - const maintenanceWindow = await MaintenanceWindow.findById({ - _id: maintenanceWindowId, + const maintenanceWindow = await MaintenanceWindow.findOne({ + _id: id, + teamId: teamId, }); return maintenanceWindow; } catch (error) { @@ -95,10 +96,11 @@ const getMaintenanceWindowsByTeamId = async (teamId, query) => { * @returns {Promise>} An array of MaintenanceWindow documents. * @throws {Error} If there is an error retrieving the documents. */ -const getMaintenanceWindowsByMonitorId = async (monitorId) => { +const getMaintenanceWindowsByMonitorId = async ({ monitorId, teamId }) => { try { const maintenanceWindows = await MaintenanceWindow.find({ monitorId: monitorId, + teamId: teamId, }); return maintenanceWindows; } catch (error) { @@ -116,9 +118,9 @@ const getMaintenanceWindowsByMonitorId = async (monitorId) => { * @returns {Promise} The deleted MaintenanceWindow document. * @throws {Error} If there is an error deleting the document. */ -const deleteMaintenanceWindowById = async (maintenanceWindowId) => { +const deleteMaintenanceWindowById = async ({ id, teamId }) => { try { - const maintenanceWindow = await MaintenanceWindow.findByIdAndDelete(maintenanceWindowId); + const maintenanceWindow = await MaintenanceWindow.findOneAndDelete({ _id: id, teamId }); return maintenanceWindow; } catch (error) { error.service = SERVICE_NAME; @@ -167,9 +169,9 @@ const deleteMaintenanceWindowByUserId = async (userId) => { } }; -const editMaintenanceWindowById = async (maintenanceWindowId, maintenanceWindowData) => { +const editMaintenanceWindowById = async ({ id, body, teamId }) => { try { - const editedMaintenanceWindow = await MaintenanceWindow.findByIdAndUpdate(maintenanceWindowId, maintenanceWindowData, { new: true }); + const editedMaintenanceWindow = await MaintenanceWindow.findByIdAndUpdate(id, body, { new: true }); return editedMaintenanceWindow; } catch (error) { error.service = SERVICE_NAME; diff --git a/server/routes/maintenanceWindowRoute.js b/server/routes/maintenanceWindowRoute.js index 7ba3ac855..774434ed6 100755 --- a/server/routes/maintenanceWindowRoute.js +++ b/server/routes/maintenanceWindowRoute.js @@ -1,7 +1,4 @@ import { Router } from "express"; -import { verifyOwnership } from "../middleware/verifyOwnership.js"; -import { verifyTeamAccess } from "../middleware/verifyTeamAccess.js"; -import Monitor from "../db/models/Monitor.js"; import MaintenanceWindow from "../db/models/MaintenanceWindow.js"; class MaintenanceWindowRoutes { constructor(maintenanceWindowController) { @@ -13,11 +10,11 @@ class MaintenanceWindowRoutes { this.router.post("/", this.mwController.createMaintenanceWindows); this.router.get("/team/", this.mwController.getMaintenanceWindowsByTeamId); - this.router.get("/monitor/:monitorId", verifyOwnership(Monitor, "monitorId"), this.mwController.getMaintenanceWindowsByMonitorId); + this.router.get("/monitor/:monitorId", this.mwController.getMaintenanceWindowsByMonitorId); this.router.get("/:id", this.mwController.getMaintenanceWindowById); - this.router.put("/:id", verifyTeamAccess(MaintenanceWindow, "id"), this.mwController.editMaintenanceWindow); - this.router.delete("/:id", verifyTeamAccess(MaintenanceWindow, "id"), this.mwController.deleteMaintenanceWindow); + this.router.put("/:id", this.mwController.editMaintenanceWindow); + this.router.delete("/:id", this.mwController.deleteMaintenanceWindow); } getRouter() { From 62f94cfb20ac4aeeaae57904e2c8b1c3664e6856 Mon Sep 17 00:00:00 2001 From: karenvicent Date: Mon, 21 Jul 2025 16:13:18 -0400 Subject: [PATCH 026/226] refactor: extract Fallback component into smaller reusable components --- .../Fallback/FallBackActionButtons.jsx | 43 ++++ .../Fallback/FallbackBackground.jsx | 31 +++ .../Components/Fallback/FallbackCheckList.jsx | 35 +++ .../Components/Fallback/FallbackContainer.jsx | 44 ++++ .../Fallback/FallbackPageSpeedWarning.jsx | 69 ++++++ .../src/Components/Fallback/FallbackTitle.jsx | 21 ++ client/src/Components/Fallback/index.css | 4 - client/src/Components/Fallback/index.jsx | 208 +++--------------- .../Pages/Infrastructure/Monitors/index.jsx | 10 +- client/src/Pages/Maintenance/index.jsx | 9 +- client/src/Pages/Notifications/index.jsx | 2 +- client/src/Pages/PageSpeed/Monitors/index.jsx | 26 ++- .../Pages/StatusPage/StatusPages/index.jsx | 9 +- client/src/Pages/Uptime/Monitors/index.jsx | 11 +- client/src/locales/en.json | 59 ++++- 15 files changed, 365 insertions(+), 216 deletions(-) create mode 100644 client/src/Components/Fallback/FallBackActionButtons.jsx create mode 100644 client/src/Components/Fallback/FallbackBackground.jsx create mode 100644 client/src/Components/Fallback/FallbackCheckList.jsx create mode 100644 client/src/Components/Fallback/FallbackContainer.jsx create mode 100644 client/src/Components/Fallback/FallbackPageSpeedWarning.jsx create mode 100644 client/src/Components/Fallback/FallbackTitle.jsx diff --git a/client/src/Components/Fallback/FallBackActionButtons.jsx b/client/src/Components/Fallback/FallBackActionButtons.jsx new file mode 100644 index 000000000..fbe8fe807 --- /dev/null +++ b/client/src/Components/Fallback/FallBackActionButtons.jsx @@ -0,0 +1,43 @@ +import { Button, Stack } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; + +const FallbackActionButtons = ({ link, type }) => { + const theme = useTheme(); + const navigate = useNavigate(); + const { t } = useTranslation(); + + return ( + + + {type === "uptimeMonitor" && ( + + )} + + ); +}; +FallbackActionButtons.propTypes = { + title: PropTypes.string.isRequired, + link: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, +}; + +export default FallbackActionButtons; diff --git a/client/src/Components/Fallback/FallbackBackground.jsx b/client/src/Components/Fallback/FallbackBackground.jsx new file mode 100644 index 000000000..e01659f49 --- /dev/null +++ b/client/src/Components/Fallback/FallbackBackground.jsx @@ -0,0 +1,31 @@ +import { useTheme } from "@emotion/react"; +import Box from "@mui/material/Box"; +import Skeleton from "../../assets/Images/create-placeholder.svg?react"; +import Background from "../../assets/Images/background-grid.svg?react"; +import SkeletonDark from "../../assets/Images/create-placeholder-dark.svg?react"; +import { useSelector } from "react-redux"; +const FallbackBackground = () => { + const theme = useTheme(); + const mode = useSelector((state) => state.ui.mode); + return ( + <> + {mode === "light" ? ( + + ) : ( + + )} + + + + + ); +}; + +export default FallbackBackground; diff --git a/client/src/Components/Fallback/FallbackCheckList.jsx b/client/src/Components/Fallback/FallbackCheckList.jsx new file mode 100644 index 000000000..4d13dead4 --- /dev/null +++ b/client/src/Components/Fallback/FallbackCheckList.jsx @@ -0,0 +1,35 @@ +import PropTypes from "prop-types"; +import { useTheme } from "@mui/material/styles"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Check from "../Check/Check"; +const FallbackCheckList = ({ checks, type }) => { + const theme = useTheme(); + return ( + + {checks?.map((check, index) => ( + + ))} + + ); +}; + +FallbackCheckList.propTypes = { + checks: PropTypes.arrayOf(PropTypes.string).isRequired, + title: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, +}; + +export default FallbackCheckList; diff --git a/client/src/Components/Fallback/FallbackContainer.jsx b/client/src/Components/Fallback/FallbackContainer.jsx new file mode 100644 index 000000000..5d14b495d --- /dev/null +++ b/client/src/Components/Fallback/FallbackContainer.jsx @@ -0,0 +1,44 @@ +import { useTheme } from "@emotion/react"; +import { Box, Stack } from "@mui/material"; +import PropTypes from "prop-types"; + +const FallbackContainer = ({ children, type }) => { + const theme = useTheme(); + return ( + + + {children} + + + ); +}; + +FallbackContainer.propTypes = { + children: PropTypes.node, + type: PropTypes.string, +}; + +export default FallbackContainer; diff --git a/client/src/Components/Fallback/FallbackPageSpeedWarning.jsx b/client/src/Components/Fallback/FallbackPageSpeedWarning.jsx new file mode 100644 index 000000000..bcec7300d --- /dev/null +++ b/client/src/Components/Fallback/FallbackPageSpeedWarning.jsx @@ -0,0 +1,69 @@ +import Box from "@mui/material/Box"; +import { useTheme } from "@mui/material/styles"; +import Link from "@mui/material/Link"; +import { Link as RouterLink } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import Alert from "../Alert"; +import PropTypes from "prop-types"; + +const renderWarningMessage = (t) => { + return ( + <> + {t("pageSpeedWarning")}{" "} + + {t("pageSpeedLearnMoreLink")} + {" "} + {t("pageSpeedAddApiKey")} + + ); +}; + +const FallbackPageSpeedWarning = ({ settingsData }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + svg": { + color: theme.palette.warningSecondary.contrastText, + }, + }, + }} + > + {settingsData?.pagespeedKeySet === false && ( + + )} + + + ); +}; + +FallbackPageSpeedWarning.propTypes = { + settingsData: PropTypes.shape({ + pagespeedKeySet: PropTypes.bool, + }), +}; + +export default FallbackPageSpeedWarning; diff --git a/client/src/Components/Fallback/FallbackTitle.jsx b/client/src/Components/Fallback/FallbackTitle.jsx new file mode 100644 index 000000000..f7f42f2ca --- /dev/null +++ b/client/src/Components/Fallback/FallbackTitle.jsx @@ -0,0 +1,21 @@ +import { useTheme } from "@mui/material/styles"; +import { Typography } from "@mui/material"; +import PropTypes from "prop-types"; + +const FallbackTitle = ({ title }) => { + const theme = useTheme(); + return ( + + {title} + + ); +}; +FallbackTitle.propTypes = { + title: PropTypes.string.isRequired, +}; +export default FallbackTitle; diff --git a/client/src/Components/Fallback/index.css b/client/src/Components/Fallback/index.css index 357988b07..6988fa5bf 100644 --- a/client/src/Components/Fallback/index.css +++ b/client/src/Components/Fallback/index.css @@ -38,7 +38,3 @@ background-size: cover; background-repeat: no-repeat; } - -.fallback__status > .MuiStack-root { - margin-left: var(--env-var-spacing-2); -} diff --git a/client/src/Components/Fallback/index.jsx b/client/src/Components/Fallback/index.jsx index dd6043697..5fbb958e7 100644 --- a/client/src/Components/Fallback/index.jsx +++ b/client/src/Components/Fallback/index.jsx @@ -1,72 +1,26 @@ import PropTypes from "prop-types"; import { useTheme } from "@emotion/react"; -import { Box, Button, Stack, Typography, Link } from "@mui/material"; -import { Link as RouterLink } from "react-router-dom"; -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"; -import Check from "../Check/Check"; -import { useNavigate } from "react-router-dom"; -import { useSelector } from "react-redux"; -import Alert from "../Alert"; -import { useTranslation } from "react-i18next"; +import { Box, Stack } from "@mui/material"; import "./index.css"; -import { useFetchSettings } from "../../Hooks/settingsHooks"; -import { useState } from "react"; +import FallbackTitle from "./FallbackTitle"; +import FallbackCheckList from "./FallbackCheckList"; +import FallbackActionButtons from "./FallBackActionButtons"; +import FallbackBackground from "./FallbackBackground"; +import FallbackContainer from "./FallbackContainer"; /** * Fallback component to display a fallback UI with a title, a list of checks, and a navigation button. * * @param {Object} props - The component props. * @param {string} props.title - The title to be displayed in the fallback UI. + * @param {string} props.type - The type of the fallback (e.g., "pageSpeed", "notifications"). * @param {Array} props.checks - An array of strings representing the checks to display. * @param {string} [props.link="/"] - The link to navigate to. - * @param {boolean} [props.vowelStart=false] - Whether the title starts with a vowel. * @param {boolean} [props.showPageSpeedWarning=false] - Whether to show the PageSpeed API warning. * @returns {JSX.Element} The rendered fallback UI. */ -const Fallback = ({ - title, - checks, - link = "/", - isAdmin, - vowelStart = false, - showPageSpeedWarning = false, -}) => { +const Fallback = ({ title, checks, link = "/", isAdmin, type, children }) => { const theme = useTheme(); - const navigate = useNavigate(); - const mode = useSelector((state) => state.ui.mode); - const { t } = useTranslation(); - const [settingsData, setSettingsData] = useState(undefined); - - const [isLoading, error] = useFetchSettings({ - setSettingsData, - setIsApiKeySet: () => {}, - setIsEmailPasswordSet: () => {}, - }); - // Custom warning message with clickable link - const renderWarningMessage = () => { - return ( - <> - {t("pageSpeedWarning")}{" "} - - {t("pageSpeedLearnMoreLink")} - {" "} - {t("pageSpeedAddApiKey")} - - ); - }; return ( - + + - {mode === "light" ? ( - - ) : ( - - )} - - - - - - {vowelStart ? "An" : "A"} {title} is used to: - - {checks?.map((check, index) => ( - - ))} - - {/* TODO - display a different fallback if user is not an admin*/} - {isAdmin && ( - - - {/* Bulk create of uptime monitors */} - {title === "uptime monitor" && ( - - )} - - {/* Warning box for PageSpeed monitor */} - {title === "pagespeed monitor" && showPageSpeedWarning && ( - - svg": { - color: theme.palette.warningSecondary.contrastText, - }, - }, - }} - > - {settingsData?.pagespeedKeySet === false && ( - - )} - - - )} - - )} + + - + {isAdmin && ( + + + {children} + + )} + ); }; - Fallback.propTypes = { title: PropTypes.string.isRequired, checks: PropTypes.arrayOf(PropTypes.string).isRequired, link: PropTypes.string, isAdmin: PropTypes.bool, - vowelStart: PropTypes.bool, showPageSpeedWarning: PropTypes.bool, + type: PropTypes.string.isRequired, + children: PropTypes.node, }; - export default Fallback; diff --git a/client/src/Pages/Infrastructure/Monitors/index.jsx b/client/src/Pages/Infrastructure/Monitors/index.jsx index 6feb933db..f7ca892c2 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.jsx +++ b/client/src/Pages/Infrastructure/Monitors/index.jsx @@ -99,13 +99,9 @@ const InfrastructureMonitors = () => { if (!isLoading && typeof summary?.totalMonitors === "undefined") { return ( diff --git a/client/src/Pages/Maintenance/index.jsx b/client/src/Pages/Maintenance/index.jsx index 66b80f87d..9f9528a67 100644 --- a/client/src/Pages/Maintenance/index.jsx +++ b/client/src/Pages/Maintenance/index.jsx @@ -68,12 +68,9 @@ const Maintenance = () => { if (isDataFetched && maintenanceWindows.length === 0) { return ( diff --git a/client/src/Pages/Notifications/index.jsx b/client/src/Pages/Notifications/index.jsx index 28feb99ea..f9fc2e674 100644 --- a/client/src/Pages/Notifications/index.jsx +++ b/client/src/Pages/Notifications/index.jsx @@ -79,7 +79,7 @@ const Notifications = () => { if (notifications?.length === 0) { return ( { order: null, }); + const [settingsData, setSettingsData] = useState(undefined); + const [isSettingsLoading, settingsError] = useFetchSettings({ + setSettingsData, + setIsApiKeySet: () => {}, + setIsEmailPasswordSet: () => {}, + }); + if (networkError === true) { return ( @@ -48,16 +58,16 @@ const PageSpeed = () => { if (!isLoading && monitors?.length === 0) { return ( + > + {isAdmin && settingsData && !settingsData.pagespeedApiKey && ( + + )} + ); } diff --git a/client/src/Pages/StatusPage/StatusPages/index.jsx b/client/src/Pages/StatusPage/StatusPages/index.jsx index 650307bdb..7057eec0c 100644 --- a/client/src/Pages/StatusPage/StatusPages/index.jsx +++ b/client/src/Pages/StatusPage/StatusPages/index.jsx @@ -42,12 +42,9 @@ const StatusPages = () => { if (!isLoading && typeof statusPages !== "undefined" && statusPages.length === 0) { return ( diff --git a/client/src/Pages/Uptime/Monitors/index.jsx b/client/src/Pages/Uptime/Monitors/index.jsx index 6e6f6caba..4ebac94d3 100644 --- a/client/src/Pages/Uptime/Monitors/index.jsx +++ b/client/src/Pages/Uptime/Monitors/index.jsx @@ -174,14 +174,9 @@ const UptimeMonitors = () => { ) { return ( diff --git a/client/src/locales/en.json b/client/src/locales/en.json index c36014fb3..654a29c83 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -428,6 +428,17 @@ "infrastructureDisplayNameLabel": "Display name", "infrastructureEditMonitor": "Save Infrastructure Monitor", "infrastructureEditYour": "Edit your", + "infrastructureMonitor": { + "fallback": { + "checks": [ + "Track the performance of your servers", + "Identify bottlenecks and optimize usage", + "Ensure reliability with real-time monitoring" + ], + "title": "An infrastructure monitor is used to:", + "actionButton": "Let's create your first infrastructure monitor!" + } + }, "infrastructureMonitorCreated": "Infrastructure monitor created successfully!", "infrastructureMonitorUpdated": "Infrastructure monitor updated successfully!", "infrastructureProtocol": "Protocol", @@ -492,6 +503,17 @@ "maintenance": "maintenance", "maintenanceRepeat": "Maintenance Repeat", "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", + "maintenanceWindow": { + "fallback": { + "checks": [ + "Mark your maintenance periods", + "Eliminate any misunderstandings", + "Stop sending alerts in maintenance windows" + ], + "title": "A maintenance window is used to:", + "actionButton": "Let's create your first maintenance window!" + } + }, "maintenanceWindowDescription": "Your pings won't be sent during this time frame", "maintenanceWindowName": "Maintenance Window Name", "maskedPageSpeedKeyPlaceholder": "*************************************", @@ -589,7 +611,8 @@ "Let engineers know when incidents happen", "Keep administrators informed of system changes" ], - "title": "notification channel" + "title": "A notification channel is used to:", + "actionButton": "Let's create your first notification channel!" }, "fetch": { "failed": "Failed to fetch notifications", @@ -634,6 +657,17 @@ "notifySMS": "Notify via SMS (coming soon)", "now": "Now", "os": "OS", + "pageSpeed": { + "fallback": { + "checks": [ + "Report on the user experience of a page", + "Help analyze webpage speed", + "Give suggestions on how the page can be improved" + ], + "title": "A PageSpeed monitor is used to:", + "actionButton": "Let's create your first PageSpeed monitor!" + } + }, "pageSpeedAddApiKey": "to add your API key.", "pageSpeedConfigureSettingsDescription": "Here you can select the URL of the host, together with the type of monitor.", "pageSpeedDetailsPerformanceReport": "Values are estimated and may vary.", @@ -847,7 +881,16 @@ "createSuccess": "Status page created successfully", "updateSuccess": "Status page updated successfully", "generalSettings": "General settings", - "contents": "Contents" + "contents": "Contents", + "fallback": { + "checks": [ + "Monitor and display the health of your services in real time", + "Track multiple services and share their status", + "Keep users informed about outages and performance" + ], + "title": "A status page is used to:", + "actionButton": "Let's create your first status page!" + } }, "submit": "Submit", "teamPanel": { @@ -901,6 +944,18 @@ "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard." }, + "uptimeMonitor": { + "fallback": { + "checks": [ + "Check if websites or servers are online & responsive", + "Alert teams about downtime or performance issues", + "Monitor HTTP endpoints, pings, containers & ports", + "Track historical uptime and reliability trends" + ], + "title": "An uptime monitor is used to:", + "actionButton": "Let's create your first uptime monitor!" + } + }, "url": "URL", "urlMonitor": "URL to monitor", "used": "Used", From f2037bf5fe56b0c205bd51ed37517e8ee60611b8 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 13:46:06 -0700 Subject: [PATCH 027/226] fix route --- client/src/Utils/NetworkService.js | 2 +- server/routes/monitorRoute.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index 54bb239a4..f1258091b 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -977,7 +977,7 @@ class NetworkService { }); } - return this.axiosInstance.get(`/monitors/summary/team?${params.toString()}`, { + return this.axiosInstance.get(`/monitors/team/summary?${params.toString()}`, { headers: { "Content-Type": "application/json", }, diff --git a/server/routes/monitorRoute.js b/server/routes/monitorRoute.js index 2911bf9d7..3f4a40c1a 100755 --- a/server/routes/monitorRoute.js +++ b/server/routes/monitorRoute.js @@ -20,7 +20,7 @@ class MonitorRoutes { // Team routes this.router.get("/team", this.monitorController.getMonitorsByTeamId); this.router.get("/team/with-checks", this.monitorController.getMonitorsWithChecksByTeamId); - this.router.get("/summary/team", this.monitorController.getMonitorsAndSummaryByTeamId); // TODO should be /team/summary + this.router.get("/team/summary", this.monitorController.getMonitorsAndSummaryByTeamId); // TODO should be /team/summary // Uptime routes this.router.get("/uptime", this.monitorController.getAllMonitorsWithUptimeStats); From fdff3be316d8ce3b2e3fb951c7ba54d54499afa9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 13:58:01 -0700 Subject: [PATCH 028/226] fix team auth --- server/controllers/monitorController.js | 94 +++++++++++++++++------- server/db/mongo/modules/monitorModule.js | 48 ------------ server/routes/monitorRoute.js | 12 +-- 3 files changed, 71 insertions(+), 83 deletions(-) diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index 79579e521..9943a765a 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -20,6 +20,7 @@ import axios from "axios"; import seedDb from "../db/mongo/utils/seedDb.js"; import pkg from "papaparse"; import { asyncHandler, createServerError } from "../utils/errorUtils.js"; +import { fetchMonitorCertificate } from "./controllerUtils.js"; const SERVICE_NAME = "monitorController"; class MonitorController { @@ -31,6 +32,15 @@ class MonitorController { this.emailService = emailService; } + async verifyTeamAccess(teamId, monitorId) { + const monitor = await this.db.getMonitorById(monitorId); + if (!monitor.teamId.equals(teamId)) { + const error = new Error("Unauthorized"); + error.status = 403; + throw error; + } + } + /** * Returns all monitors * @async @@ -52,32 +62,18 @@ class MonitorController { "getAllMonitors" ); - /** - * Returns all monitors with uptime stats for 1,7,30, and 90 days - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @param {function} next - * @returns {Promise} - * @throws {Error} - */ - getAllMonitorsWithUptimeStats = asyncHandler( - async (req, res, next) => { - const monitors = await this.db.getAllMonitorsWithUptimeStats(); - return res.success({ - msg: this.stringService.monitorGetAll, - data: monitors, - }); - }, - SERVICE_NAME, - "getAllMonitorsWithUptimeStats" - ); - getUptimeDetailsById = asyncHandler( async (req, res, next) => { const { monitorId } = req.params; const { dateRange, normalize } = req.query; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + await this.verifyTeamAccess(teamId, monitorId); + const data = await this.db.getUptimeDetailsById({ monitorId, dateRange, @@ -109,6 +105,13 @@ class MonitorController { let { limit, sortOrder, dateRange, numToDisplay, normalize } = req.query; const { monitorId } = req.params; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + await this.verifyTeamAccess(teamId, monitorId); + const monitorStats = await this.db.getMonitorStatsById({ monitorId, limit, @@ -140,8 +143,14 @@ class MonitorController { await getHardwareDetailsByIdParamValidation.validateAsync(req.params); await getHardwareDetailsByIdQueryValidation.validateAsync(req.query); - const { monitorId } = req.params; - const { dateRange } = req.query; + const monitorId = req?.params?.monitorId; + const dateRange = req?.query?.dateRange; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + await this.verifyTeamAccess(teamId, monitorId); const monitor = await this.db.getHardwareDetailsById({ monitorId, dateRange }); return res.success({ msg: this.stringService.monitorGetByIdSuccess, @@ -153,7 +162,7 @@ class MonitorController { ); getMonitorCertificate = asyncHandler( - async (req, res, next, fetchMonitorCertificate) => { + async (req, res, next) => { await getCertificateParamValidation.validateAsync(req.params); const { monitorId } = req.params; @@ -188,6 +197,18 @@ class MonitorController { await getMonitorByIdQueryValidation.validateAsync(req.query); const monitor = await this.db.getMonitorById(req.params.monitorId); + + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + if (!monitor.teamId.equals(teamId)) { + const error = new Error("Unauthorized"); + error.status = 403; + throw error; + } + return res.success({ msg: this.stringService.monitorGetByIdSuccess, data: monitor, @@ -367,6 +388,13 @@ class MonitorController { async (req, res, next) => { await getMonitorByIdParamValidation.validateAsync(req.params); const monitorId = req.params.monitorId; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + await this.verifyTeamAccess(teamId, monitorId); + const monitor = await this.db.deleteMonitor({ monitorId }); await this.jobQueue.deleteJob(monitor); await this.db.deleteStatusPagesByMonitorId(monitor._id); @@ -389,7 +417,7 @@ class MonitorController { */ deleteAllMonitors = asyncHandler( async (req, res, next) => { - const { teamId } = req.user; + const teamId = req?.user?.teamId; const { monitors, deletedCount } = await this.db.deleteAllMonitors(teamId); await Promise.all( monitors.map(async (monitor) => { @@ -431,7 +459,14 @@ class MonitorController { async (req, res, next) => { await getMonitorByIdParamValidation.validateAsync(req.params); await editMonitorBodyValidation.validateAsync(req.body); - const { monitorId } = req.params; + const monitorId = req?.params?.monitorId; + + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + await this.verifyTeamAccess(teamId, monitorId); const editedMonitor = await this.db.editMonitor(monitorId, req.body); @@ -462,6 +497,13 @@ class MonitorController { await pauseMonitorParamValidation.validateAsync(req.params); const monitorId = req.params.monitorId; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + await this.verifyTeamAccess(teamId, monitorId); + const monitor = await this.db.pauseMonitor({ monitorId }); monitor.isActive === true ? await this.jobQueue.resumeJob(monitor._id, monitor) : await this.jobQueue.pauseJob(monitor); diff --git a/server/db/mongo/modules/monitorModule.js b/server/db/mongo/modules/monitorModule.js index b331bc337..3192b711c 100755 --- a/server/db/mongo/modules/monitorModule.js +++ b/server/db/mongo/modules/monitorModule.js @@ -56,53 +56,6 @@ const getAllMonitors = async (req, res) => { } }; -/** - * Get all monitors with uptime stats for 1,7,30, and 90 days - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @returns {Promise>} - * @throws {Error} - */ -const getAllMonitorsWithUptimeStats = async () => { - const timeRanges = { - 1: new Date(Date.now() - 24 * 60 * 60 * 1000), - 7: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), - 30: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), - 90: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), - }; - - try { - const monitors = await Monitor.find(); - const monitorsWithStats = await Promise.all( - monitors.map(async (monitor) => { - const model = CHECK_MODEL_LOOKUP[monitor.type]; - - const uptimeStats = await Promise.all( - Object.entries(timeRanges).map(async ([days, startDate]) => { - const checks = await model.find({ - monitorId: monitor._id, - createdAt: { $gte: startDate }, - }); - return [days, getUptimePercentage(checks)]; - }) - ); - - return { - ...monitor.toObject(), - ...Object.fromEntries(uptimeStats), - }; - }) - ); - - return monitorsWithStats; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getAllMonitorsWithUptimeStats"; - throw error; - } -}; - /** * Function to calculate uptime duration based on the most recent check. * @param {Array} checks Array of check objects. @@ -761,7 +714,6 @@ const pauseMonitor = async ({ monitorId }) => { export { getAllMonitors, - getAllMonitorsWithUptimeStats, getMonitorStatsById, getMonitorById, getMonitorsByIds, diff --git a/server/routes/monitorRoute.js b/server/routes/monitorRoute.js index 3f4a40c1a..15553fc1c 100755 --- a/server/routes/monitorRoute.js +++ b/server/routes/monitorRoute.js @@ -20,10 +20,9 @@ class MonitorRoutes { // Team routes this.router.get("/team", this.monitorController.getMonitorsByTeamId); this.router.get("/team/with-checks", this.monitorController.getMonitorsWithChecksByTeamId); - this.router.get("/team/summary", this.monitorController.getMonitorsAndSummaryByTeamId); // TODO should be /team/summary + this.router.get("/team/summary", this.monitorController.getMonitorsAndSummaryByTeamId); // Uptime routes - this.router.get("/uptime", this.monitorController.getAllMonitorsWithUptimeStats); this.router.get("/uptime/details/:monitorId", this.monitorController.getUptimeDetailsById); // Hardware routes @@ -53,13 +52,8 @@ class MonitorRoutes { // Individual monitor CRUD routes this.router.get("/:monitorId", this.monitorController.getMonitorById); - this.router.put("/:monitorId", verifyTeamAccess(Monitor, "monitorId"), isAllowed(["admin", "superadmin"]), this.monitorController.editMonitor); - this.router.delete( - "/:monitorId", - verifyOwnership(Monitor, "monitorId"), - isAllowed(["admin", "superadmin"]), - this.monitorController.deleteMonitor - ); + this.router.put("/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.editMonitor); + this.router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.deleteMonitor); } getRouter() { From 1b085236c7ae6165cf926f0911d1d45fe48fa836 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 14:11:09 -0700 Subject: [PATCH 029/226] fix aith for notification controller --- server/controllers/notificationController.js | 76 +++++++++++++++++-- server/db/mongo/modules/notificationModule.js | 5 ++ server/routes/notificationRoute.js | 10 +-- 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/server/controllers/notificationController.js b/server/controllers/notificationController.js index 4e21e7284..41af21ece 100755 --- a/server/controllers/notificationController.js +++ b/server/controllers/notificationController.js @@ -39,9 +39,19 @@ class NotificationController { }); const body = req.body; - const { _id, teamId } = req.user; - body.userId = _id; + + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + + const userId = req?.user?._id; + if (!userId) { + throw new Error("No user ID in request"); + } + body.userId = userId; body.teamId = teamId; + const notification = await this.db.createNotification(body); return res.success({ msg: "Notification created successfully", @@ -54,7 +64,13 @@ class NotificationController { getNotificationsByTeamId = asyncHandler( async (req, res, next) => { - const notifications = await this.db.getNotificationsByTeamId(req.user.teamId); + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + + const notifications = await this.db.getNotificationsByTeamId(teamId); + return res.success({ msg: "Notifications fetched successfully", data: notifications, @@ -66,6 +82,18 @@ class NotificationController { deleteNotification = asyncHandler( async (req, res, next) => { + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + + const notification = await this.db.getNotificationById(req.params.id); + if (!notification.teamId.equals(teamId)) { + const error = new Error("Unauthorized"); + error.status = 403; + throw error; + } + await this.db.deleteNotificationById(req.params.id); return res.success({ msg: "Notification deleted successfully", @@ -78,6 +106,17 @@ class NotificationController { getNotificationById = asyncHandler( async (req, res, next) => { const notification = await this.db.getNotificationById(req.params.id); + + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + + if (!notification.teamId.equals(teamId)) { + const error = new Error("Unauthorized"); + error.status = 403; + throw error; + } return res.success({ msg: "Notification fetched successfully", data: notification, @@ -93,10 +132,23 @@ class NotificationController { abortEarly: false, }); - const notification = await this.db.editNotification(req.params.id, req.body); + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + + const notification = await this.db.getNotificationById(req.params.id); + + if (!notification.teamId.equals(teamId)) { + const error = new Error("Unauthorized"); + error.status = 403; + throw error; + } + + const editedNotification = await this.db.editNotification(req.params.id, req.body); return res.success({ msg: "Notification updated successfully", - data: notification, + data: editedNotification, }); }, SERVICE_NAME, @@ -105,8 +157,20 @@ class NotificationController { testAllNotifications = asyncHandler( async (req, res, next) => { - const { monitorId } = req.body; + const monitorId = req.body.monitorId; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("No team ID in request"); + } + const monitor = await this.db.getMonitorById(monitorId); + + if (!monitor.teamId.equals(teamId)) { + const error = new Error("Unauthorized"); + error.status = 403; + throw error; + } + const notifications = monitor.notifications; if (notifications.length === 0) throw new Error("No notifications"); const result = await this.notificationService.testAllNotifications(notifications); diff --git a/server/db/mongo/modules/notificationModule.js b/server/db/mongo/modules/notificationModule.js index 08d57b1a9..2348a6321 100755 --- a/server/db/mongo/modules/notificationModule.js +++ b/server/db/mongo/modules/notificationModule.js @@ -74,6 +74,11 @@ const deleteNotificationsByMonitorId = async (monitorId) => { const deleteNotificationById = async (id) => { try { + const notification = await Notification.findById(id); + if (!notification) { + throw new Error("Notification not found"); + } + const result = await Notification.findByIdAndDelete(id); await Monitor.updateMany({ notifications: id }, { $pull: { notifications: id } }); return result; diff --git a/server/routes/notificationRoute.js b/server/routes/notificationRoute.js index 6c77d8519..f88b0b96c 100755 --- a/server/routes/notificationRoute.js +++ b/server/routes/notificationRoute.js @@ -1,8 +1,4 @@ import { Router } from "express"; -import { verifyJWT } from "../middleware/verifyJWT.js"; -import { verifyOwnership } from "../middleware/verifyOwnership.js"; -import { verifyTeamAccess } from "../middleware/verifyTeamAccess.js"; -import Notification from "../db/models/Notification.js"; class NotificationRoutes { constructor(notificationController) { this.router = Router(); @@ -11,8 +7,6 @@ class NotificationRoutes { } initializeRoutes() { - this.router.use(verifyJWT); - this.router.post("/", this.notificationController.createNotification); this.router.post("/test/all", this.notificationController.testAllNotifications); @@ -20,9 +14,9 @@ class NotificationRoutes { this.router.get("/team", this.notificationController.getNotificationsByTeamId); - this.router.delete("/:id", verifyOwnership(Notification, "id"), this.notificationController.deleteNotification); this.router.get("/:id", this.notificationController.getNotificationById); - this.router.put("/:id", verifyTeamAccess(Notification, "id"), this.notificationController.editNotification); + this.router.delete("/:id", this.notificationController.deleteNotification); + this.router.put("/:id", this.notificationController.editNotification); } getRouter() { From 48848d1d43eeaceb12aa93004d9efe3fad87d46f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 21 Jul 2025 15:05:17 -0700 Subject: [PATCH 030/226] prevent demo user from being deleted --- server/controllers/authController.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/controllers/authController.js b/server/controllers/authController.js index 1cc1a747a..ace63b662 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -340,7 +340,6 @@ class AuthController { if (!email) { throw new Error("No email in request"); } - // 1. Find all the monitors associated with the team ID if superadmin const teamId = req?.user?.teamId; const userId = req?.user?._id; @@ -354,7 +353,11 @@ class AuthController { } const roles = req.user.role; + if (roles.includes("demo")) { + throw new Error("Demo user cannot be deleted"); + } + // 1. Find all the monitors associated with the team ID if superadmin const result = await this.db.getMonitorsByTeamId({ teamId: teamId, }); From 43ac0174d1fce96df0159154111061b986bc7548 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 09:23:26 -0700 Subject: [PATCH 031/226] format --- server/validation/joi.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/server/validation/joi.js b/server/validation/joi.js index 57c8b7350..b33283d98 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -164,11 +164,7 @@ const createMonitorBodyValidation = joi.object({ .custom((value, helpers) => { // 1. Standard URLs: must have protocol and pass canParse() if (/^(https?:\/\/)/.test(value)) { - if ( - typeof URL !== "undefined" && - typeof URL.canParse === "function" && - URL.canParse(value) - ) { + if (typeof URL !== "undefined" && typeof URL.canParse === "function" && URL.canParse(value)) { return value; } // else, it's a malformed URL with protocol @@ -197,8 +193,7 @@ const createMonitorBodyValidation = joi.object({ .messages({ "string.empty": "This field is required.", "string.uri": "The URL you provided is not valid.", - "string.invalidUrl": - "Please enter a valid URL, hostname, or container name (with optional port).", + "string.invalidUrl": "Please enter a valid URL, hostname, or container name (with optional port).", }), ignoreTlsErrors: joi.boolean().default(false), port: joi.number(), From 97082b30b97649216f61b53791924872749210cf Mon Sep 17 00:00:00 2001 From: Mohansai Mallineni Date: Sun, 20 Jul 2025 20:08:06 +0530 Subject: [PATCH 032/226] feat(i18n): implement new language mappings and initialize translation files --- client/src/Components/LanguageSelector.jsx | 14 +- client/src/locales/fi.json | 836 --------------------- client/src/locales/fr.json | 836 --------------------- client/src/locales/{ar.json => ja.json} | 0 client/src/locales/{es.json => uk.json} | 4 +- client/src/locales/{cs.json => vi.json} | 188 ++--- 6 files changed, 106 insertions(+), 1772 deletions(-) delete mode 100644 client/src/locales/fi.json delete mode 100644 client/src/locales/fr.json rename client/src/locales/{ar.json => ja.json} (100%) rename client/src/locales/{es.json => uk.json} (99%) rename client/src/locales/{cs.json => vi.json} (74%) diff --git a/client/src/Components/LanguageSelector.jsx b/client/src/Components/LanguageSelector.jsx index 284d0db05..57d102a9e 100644 --- a/client/src/Components/LanguageSelector.jsx +++ b/client/src/Components/LanguageSelector.jsx @@ -6,6 +6,14 @@ import { useSelector } from "react-redux"; import { useDispatch } from "react-redux"; import { setLanguage } from "../Features/UI/uiSlice"; + +const langMap = { + cs: "cz", + ja: "jp", + uk: "ua", + vi: "vn", +}; + const LanguageSelector = () => { const { i18n } = useTranslation(); const theme = useTheme(); @@ -28,14 +36,12 @@ const LanguageSelector = () => { {languages.map((lang) => { let parsedLang = lang === "en" ? "gb" : lang; - // Fix for Czech - if (parsedLang === "cs") { - parsedLang = "cz"; - } if (parsedLang.includes("-")) { parsedLang = parsedLang.split("-")[1].toLowerCase(); } + parsedLang = langMap[parsedLang] || parsedLang; + const flag = parsedLang ? `fi fi-${parsedLang}` : null; return ( diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json deleted file mode 100644 index 90b30e925..000000000 --- a/client/src/locales/fi.json +++ /dev/null @@ -1,836 +0,0 @@ -{ - "submit": "Lähetä", - "title": "Otsikko", - "distributedStatusHeaderText": "Reaaliaikainen kattavuus oikeilla laitteilla", - "distributedStatusSubHeaderText": "Miljoonien laitteiden voimin ympäri maailman näet järjestelmän suorituskyvyn maailman alueittain, maittain tai kaupungeittain", - "settingsDisabled": "Poistettu käytöstä", - "settingsSuccessSaved": "", - "settingsFailedToSave": "", - "settingsStatsCleared": "", - "settingsFailedToClearStats": "", - "settingsFailedToAddDemoMonitors": "", - "settingsMonitorsDeleted": "", - "settingsFailedToDeleteMonitors": "", - "starPromptTitle": "", - "starPromptDescription": "", - "https": "HTTPS", - "http": "HTTP", - "monitor": "Seurain", - "aboutus": "Tietoja meistä", - "now": "Nyt", - "delete": "Poista", - "configure": "Määritä", - "responseTime": "Vasteaika", - "ms": "ms", - "bar": "Palkki", - "area": "Alue", - "country": "MAA", - "city": "KAUPUNKI", - "response": "Vastaus", - "monitorStatusUp": "Valvontakohde {name} ({url}) on nyt toiminnassa ja vastaa", - "monitorStatusDown": "Valvontakohde {name} ({url}) ei ole toiminnassa, eikä vastaa", - "webhookSendSuccess": "Webhook-ilmoitus lähetettiin onnistuneesti", - "webhookSendError": "Virhe lähetettäessä webhook-ilmoitusta palveluun {platform}", - "webhookUnsupportedPlatform": "Ei tuettu palvelu: {platform}", - "distributedRightCategoryTitle": "Valvonta", - "distributedStatusServerMonitors": "Palvelin valvonta", - "distributedStatusServerMonitorsDescription": "Valvo palvelimien tilaa", - "distributedUptimeCreateSelectURL": "Tässä voit valita isäntäkohteen URL-osoitteen ja valvontatyypin.", - "distributedUptimeCreateChecks": "Suoritettavat tarkistukset", - "distributedUptimeCreateChecksDescription": "Voit aina lisätä tai poistaa tarkistuksia sivuston lisäämisen jälkeen.", - "distributedUptimeCreateIncidentNotification": "Häiriöilmoitukset", - "distributedUptimeCreateIncidentDescription": "Jos ilmenee häiriö, ilmoita käyttäjille.", - "distributedUptimeCreateAdvancedSettings": "Lisäasetukset", - "distributedUptimeDetailsNoMonitorHistory": "Tälle valvontakohteelle ei ole vielä tarkistushistoriaa.", - "distributedUptimeDetailsStatusHeaderUptime": "", - "distributedUptimeDetailsStatusHeaderLastUpdate": "Viimeksi päivitetty", - "notifications": { - "enableNotifications": "", - "testNotification": "Testi-ilmoitus", - "addOrEditNotifications": "Lisää tai muokkaa ilmoituksia", - "slack": { - "label": "Slack", - "description": "", - "webhookLabel": "Webhookin URL", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "discord": { - "label": "Discord", - "description": "", - "webhookLabel": "Discord Webhookin URL-osoite", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "" - }, - "telegram": { - "label": "Telegram", - "description": "", - "tokenLabel": "", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "" - }, - "webhook": { - "label": "Webhookit", - "description": "", - "urlLabel": "Webhookin URL", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "" - }, - "testNotificationDevelop": "Testi-ilmoitus 2", - "integrationButton": "", - "testSuccess": "Testi ilmoituksen lähetys onnistui!", - "testFailed": "Testi ilmoituksen lähetys epäonnistui", - "unsupportedType": "", - "networkError": "", - "fallback": { - "title": "", - "checks": [""] - }, - "createButton": "", - "createTitle": "", - "create": { - "success": "", - "failed": "" - }, - "fetch": { - "success": "", - "failed": "" - }, - "delete": { - "success": "", - "failed": "" - }, - "edit": { - "success": "", - "failed": "" - }, - "test": { - "success": "", - "failed": "" - } - }, - "testLocale": "", - "add": "Lisää", - "monitors": "Seuraimet", - "distributedUptimeStatusCreateStatusPage": "", - "distributedUptimeStatusCreateStatusPageAccess": "", - "distributedUptimeStatusCreateStatusPageReady": "", - "distributedUptimeStatusBasicInfoHeader": "", - "distributedUptimeStatusBasicInfoDescription": "", - "distributedUptimeStatusLogoHeader": "Logo", - "distributedUptimeStatusLogoDescription": "", - "distributedUptimeStatusLogoUploadButton": "", - "distributedUptimeStatusStandardMonitorsHeader": "", - "distributedUptimeStatusStandardMonitorsDescription": "", - "distributedUptimeStatusCreateYour": "", - "distributedUptimeStatusEditYour": "", - "distributedUptimeStatusPublishedLabel": "", - "distributedUptimeStatusCompanyNameLabel": "Yrityksen nimi", - "distributedUptimeStatusPageAddressLabel": "", - "distributedUptimeStatus30Days": "30 päivää", - "distributedUptimeStatus60Days": "60 päivää", - "distributedUptimeStatus90Days": "90 päivää", - "distributedUptimeStatusPageNotSetUp": "", - "distributedUptimeStatusContactAdmin": "Ota yhteys järjestelmävalvojaasi", - "distributedUptimeStatusPageNotPublic": "", - "distributedUptimeStatusPageDeleteDialog": "", - "distributedUptimeStatusPageDeleteConfirm": "", - "distributedUptimeStatusPageDeleteDescription": "", - "distributedUptimeStatusDevices": "", - "distributedUptimeStatusUpt": "", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "UPT logo", - "incidentsTableNoIncidents": "", - "incidentsTablePaginationLabel": "", - "incidentsTableMonitorName": "", - "incidentsTableStatus": "Tila", - "incidentsTableDateTime": "Päivämäärä ja aika", - "incidentsTableStatusCode": "", - "incidentsTableMessage": "Viesti", - "incidentsOptionsHeader": "", - "incidentsOptionsHeaderFilterBy": "", - "incidentsOptionsHeaderFilterAll": "Kaikki", - "incidentsOptionsHeaderFilterDown": "Alhaalla", - "incidentsOptionsHeaderFilterCannotResolve": "", - "incidentsOptionsHeaderShow": "Näytä:", - "incidentsOptionsHeaderLastHour": "Viime tunti", - "incidentsOptionsHeaderLastDay": "Viime päivä", - "incidentsOptionsHeaderLastWeek": "Viime viikko", - "incidentsOptionsPlaceholderAllServers": "Kaikki palvelimet", - "infrastructureCreateYour": "", - "infrastructureCreateGeneralSettingsDescription": "", - "infrastructureServerRequirement": "", - "infrastructureCustomizeAlerts": "", - "infrastructureAlertNotificationDescription": "", - "infrastructureCreateMonitor": "", - "infrastructureProtocol": "Protokolla", - "infrastructureServerUrlLabel": "Palvelimen URL-osoite", - "infrastructureDisplayNameLabel": "", - "infrastructureAuthorizationSecretLabel": "", - "gb": "", - "mb": "", - "mem": "", - "memoryUsage": "Muistinkäyttö", - "cpu": "", - "cpuUsage": "", - "cpuTemperature": "", - "diskUsage": "", - "used": "", - "total": "", - "cores": "", - "frequency": "", - "status": "Tila", - "cpuPhysical": "", - "cpuLogical": "", - "cpuFrequency": "", - "avgCpuTemperature": "", - "memory": "Muisti", - "disk": "", - "uptime": "", - "os": "", - "host": "", - "actions": "", - "integrations": "", - "integrationsPrism": "", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "", - "integrationsDiscord": "", - "integrationsDiscordInfo": "", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "", - "commonSave": "Tallenna", - "createYour": "", - "createMonitor": "", - "pause": "", - "resume": "", - "editing": "", - "url": "", - "access": "", - "timezone": "Aikavyöhyke", - "features": "", - "administrator": "", - "loginHere": "", - "displayName": "", - "urlMonitor": "", - "portToMonitor": "", - "websiteMonitoring": "", - "websiteMonitoringDescription": "", - "pingMonitoring": "", - "pingMonitoringDescription": "", - "dockerContainerMonitoring": "", - "dockerContainerMonitoringDescription": "", - "portMonitoring": "", - "portMonitoringDescription": "", - "createMaintenanceWindow": "", - "createMaintenance": "", - "editMaintenance": "", - "maintenanceWindowName": "", - "friendlyNameInput": "", - "friendlyNamePlaceholder": "", - "maintenanceRepeat": "", - "maintenance": "", - "duration": "", - "addMonitors": "", - "window": "", - "cancel": "Peruuttaa", - "message": "Viesti", - "low": "", - "high": "", - "statusCode": "Tilakoodi", - "date&Time": "Päivämäärä ja aika", - "type": "", - "statusPageName": "", - "publicURL": "Julkinen URL-osoite", - "repeat": "", - "edit": "Muokkaa", - "createA": "", - "remove": "", - "maintenanceWindowDescription": "", - "startTime": "", - "timeZoneInfo": "", - "monitorsToApply": "", - "nextWindow": "", - "notFoundButton": "", - "pageSpeedConfigureSettingsDescription": "", - "monitorDisplayName": "", - "whenNewIncident": "", - "notifySMS": "Ilmoita tekstiviestillä (tulossa pian)", - "notifyEmails": "", - "seperateEmails": "", - "checkFrequency": "", - "matchMethod": "", - "expectedValue": "", - "deleteDialogTitle": "", - "deleteDialogDescription": "", - "pageSpeedMonitor": "", - "shown": "", - "ago": "sitten", - "companyName": "Yrityksen nimi", - "pageSpeedDetailsPerformanceReport": "", - "pageSpeedDetailsPerformanceReportCalculator": "", - "checkingEvery": "", - "statusPageCreateSettings": "", - "basicInformation": "", - "statusPageCreateBasicInfoDescription": "", - "statusPageCreateSelectTimeZoneDescription": "", - "statusPageCreateAppearanceDescription": "", - "statusPageCreateSettingsCheckboxLabel": "", - "statusPageCreateBasicInfoStatusPageAddress": "", - "statusPageCreateTabsContent": "", - "statusPageCreateTabsContentDescription": "", - "statusPageCreateTabsContentFeaturesDescription": "", - "showCharts": "Näytä kaaviot", - "showUptimePercentage": "", - "removeLogo": "Poista logo", - "statusPageStatus": "", - "statusPageStatusContactAdmin": "Ota yhteyttä järjestelmänvalvojaan", - "statusPageStatusNotPublic": "", - "statusPageStatusNoPage": "", - "statusPageStatusServiceStatus": "", - "deleteStatusPage": "", - "deleteStatusPageConfirm": "", - "deleteStatusPageDescription": "", - "uptimeCreate": "", - "uptimeCreateJsonPath": "", - "uptimeCreateJsonPathQuery": "", - "maintenanceTableActionMenuDialogTitle": "", - "infrastructureEditYour": "Muokkaa omaa", - "infrastructureEditMonitor": "", - "infrastructureMonitorCreated": "", - "infrastructureMonitorUpdated": "", - "errorInvalidTypeId": "", - "errorInvalidFieldId": "", - "inviteNoTokenFound": "", - "pageSpeedWarning": "", - "pageSpeedLearnMoreLink": "", - "pageSpeedAddApiKey": "", - "update": "Päivitä", - "invalidFileFormat": "", - "invalidFileSize": "Tiedoston koko on liian suuri!", - "ClickUpload": "", - "DragandDrop": "raahaa ja pudota", - "MaxSize": "Enimmäiskoko", - "SupportedFormats": "Tuetut muodot", - "FirstName": "Etunimi", - "LastName": "Sukunimi", - "EmailDescriptionText": "Tämä on nykyinen sähköpostiosoitteesi — sitä ei voi vaihtaa.", - "YourPhoto": "Profiilikuva", - "PhotoDescriptionText": "", - "save": "Tallenna", - "DeleteDescriptionText": "", - "DeleteAccountWarning": "", - "DeleteWarningTitle": "Haluatko todella poistaa tämän tilin?", - "bulkImport": { - "title": "", - "selectFileTips": "", - "selectFileDescription": "", - "selectFile": "Valitse tiedosto", - "parsingFailed": "", - "uploadSuccess": "", - "validationFailed": "", - "noFileSelected": "Ei valittua tiedostoa", - "fallbackPage": "" - }, - "DeleteAccountTitle": "Poista tili", - "DeleteAccountButton": "Poista tili", - "publicLink": "Julkinen linkki", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "reset": "Palauta", - "ignoreTLSError": "", - "tlsErrorIgnored": "", - "ignoreTLSErrorDescription": "", - "createNew": "Luo uusi", - "greeting": { - "prepend": "Hei", - "append": "", - "overview": "" - }, - "roles": { - "superAdmin": "Pääylläpitäjä", - "admin": "Ylläpitäjä", - "teamMember": "Tiimin jäsen", - "demoUser": "Demokäyttäjä" - }, - "teamPanel": { - "teamMembers": "Tiimin jäsenet", - "filter": { - "all": "Kaikki", - "member": "Jäsen" - }, - "inviteTeamMember": "", - "inviteNewTeamMember": "", - "inviteDescription": "", - "email": "Sähköposti", - "selectRole": "Valitse rooli", - "inviteLink": "Kutsulinkki", - "cancel": "Peruuttaa", - "noMembers": "", - "getToken": "Hae tunniste", - "emailToken": "Sähköpostitunnus", - "table": { - "name": "Nimi", - "email": "Sähköposti", - "role": "Rooli", - "created": "Luotu" - } - }, - "monitorState": { - "paused": "Tauota", - "resumed": "Jatka", - "active": "" - }, - "menu": { - "uptime": "", - "pagespeed": "", - "infrastructure": "Infrastruktuuri", - "incidents": "", - "statusPages": "Tilasivut", - "maintenance": "Huolto", - "integrations": "", - "settings": "Asetukset", - "support": "Tuki", - "discussions": "Keskustelut", - "docs": "Dokumentit", - "changelog": "Muutosloki", - "profile": "Profiili", - "password": "Salasana", - "team": "Tiimi", - "logOut": "Kirjaudu ulos", - "notifications": "", - "logs": "" - }, - "settingsEmailUser": "", - "state": "Tila", - "statusBreadCrumbsStatusPages": "Tilasivut", - "statusBreadCrumbsDetails": "Tiedot", - "commonSaving": "Tallennetaan...", - "navControls": "Ohjaimet", - "incidentsPageTitle": "Häiriöt", - "passwordPanel": { - "passwordChangedSuccess": "Salasanasi vaihdettiin onnistuneesti.", - "passwordInputIncorrect": "", - "currentPassword": "Nykyinen salasana", - "enterCurrentPassword": "Kirjoita nykyinen salasanasi", - "newPassword": "Uusi salasana", - "enterNewPassword": "Kirjoita uusi salasana", - "confirmNewPassword": "Vahvista uusi salasana", - "passwordRequirements": "Uuden salasanan on oltava vähintään 8 merkkiä pitkä ja sen on sisältävä vähintään yksi iso kirjain, yksi pieni kirjain, yksi numero sekä yksi erikoismerkki.", - "saving": "Tallennetaan..." - }, - "emailSent": "Sähköpostin lähetys onnistui", - "failedToSendEmail": "Sähköpostin lähetys epäonnistui", - "settingsTestEmailSuccess": "Testisähköpostin lähetys onnistui", - "settingsTestEmailFailed": "Testisähköpostin lähetys epäonnistui", - "settingsTestEmailFailedWithReason": "Testisähköpostin lähetys epäonnistui: {{reason}}", - "settingsTestEmailUnknownError": "Tuntematon virhe", - "statusMsg": { - "paused": "Valvonta on keskeytetty.", - "up": "Sivusi on ylhäällä.", - "down": "Sivusi on alhaalla.", - "pending": "Odottaa..." - }, - "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "" - }, - "common": { - "appName": "", - "monitoringAgentName": "", - "buttons": { - "toggleTheme": "" - }, - "toasts": { - "networkError": "", - "checkConnection": "", - "unknownError": "" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "", - "back": "" - }, - "inputs": { - "email": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "invalid": "" - } - }, - "password": { - "label": "", - "rules": { - "length": { - "beginning": "", - "highlighted": "" - }, - "special": { - "beginning": "", - "highlighted": "" - }, - "number": { - "beginning": "", - "highlighted": "" - }, - "uppercase": { - "beginning": "", - "highlighted": "" - }, - "lowercase": { - "beginning": "", - "highlighted": "" - }, - "match": { - "beginning": "", - "highlighted": "" - } - }, - "errors": { - "empty": "", - "length": "", - "uppercase": "", - "lowercase": "", - "number": "", - "special": "", - "incorrect": "" - } - }, - "passwordConfirm": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "different": "" - } - }, - "firstName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - }, - "lastName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - } - }, - "errors": { - "validation": "" - }, - "fields": { - "password": { - "errors": { - "incorrect": "" - } - } - } - }, - "login": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "" - }, - "links": { - "forgotPassword": "", - "register": "", - "forgotPasswordLink": "", - "registerLink": "" - }, - "toasts": { - "success": "", - "incorrectPassword": "" - }, - "errors": { - "password": { - "incorrect": "" - } - } - }, - "registration": { - "heading": { - "superAdmin": "", - "user": "" - }, - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "" - }, - "description": { - "superAdmin": "", - "user": "" - }, - "gettingStartedButton": { - "superAdmin": "", - "user": "" - }, - "termsAndPolicies": "", - "links": { - "login": "" - }, - "toasts": { - "success": "" - } - }, - "forgotPassword": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "", - "stepFour": "" - }, - "buttons": { - "openEmail": "", - "resetPassword": "" - }, - "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" - }, - "links": { - "login": "", - "resend": "" - }, - "toasts": { - "sent": "", - "emailNotFound": "", - "redirect": "", - "success": "", - "error": "" - } - } - }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "", - "stillUnreachable": "" - }, - "alertBox": "", - "description": "", - "retryButton": { - "default": "", - "processing": "" - } - } - }, - "createNotifications": { - "title": "", - "nameSettings": { - "title": "", - "description": "", - "nameLabel": "", - "namePlaceholder": "" - }, - "typeSettings": { - "title": "", - "description": "", - "typeLabel": "" - }, - "emailSettings": { - "title": "", - "description": "", - "emailLabel": "", - "emailPlaceholder": "" - }, - "slackSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "pagerdutySettings": { - "title": "", - "description": "", - "integrationKeyLabel": "", - "integrationKeyPlaceholder": "" - }, - "discordSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "webhookSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - } - }, - "notificationConfig": { - "title": "", - "description": "" - }, - "monitorStatus": { - "checkingEvery": "", - "withCaptureAgent": "", - "up": "", - "down": "", - "paused": "" - }, - "advancedMatching": "", - "sendTestNotifications": "", - "testNotificationsDisabled": "", - "selectAll": "", - "showAdminLoginLink": "", - "logsPage": { - "title": "", - "description": "", - "tabs": { - "queue": "", - "logs": "" - }, - "toast": { - "fetchLogsSuccess": "" - }, - "logLevelSelect": { - "title": "", - "values": { - "all": "", - "info": "", - "warn": "", - "error": "", - "debug": "" - } - } - }, - "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", - "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "", - "activeHeader": "", - "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "", - "lastRunTookHeader": "" - }, - "metricsTable": { - "title": "", - "metricHeader": "", - "valueHeader": "" - }, - "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", - "failedAtHeader": "", - "failReasonHeader": "" - } - }, - "export": { - "title": "", - "success": "", - "failed": "" - }, - "monitorActions": { - "title": "", - "import": "", - "export": "" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "" - }, - "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" - }, - "emailSettings": { - "buttonSendTestEmail": "", - "description": "", - "descriptionTransport": "", - "labelAddress": "", - "labelConnectionHost": "", - "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", - "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", - "labelRequireTLS": "", - "labelSecure": "", - "labelTLSServername": "", - "labelUser": "", - "linkTransport": "", - "placeholderUser": "", - "title": "" - }, - "pageSpeedSettings": { - "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "" - }, - "saveButtonLabel": "", - "statsSettings": { - "clearAllStatsButton": "", - "clearAllStatsDescription": "", - "clearAllStatsDialogConfirm": "", - "clearAllStatsDialogDescription": "", - "clearAllStatsDialogTitle": "", - "description": "", - "labelTTL": "", - "labelTTLOptional": "", - "title": "" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", - "dialogDescription": "", - "dialogTitle": "", - "title": "" - }, - "timezoneSettings": { - "description": "", - "label": "", - "title": "" - }, - "title": "", - "uiSettings": { - "description": "", - "labelLanguage": "", - "labelTheme": "", - "title": "" - }, - "urlSettings": { - "description": "", - "label": "", - "selectDisabled": "", - "selectEnabled": "", - "title": "" - } - } -} diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json deleted file mode 100644 index fd2173b7b..000000000 --- a/client/src/locales/fr.json +++ /dev/null @@ -1,836 +0,0 @@ -{ - "submit": "Envoyer", - "title": "Titre", - "distributedStatusHeaderText": "", - "distributedStatusSubHeaderText": "", - "settingsDisabled": "Désactivé", - "settingsSuccessSaved": "", - "settingsFailedToSave": "", - "settingsStatsCleared": "", - "settingsFailedToClearStats": "", - "settingsFailedToAddDemoMonitors": "", - "settingsMonitorsDeleted": "", - "settingsFailedToDeleteMonitors": "", - "starPromptTitle": "", - "starPromptDescription": "", - "https": "", - "http": "", - "monitor": "", - "aboutus": "A propos de nous", - "now": "", - "delete": "Supprimer", - "configure": "", - "responseTime": "", - "ms": "", - "bar": "", - "area": "Zone", - "country": "PAYS", - "city": "VILLE", - "response": "", - "monitorStatusUp": "", - "monitorStatusDown": "", - "webhookSendSuccess": "", - "webhookSendError": "", - "webhookUnsupportedPlatform": "", - "distributedRightCategoryTitle": "Moniteur", - "distributedStatusServerMonitors": "", - "distributedStatusServerMonitorsDescription": "", - "distributedUptimeCreateSelectURL": "", - "distributedUptimeCreateChecks": "", - "distributedUptimeCreateChecksDescription": "", - "distributedUptimeCreateIncidentNotification": "Notifications d'incidents", - "distributedUptimeCreateIncidentDescription": "", - "distributedUptimeCreateAdvancedSettings": "Paramètres avancés", - "distributedUptimeDetailsNoMonitorHistory": "", - "distributedUptimeDetailsStatusHeaderUptime": "", - "distributedUptimeDetailsStatusHeaderLastUpdate": "", - "notifications": { - "enableNotifications": "", - "testNotification": "Notification de test", - "addOrEditNotifications": "Ajouter ou éditer des notifications", - "slack": { - "label": "", - "description": "", - "webhookLabel": "URL du webhook", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "discord": { - "label": "", - "description": "", - "webhookLabel": "URL du webhook Discord", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "telegram": { - "label": "", - "description": "", - "tokenLabel": "", - "tokenPlaceholder": "", - "chatIdLabel": "Votre Chat ID", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "" - }, - "webhook": { - "label": "", - "description": "", - "urlLabel": "URL du webhook", - "urlPlaceholder": "https://votre-serveur.fr/webhook", - "urlRequired": "L'URL du webhook est nécessaire" - }, - "testNotificationDevelop": "Notification de test 2", - "integrationButton": "Intégration de notification", - "testSuccess": "La notification de test a été envoyée avec succès !", - "testFailed": "Échec de l'envoi de la notification de test", - "unsupportedType": "", - "networkError": "", - "fallback": { - "title": "", - "checks": [""] - }, - "createButton": "", - "createTitle": "", - "create": { - "success": "", - "failed": "" - }, - "fetch": { - "success": "", - "failed": "" - }, - "delete": { - "success": "", - "failed": "" - }, - "edit": { - "success": "", - "failed": "" - }, - "test": { - "success": "", - "failed": "" - } - }, - "testLocale": "", - "add": "Ajouter", - "monitors": "", - "distributedUptimeStatusCreateStatusPage": "page de statut", - "distributedUptimeStatusCreateStatusPageAccess": "Accès", - "distributedUptimeStatusCreateStatusPageReady": "", - "distributedUptimeStatusBasicInfoHeader": "", - "distributedUptimeStatusBasicInfoDescription": "", - "distributedUptimeStatusLogoHeader": "Logo", - "distributedUptimeStatusLogoDescription": "", - "distributedUptimeStatusLogoUploadButton": "Téléverser un logo", - "distributedUptimeStatusStandardMonitorsHeader": "", - "distributedUptimeStatusStandardMonitorsDescription": "", - "distributedUptimeStatusCreateYour": "", - "distributedUptimeStatusEditYour": "", - "distributedUptimeStatusPublishedLabel": "", - "distributedUptimeStatusCompanyNameLabel": "Nom de l'entreprise", - "distributedUptimeStatusPageAddressLabel": "Adresse de votre page de statut", - "distributedUptimeStatus30Days": "30 jours", - "distributedUptimeStatus60Days": "60 jours", - "distributedUptimeStatus90Days": "90 jours", - "distributedUptimeStatusPageNotSetUp": "", - "distributedUptimeStatusContactAdmin": "Veuillez contacter votre administrateur", - "distributedUptimeStatusPageNotPublic": "Cette page de statut n'est pas publique.", - "distributedUptimeStatusPageDeleteDialog": "Voulez-vous supprimer cette page de statut ?", - "distributedUptimeStatusPageDeleteConfirm": "Oui, supprimer la page de statut", - "distributedUptimeStatusPageDeleteDescription": "Supprimer votre page de statut est irréversible.", - "distributedUptimeStatusDevices": "", - "distributedUptimeStatusUpt": "", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "", - "incidentsTableNoIncidents": "", - "incidentsTablePaginationLabel": "incidents", - "incidentsTableMonitorName": "", - "incidentsTableStatus": "", - "incidentsTableDateTime": "Date et heure", - "incidentsTableStatusCode": "", - "incidentsTableMessage": "Message", - "incidentsOptionsHeader": "", - "incidentsOptionsHeaderFilterBy": "", - "incidentsOptionsHeaderFilterAll": "", - "incidentsOptionsHeaderFilterDown": "", - "incidentsOptionsHeaderFilterCannotResolve": "", - "incidentsOptionsHeaderShow": "", - "incidentsOptionsHeaderLastHour": "Dernière heure", - "incidentsOptionsHeaderLastDay": "Dernier jour", - "incidentsOptionsHeaderLastWeek": "Dernière semaine", - "incidentsOptionsPlaceholderAllServers": "", - "infrastructureCreateYour": "", - "infrastructureCreateGeneralSettingsDescription": "", - "infrastructureServerRequirement": "", - "infrastructureCustomizeAlerts": "", - "infrastructureAlertNotificationDescription": "", - "infrastructureCreateMonitor": "", - "infrastructureProtocol": "Protocole", - "infrastructureServerUrlLabel": "URL du serveur", - "infrastructureDisplayNameLabel": "", - "infrastructureAuthorizationSecretLabel": "", - "gb": "", - "mb": "", - "mem": "", - "memoryUsage": "", - "cpu": "CPU", - "cpuUsage": "", - "cpuTemperature": "", - "diskUsage": "Utilisation du disque", - "used": "", - "total": "", - "cores": "", - "frequency": "", - "status": "", - "cpuPhysical": "", - "cpuLogical": "", - "cpuFrequency": "Fréquence CPU", - "avgCpuTemperature": "Température moyenne du CPU", - "memory": "", - "disk": "Disque", - "uptime": "", - "os": "", - "host": "", - "actions": "Actions", - "integrations": "Intégrations", - "integrationsPrism": "Connecter Prism à votre service préféré.", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "Connecter à Slack et voir les incidents dans un canal", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "Connecter à Discord et voir les incidents dans un canal", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "Envoyer tous les incidents à Zapier", - "commonSave": "Sauvegarder", - "createYour": "Créez votre", - "createMonitor": "", - "pause": "", - "resume": "", - "editing": "", - "url": "URL", - "access": "Accès", - "timezone": "Fuseau horaire", - "features": "", - "administrator": "Administrateur ?", - "loginHere": "", - "displayName": "Nom d'affichage", - "urlMonitor": "URL à suivre", - "portToMonitor": "", - "websiteMonitoring": "", - "websiteMonitoringDescription": "", - "pingMonitoring": "", - "pingMonitoringDescription": "", - "dockerContainerMonitoring": "", - "dockerContainerMonitoringDescription": "", - "portMonitoring": "", - "portMonitoringDescription": "", - "createMaintenanceWindow": "", - "createMaintenance": "Créer une maintenance", - "editMaintenance": "", - "maintenanceWindowName": "", - "friendlyNameInput": "", - "friendlyNamePlaceholder": "", - "maintenanceRepeat": "", - "maintenance": "", - "duration": "", - "addMonitors": "Ajouter des moniteurs", - "window": "", - "cancel": "Annuler", - "message": "", - "low": "", - "high": "", - "statusCode": "", - "date&Time": "Date et heure", - "type": "", - "statusPageName": "", - "publicURL": "", - "repeat": "", - "edit": "", - "createA": "Créer un", - "remove": "", - "maintenanceWindowDescription": "", - "startTime": "", - "timeZoneInfo": "", - "monitorsToApply": "", - "nextWindow": "", - "notFoundButton": "", - "pageSpeedConfigureSettingsDescription": "", - "monitorDisplayName": "", - "whenNewIncident": "Lors d'un nouvel incident,", - "notifySMS": "", - "notifyEmails": "", - "seperateEmails": "", - "checkFrequency": "Vérifier la fréquence", - "matchMethod": "", - "expectedValue": "", - "deleteDialogTitle": "", - "deleteDialogDescription": "", - "pageSpeedMonitor": "", - "shown": "", - "ago": "depuis", - "companyName": "", - "pageSpeedDetailsPerformanceReport": "", - "pageSpeedDetailsPerformanceReportCalculator": "", - "checkingEvery": "Vérification tous les", - "statusPageCreateSettings": "", - "basicInformation": "", - "statusPageCreateBasicInfoDescription": "", - "statusPageCreateSelectTimeZoneDescription": "", - "statusPageCreateAppearanceDescription": "", - "statusPageCreateSettingsCheckboxLabel": "", - "statusPageCreateBasicInfoStatusPageAddress": "", - "statusPageCreateTabsContent": "", - "statusPageCreateTabsContentDescription": "", - "statusPageCreateTabsContentFeaturesDescription": "", - "showCharts": "", - "showUptimePercentage": "", - "removeLogo": "", - "statusPageStatus": "", - "statusPageStatusContactAdmin": "", - "statusPageStatusNotPublic": "", - "statusPageStatusNoPage": "", - "statusPageStatusServiceStatus": "", - "deleteStatusPage": "", - "deleteStatusPageConfirm": "Oui, supprimer la page de statut", - "deleteStatusPageDescription": "", - "uptimeCreate": "", - "uptimeCreateJsonPath": "", - "uptimeCreateJsonPathQuery": "", - "maintenanceTableActionMenuDialogTitle": "", - "infrastructureEditYour": "", - "infrastructureEditMonitor": "", - "infrastructureMonitorCreated": "", - "infrastructureMonitorUpdated": "", - "errorInvalidTypeId": "", - "errorInvalidFieldId": "", - "inviteNoTokenFound": "", - "pageSpeedWarning": "", - "pageSpeedLearnMoreLink": "", - "pageSpeedAddApiKey": "", - "update": "", - "invalidFileFormat": "Format de fichier non supporté !", - "invalidFileSize": "Le fichier est trop volumineux !", - "ClickUpload": "Cliquez pour téléverser", - "DragandDrop": "", - "MaxSize": "", - "SupportedFormats": "", - "FirstName": "", - "LastName": "", - "EmailDescriptionText": "", - "YourPhoto": "Photo de profil", - "PhotoDescriptionText": "", - "save": "", - "DeleteDescriptionText": "", - "DeleteAccountWarning": "", - "DeleteWarningTitle": "Vous supprimez vraiment ce compte ?", - "bulkImport": { - "title": "", - "selectFileTips": "", - "selectFileDescription": "", - "selectFile": "", - "parsingFailed": "", - "uploadSuccess": "", - "validationFailed": "Echec de la validation", - "noFileSelected": "", - "fallbackPage": "" - }, - "DeleteAccountTitle": "Supprimer le compte", - "DeleteAccountButton": "Supprimer le compte", - "publicLink": "", - "maskedPageSpeedKeyPlaceholder": "", - "reset": "", - "ignoreTLSError": "", - "tlsErrorIgnored": "", - "ignoreTLSErrorDescription": "", - "createNew": "Créer un nouveau", - "greeting": { - "prepend": "", - "append": "L'après-midi est votre libre, rendons-le épique !", - "overview": "" - }, - "roles": { - "superAdmin": "", - "admin": "Admin", - "teamMember": "", - "demoUser": "" - }, - "teamPanel": { - "teamMembers": "Membres de l'équipe", - "filter": { - "all": "Tous", - "member": "" - }, - "inviteTeamMember": "", - "inviteNewTeamMember": "", - "inviteDescription": "", - "email": "Email", - "selectRole": "", - "inviteLink": "Lien d'invitation", - "cancel": "Annuler", - "noMembers": "", - "getToken": "", - "emailToken": "", - "table": { - "name": "", - "email": "Email", - "role": "", - "created": "Créée" - } - }, - "monitorState": { - "paused": "", - "resumed": "", - "active": "Actif" - }, - "menu": { - "uptime": "", - "pagespeed": "", - "infrastructure": "", - "incidents": "", - "statusPages": "", - "maintenance": "", - "integrations": "Intégrations", - "settings": "", - "support": "", - "discussions": "Discussions", - "docs": "", - "changelog": "Changelog", - "profile": "", - "password": "", - "team": "", - "logOut": "Déconnexion", - "notifications": "", - "logs": "" - }, - "settingsEmailUser": "", - "state": "", - "statusBreadCrumbsStatusPages": "", - "statusBreadCrumbsDetails": "", - "commonSaving": "Sauvegarde...", - "navControls": "", - "incidentsPageTitle": "Incidents", - "passwordPanel": { - "passwordChangedSuccess": "Votre mot de passe a été modifié avec succès.", - "passwordInputIncorrect": "La saisie de votre mot de passe est incorrecte.", - "currentPassword": "Mot de passe actuel", - "enterCurrentPassword": "Entrez votre mot de passe actuel", - "newPassword": "Nouveau mot de passe", - "enterNewPassword": "Entrez votre nouveau mot de passe", - "confirmNewPassword": "Confirmer le nouveau mot de passe", - "passwordRequirements": "Le nouveau mot de passe doit contenir au moins 8 caractères et au moins une lettre majuscule, une lettre minuscule, un chiffre et un caractère spécial.", - "saving": "Sauvegarde..." - }, - "emailSent": "", - "failedToSendEmail": "", - "settingsTestEmailSuccess": "", - "settingsTestEmailFailed": "", - "settingsTestEmailFailedWithReason": "", - "settingsTestEmailUnknownError": "Erreur inconnue", - "statusMsg": { - "paused": "", - "up": "", - "down": "", - "pending": "" - }, - "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "" - }, - "common": { - "appName": "", - "monitoringAgentName": "", - "buttons": { - "toggleTheme": "" - }, - "toasts": { - "networkError": "", - "checkConnection": "", - "unknownError": "" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "", - "back": "" - }, - "inputs": { - "email": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "invalid": "" - } - }, - "password": { - "label": "", - "rules": { - "length": { - "beginning": "", - "highlighted": "" - }, - "special": { - "beginning": "", - "highlighted": "" - }, - "number": { - "beginning": "", - "highlighted": "" - }, - "uppercase": { - "beginning": "", - "highlighted": "" - }, - "lowercase": { - "beginning": "", - "highlighted": "" - }, - "match": { - "beginning": "", - "highlighted": "" - } - }, - "errors": { - "empty": "", - "length": "", - "uppercase": "", - "lowercase": "", - "number": "", - "special": "", - "incorrect": "" - } - }, - "passwordConfirm": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "different": "" - } - }, - "firstName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - }, - "lastName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - } - }, - "errors": { - "validation": "" - }, - "fields": { - "password": { - "errors": { - "incorrect": "" - } - } - } - }, - "login": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "" - }, - "links": { - "forgotPassword": "", - "register": "", - "forgotPasswordLink": "", - "registerLink": "" - }, - "toasts": { - "success": "", - "incorrectPassword": "" - }, - "errors": { - "password": { - "incorrect": "" - } - } - }, - "registration": { - "heading": { - "superAdmin": "", - "user": "" - }, - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "" - }, - "description": { - "superAdmin": "", - "user": "" - }, - "gettingStartedButton": { - "superAdmin": "", - "user": "" - }, - "termsAndPolicies": "", - "links": { - "login": "" - }, - "toasts": { - "success": "" - } - }, - "forgotPassword": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "", - "stepFour": "" - }, - "buttons": { - "openEmail": "", - "resetPassword": "" - }, - "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" - }, - "links": { - "login": "", - "resend": "" - }, - "toasts": { - "sent": "", - "emailNotFound": "", - "redirect": "", - "success": "", - "error": "" - } - } - }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "", - "stillUnreachable": "" - }, - "alertBox": "", - "description": "", - "retryButton": { - "default": "", - "processing": "" - } - } - }, - "createNotifications": { - "title": "", - "nameSettings": { - "title": "", - "description": "", - "nameLabel": "", - "namePlaceholder": "" - }, - "typeSettings": { - "title": "", - "description": "", - "typeLabel": "" - }, - "emailSettings": { - "title": "", - "description": "", - "emailLabel": "", - "emailPlaceholder": "" - }, - "slackSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "pagerdutySettings": { - "title": "", - "description": "", - "integrationKeyLabel": "", - "integrationKeyPlaceholder": "" - }, - "discordSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "webhookSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - } - }, - "notificationConfig": { - "title": "", - "description": "" - }, - "monitorStatus": { - "checkingEvery": "", - "withCaptureAgent": "", - "up": "", - "down": "", - "paused": "" - }, - "advancedMatching": "", - "sendTestNotifications": "", - "testNotificationsDisabled": "", - "selectAll": "", - "showAdminLoginLink": "", - "logsPage": { - "title": "", - "description": "", - "tabs": { - "queue": "", - "logs": "" - }, - "toast": { - "fetchLogsSuccess": "" - }, - "logLevelSelect": { - "title": "", - "values": { - "all": "", - "info": "", - "warn": "", - "error": "", - "debug": "" - } - } - }, - "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", - "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "", - "activeHeader": "", - "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "", - "lastRunTookHeader": "" - }, - "metricsTable": { - "title": "", - "metricHeader": "", - "valueHeader": "" - }, - "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", - "failedAtHeader": "", - "failReasonHeader": "" - } - }, - "export": { - "title": "", - "success": "", - "failed": "" - }, - "monitorActions": { - "title": "", - "import": "", - "export": "" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "" - }, - "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" - }, - "emailSettings": { - "buttonSendTestEmail": "", - "description": "", - "descriptionTransport": "", - "labelAddress": "", - "labelConnectionHost": "", - "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", - "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", - "labelRequireTLS": "", - "labelSecure": "", - "labelTLSServername": "", - "labelUser": "", - "linkTransport": "", - "placeholderUser": "", - "title": "" - }, - "pageSpeedSettings": { - "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "" - }, - "saveButtonLabel": "", - "statsSettings": { - "clearAllStatsButton": "", - "clearAllStatsDescription": "", - "clearAllStatsDialogConfirm": "", - "clearAllStatsDialogDescription": "", - "clearAllStatsDialogTitle": "", - "description": "", - "labelTTL": "", - "labelTTLOptional": "", - "title": "" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", - "dialogDescription": "", - "dialogTitle": "", - "title": "" - }, - "timezoneSettings": { - "description": "", - "label": "", - "title": "" - }, - "title": "", - "uiSettings": { - "description": "", - "labelLanguage": "", - "labelTheme": "", - "title": "" - }, - "urlSettings": { - "description": "", - "label": "", - "selectDisabled": "", - "selectEnabled": "", - "title": "" - } - } -} diff --git a/client/src/locales/ar.json b/client/src/locales/ja.json similarity index 100% rename from client/src/locales/ar.json rename to client/src/locales/ja.json diff --git a/client/src/locales/es.json b/client/src/locales/uk.json similarity index 99% rename from client/src/locales/es.json rename to client/src/locales/uk.json index 0db51b102..b3b8fc4ce 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/uk.json @@ -1,6 +1,6 @@ { - "submit": "Enviar", - "title": "Titulo", + "submit": "", + "title": "", "distributedStatusHeaderText": "", "distributedStatusSubHeaderText": "", "settingsDisabled": "", diff --git a/client/src/locales/cs.json b/client/src/locales/vi.json similarity index 74% rename from client/src/locales/cs.json rename to client/src/locales/vi.json index 2bb03f5ad..b3b8fc4ce 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/vi.json @@ -1,11 +1,11 @@ { - "submit": "Odeslat", + "submit": "", "title": "", "distributedStatusHeaderText": "", "distributedStatusSubHeaderText": "", - "settingsDisabled": "Vypnuto", - "settingsSuccessSaved": "Nastavení bylo úspěšně uloženo", - "settingsFailedToSave": "Nepodařilo se uložit nastavení", + "settingsDisabled": "", + "settingsSuccessSaved": "", + "settingsFailedToSave": "", "settingsStatsCleared": "", "settingsFailedToClearStats": "", "settingsFailedToAddDemoMonitors": "", @@ -438,99 +438,99 @@ "port": "" }, "common": { - "appName": "Checkmate", - "monitoringAgentName": "Capture", + "appName": "", + "monitoringAgentName": "", "buttons": { - "toggleTheme": "Přepnout mezi světlým a tmavým motivem" + "toggleTheme": "" }, "toasts": { - "networkError": "Chyba připojení k síti", - "checkConnection": "Zkontrolujte prosím své připojení k síti", - "unknownError": "Nastala neznámá chyba" + "networkError": "", + "checkConnection": "", + "unknownError": "" } }, "auth": { "common": { "navigation": { - "continue": "Pokračovat", - "back": "Zpět" + "continue": "", + "back": "" }, "inputs": { "email": { - "label": "E-mail", - "placeholder": "jan.novak@domena.cz", + "label": "", + "placeholder": "", "errors": { - "empty": "Zadejte prosím e-mailovou adresu", - "invalid": "Překontrolujte si prosím správnost zadané e-mailové adresy" + "empty": "", + "invalid": "" } }, "password": { - "label": "Heslo", + "label": "", "rules": { "length": { - "beginning": "Heslo musí být dlouhé alespoň", - "highlighted": "8 znaků" + "beginning": "", + "highlighted": "" }, "special": { - "beginning": "Heslo musí obsahovat alespoň", - "highlighted": "1 speciální znak" + "beginning": "", + "highlighted": "" }, "number": { - "beginning": "Heslo musí obsahovat alespoň", - "highlighted": "1 číslo" + "beginning": "", + "highlighted": "" }, "uppercase": { - "beginning": "Heslo musí obsahovat alespoň", - "highlighted": "1 velké písmeno" + "beginning": "", + "highlighted": "" }, "lowercase": { - "beginning": "Heslo musí obsahovat alespoň", - "highlighted": "1 malé písmeno" + "beginning": "", + "highlighted": "" }, "match": { - "beginning": "Obě hesla se", - "highlighted": "musí shodovat" + "beginning": "", + "highlighted": "" } }, "errors": { - "empty": "Zadejte prosím heslo", - "length": "Heslo musí být dlouhé alespoň 8 znaků", - "uppercase": "Heslo musí obsahovat alespoň 1 velké písmeno", - "lowercase": "Heslo musí obsahovat alespoň 1 malé písmeno", - "number": "Heslo musí obsahovat alespoň 1 číslo", - "special": "Heslo musí obsahovat alespoň 1 speciální znak", - "incorrect": "Zadané heslo neodpovídá tomu, co bylo nastaveno" + "empty": "", + "length": "", + "uppercase": "", + "lowercase": "", + "number": "", + "special": "", + "incorrect": "" } }, "passwordConfirm": { - "label": "Potvrzení hesla", - "placeholder": "Pro ověření správnosti zadejte heslo ještě jednou", + "label": "", + "placeholder": "", "errors": { - "empty": "Zadejte prosím své heslo ještě jednou, aby mohla být potvrzena jeho správnost (odhalí překlepy)", - "different": "Zadaná hesla se liší, takže je nejspíše jedno z nich špatně zapsané" + "empty": "", + "different": "" } }, "firstName": { - "label": "Jméno", - "placeholder": "Jan", + "label": "", + "placeholder": "", "errors": { - "empty": "Zadejte prosím své křestní jméno", - "length": "Jméno se musí vejít do 50 znaků", - "pattern": "Součástí jména mohou být pouze písmena, mezery apostrofy nebo spojovníky" + "empty": "", + "length": "", + "pattern": "" } }, "lastName": { - "label": "Příjmení", - "placeholder": "Novák", + "label": "", + "placeholder": "", "errors": { - "empty": "Zadejte prosím své příjmení", - "length": "Příjmení se musí vejít do 50 znaků", - "pattern": "Součástí příjmení mohou být pouze písmena, mezery apostrofy nebo spojovníky" + "empty": "", + "length": "", + "pattern": "" } } }, "errors": { - "validation": "Nastala chyba při ověřování dat." + "validation": "" }, "fields": { "password": { @@ -541,20 +541,20 @@ } }, "login": { - "heading": "Přihlášení", + "heading": "", "subheadings": { - "stepOne": "Zadejte svůj e-mail", - "stepTwo": "Zadejte své heslo" + "stepOne": "", + "stepTwo": "" }, "links": { - "forgotPassword": "Zapomněli jste heslo? Obnovte si ho", - "register": "Ještě nemáte účet? Zaregistrujte se", + "forgotPassword": "", + "register": "", "forgotPasswordLink": "", "registerLink": "" }, "toasts": { - "success": "Vítejte zpět", - "incorrectPassword": "Nesprávné heslo" + "success": "", + "incorrectPassword": "" }, "errors": { "password": { @@ -564,72 +564,72 @@ }, "registration": { "heading": { - "superAdmin": "Vytvoření superadministrátora", - "user": "Registrace" + "superAdmin": "", + "user": "" }, "subheadings": { - "stepOne": "Zadejte své osobní údaje", - "stepTwo": "Zadejte svůj e-mail", - "stepThree": "Nastavte si heslo" + "stepOne": "", + "stepTwo": "", + "stepThree": "" }, "description": { - "superAdmin": "Pro začátek vytvořte účet superadministrátora", - "user": "Zaregistrujte se jako uživatel a požádejte svého superadministrátora, aby vám přidělil oprávnění k hlídačům" + "superAdmin": "", + "user": "" }, "gettingStartedButton": { - "superAdmin": "Vytvořit účet superadministrátora", - "user": "Zaregistrovat se jako běžný uživatel" + "superAdmin": "", + "user": "" }, - "termsAndPolicies": "Vytvořením účtu souhlasíte s našimi Podmínkami použití (anglicky) a Podmínkami zpracování osobních údajů (anglicky).", + "termsAndPolicies": "", "links": { - "login": "Máte již vytvořený účet? Přihlašte se" + "login": "" }, "toasts": { - "success": "Vítejte! Váš účet byl úspěšně vytvořen." + "success": "" } }, "forgotPassword": { - "heading": "Zapomenuté heslo", + "heading": "", "subheadings": { - "stepOne": "Nebojte se, zašleme vám pokyny pro obnovení hesla.", - "stepTwo": "Odkaz pro obnovení hesla byl odeslán na ", - "stepThree": "Nové heslo se musí lišit od těch, které jste již použili.", - "stepFour": "Heslo bylo úspěšně obnoveno. Klepněte na tlačítko a dojde k čarovnému přihlášení." + "stepOne": "", + "stepTwo": "", + "stepThree": "", + "stepFour": "" }, "buttons": { - "openEmail": "Otevřít e-mailovou aplikaci", - "resetPassword": "Obnovit heslo" + "openEmail": "", + "resetPassword": "" }, "imageAlts": { - "passwordKey": "Ikonka přístupového klíče", - "email": "Ikonka e-mailu", - "lock": "Ikonka zámku", - "passwordConfirm": "Ikonka potvrzeného hesla" + "passwordKey": "", + "email": "", + "lock": "", + "passwordConfirm": "" }, "links": { - "login": "Vrátit se k Přihlášení", - "resend": "Nedorazil vám e-mail? Klepněte pro opětovné zaslání" + "login": "", + "resend": "" }, "toasts": { - "sent": "Instrukce vám dorazí na .", - "emailNotFound": "Nepodařilo se nalézt e-mailovou adresu.", - "redirect": "Přesměrování proběhne za ...", - "success": "Heslo bylo úspěšně obnoveno.", - "error": "Obnova hesla se nepodařila. Zkuste to prosím znovu nebo kontaktujte podporu." + "sent": "", + "emailNotFound": "", + "redirect": "", + "success": "", + "error": "" } } }, "errorPages": { "serverUnreachable": { "toasts": { - "reconnected": "Podařilo se úspěšně obnovit spojení se serverem", - "stillUnreachable": "Server je stále nedosažitelný. Zkuste to prosím později." + "reconnected": "", + "stillUnreachable": "" }, - "alertBox": "Chyba připojení k serveru", - "description": "Nepodařilo se připojit k serveru. Zkontrolujte si prosím internetové připojení a pokud problém přetrvává, ověřte konfiguraci aplikace Checkmate.", + "alertBox": "", + "description": "", "retryButton": { - "default": "Zkusit se znovu připojit", - "processing": "Probíhá připojování…" + "default": "", + "processing": "" } } }, From b2fdb11195328c07688deec6f53fbfc21f02f0af Mon Sep 17 00:00:00 2001 From: karenvicent Date: Tue, 22 Jul 2025 12:47:29 -0400 Subject: [PATCH 033/226] run format --- client/src/Components/Fallback/FallbackContainer.jsx | 4 ++-- client/src/Components/LanguageSelector.jsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/client/src/Components/Fallback/FallbackContainer.jsx b/client/src/Components/Fallback/FallbackContainer.jsx index 5d14b495d..65d927190 100644 --- a/client/src/Components/Fallback/FallbackContainer.jsx +++ b/client/src/Components/Fallback/FallbackContainer.jsx @@ -15,13 +15,13 @@ const FallbackContainer = ({ children, type }) => { display: "flex", borderStyle: "dashed", height: "fit-content", - minHeight: theme.spacing(280), + minHeight: "60vh", width: { sm: "90%", md: "70%", lg: "50%", + xl: "40%", }, - maxWidth: theme.spacing(250), padding: `${theme.spacing(20)} ${theme.spacing(10)}`, }} > diff --git a/client/src/Components/LanguageSelector.jsx b/client/src/Components/LanguageSelector.jsx index 57d102a9e..56a9a898d 100644 --- a/client/src/Components/LanguageSelector.jsx +++ b/client/src/Components/LanguageSelector.jsx @@ -6,7 +6,6 @@ import { useSelector } from "react-redux"; import { useDispatch } from "react-redux"; import { setLanguage } from "../Features/UI/uiSlice"; - const langMap = { cs: "cz", ja: "jp", From 4f68a76674f94ce34f19511f4ac33ae20e8b97da Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 12:27:06 -0700 Subject: [PATCH 034/226] implement frontend of user edit --- client/src/Components/Inputs/Search/index.jsx | 1 + client/src/Components/Tab/index.jsx | 1 + client/src/Hooks/useIsAdmin.js | 8 +- client/src/Hooks/userHooks.js | 61 +++++++++ .../Pages/Account/EditUser/hooks/editUser.js | 101 +++++++++++++++ client/src/Pages/Account/EditUser/index.jsx | 122 ++++++++++++++++++ .../Account/components}/PasswordPanel.jsx | 6 +- .../Account/components}/ProfilePanel.jsx | 11 +- .../Account/components/RoleTable/index.jsx | 42 ++++++ .../Account/components}/TeamPanel.jsx | 26 +++- client/src/Pages/Account/index.css | 38 ------ client/src/Pages/Account/index.jsx | 10 +- client/src/Routes/index.jsx | 9 ++ client/src/Utils/NetworkService.js | 18 +++ client/src/Utils/roleUtils.js | 11 ++ client/src/Validation/validation.js | 20 +++ client/src/locales/en.json | 24 ++++ 17 files changed, 449 insertions(+), 60 deletions(-) create mode 100644 client/src/Hooks/userHooks.js create mode 100644 client/src/Pages/Account/EditUser/hooks/editUser.js create mode 100644 client/src/Pages/Account/EditUser/index.jsx rename client/src/{Components/TabPanels/Account => Pages/Account/components}/PasswordPanel.jsx (97%) rename client/src/{Components/TabPanels/Account => Pages/Account/components}/ProfilePanel.jsx (97%) create mode 100644 client/src/Pages/Account/components/RoleTable/index.jsx rename client/src/{Components/TabPanels/Account => Pages/Account/components}/TeamPanel.jsx (92%) delete mode 100644 client/src/Pages/Account/index.css create mode 100644 client/src/Utils/roleUtils.js diff --git a/client/src/Components/Inputs/Search/index.jsx b/client/src/Components/Inputs/Search/index.jsx index 9f033cd52..c3008886c 100644 --- a/client/src/Components/Inputs/Search/index.jsx +++ b/client/src/Components/Inputs/Search/index.jsx @@ -77,6 +77,7 @@ const Search = ({ labelVariant, labelSx = {}, unit = "option", + maxWidth = "100%", }) => { const theme = useTheme(); const { t } = useTranslation(); diff --git a/client/src/Components/Tab/index.jsx b/client/src/Components/Tab/index.jsx index 72709cc00..7609904d9 100644 --- a/client/src/Components/Tab/index.jsx +++ b/client/src/Components/Tab/index.jsx @@ -16,6 +16,7 @@ const CustomTabList = ({ value, onChange, children, ...props }) => { return ( { return isAdmin; }; -export { useIsAdmin }; +const useIsSuperAdmin = () => { + const { user } = useSelector((state) => state.auth); + const isSuperAdmin = user?.role?.includes("superadmin"); + return isSuperAdmin; +}; + +export { useIsAdmin, useIsSuperAdmin }; diff --git a/client/src/Hooks/userHooks.js b/client/src/Hooks/userHooks.js new file mode 100644 index 000000000..8be32a082 --- /dev/null +++ b/client/src/Hooks/userHooks.js @@ -0,0 +1,61 @@ +import { useEffect, useState, useCallback } from "react"; +import { networkService } from "../main"; +import { createToast } from "../Utils/toastUtils"; +import { useTranslation } from "react-i18next"; + +export const useGetUser = (userId) => { + const [user, setUser] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchUser = useCallback(async () => { + try { + setIsLoading(true); + const response = await networkService.getUserById({ userId }); + setUser(response?.data?.data); + } catch (error) { + createToast({ + body: error.message, + }); + setError(error); + } finally { + setIsLoading(false); + } + }, [userId]); + + useEffect(() => { + if (userId) { + fetchUser(); + } + }, [userId, fetchUser]); + + return [user, isLoading, error]; +}; + +export const useEditUser = (userId) => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const { t } = useTranslation(); + + const editUser = useCallback( + async (user) => { + try { + setIsLoading(true); + await networkService.editUser({ userId, user }); + createToast({ + body: t("editUserPage.toast.successUserUpdate"), + }); + } catch (error) { + createToast({ + body: error.message, + }); + setError(error); + } finally { + setIsLoading(false); + } + }, + [userId] + ); + + return [editUser, isLoading, error]; +}; diff --git a/client/src/Pages/Account/EditUser/hooks/editUser.js b/client/src/Pages/Account/EditUser/hooks/editUser.js new file mode 100644 index 000000000..74914ee07 --- /dev/null +++ b/client/src/Pages/Account/EditUser/hooks/editUser.js @@ -0,0 +1,101 @@ +import { useState, useEffect } from "react"; +import { ROLES } from "../../../../Utils/roleUtils"; +import { editUserValidation } from "../../../../Validation/validation"; +import Joi from "joi"; +import { createToast } from "../../../../Utils/toastUtils"; +import { useTranslation } from "react-i18next"; +export const useEditUserForm = (user) => { + const [searchInput, setSearchInput] = useState(""); + const [form, setForm] = useState({ + firstName: "", + lastName: "", + email: "", + role: [], + }); + + // Effect to set user + useEffect(() => { + if (user) { + setForm({ + firstName: user?.firstName, + lastName: user?.lastName, + email: user?.email, + role: user?.role, + }); + } + }, [user]); + + const handleRoleChange = (value) => { + const hasSuperAdmin = form.role.includes(ROLES.SUPERADMIN); + const newRoles = value.map((item) => item.role); + if (hasSuperAdmin && !newRoles.includes(ROLES.SUPERADMIN)) { + newRoles.push(ROLES.SUPERADMIN); + } + + setForm({ + ...form, + role: newRoles, + }); + }; + + const handleDeleteRole = (role) => { + setForm({ ...form, role: form?.role?.filter((r) => r !== role) }); + }; + + const handleSearchInput = (value) => { + setSearchInput(value); + }; + + return [ + form, + setForm, + handleRoleChange, + handleDeleteRole, + searchInput, + handleSearchInput, + ]; +}; + +export const useValidateEditUserForm = () => { + const [errors, setErrors] = useState({}); + const { t } = useTranslation(); + const validateForm = (form) => { + const { error } = editUserValidation.validate(form, { + abortEarly: false, + }); + const errs = error?.details; + const errsObj = errs?.reduce((acc, curr) => { + acc[curr.path[0]] = curr.message; + return acc; + }, {}); + setErrors(errsObj); + + if (errs?.length > 0) { + createToast({ + variant: "warning", + hasIcon: true, + title: t("editUserPage.toast.validationErrors"), + body: errs.map((err) => t(err.message)), + }); + return false; + } + return true; + }; + + const validateField = (name, value) => { + const fieldSchema = Joi.object({ + [name]: editUserValidation.extract(name), + }); + + const { error } = fieldSchema.validate({ [name]: value }); + + setErrors((prev) => { + const prevErrors = { ...prev }; + if (error) prevErrors[name] = error.details[0].message; + else delete prevErrors[name]; + return prevErrors; + }); + }; + + return [errors, validateForm, validateField]; +}; diff --git a/client/src/Pages/Account/EditUser/index.jsx b/client/src/Pages/Account/EditUser/index.jsx new file mode 100644 index 000000000..e1eeead9d --- /dev/null +++ b/client/src/Pages/Account/EditUser/index.jsx @@ -0,0 +1,122 @@ +// Components +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Breadcrumbs from "../../../Components/Breadcrumbs"; +import TextInput from "../../../Components/Inputs/TextInput"; +import Search from "../../../Components/Inputs/Search"; +import Button from "@mui/material/Button"; +import RoleTable from "../components/RoleTable"; + +// Utils +import { useParams } from "react-router-dom"; +import { useTheme } from "@emotion/react"; +import { useTranslation } from "react-i18next"; +import { useGetUser, useEditUser } from "../../../Hooks/userHooks"; +import { EDITABLE_ROLES, ROLES } from "../../../Utils/roleUtils"; +import { useEditUserForm, useValidateEditUserForm } from "./hooks/editUser"; + +const EditUser = () => { + const { userId } = useParams(); + const theme = useTheme(); + const { t } = useTranslation(); + const BREADCRUMBS = [ + { name: t("menu.team"), path: "/account/team" }, + { name: t("editUserPage.title"), path: "" }, + ]; + + const [user, isLoading, error] = useGetUser(userId); + const [editUser, isSaving, saveError] = useEditUser(userId); + const [ + form, + setForm, + handleRoleChange, + handleDeleteRole, + searchInput, + handleSearchInput, + ] = useEditUserForm(user); + const [errors, validateForm, validateField] = useValidateEditUserForm(); + + const onChange = (e) => { + const name = e.target.name; + const value = e.target.value; + validateField(name, value); + setForm({ ...form, [name]: value }); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + const valid = validateForm(form); + if (valid) { + editUser(form); + } + }; + + return ( + + + {t("editUserPage.title")} + + + + + role !== ROLES.SUPERADMIN) + .map((role) => ({ role, _id: role })) || [] + } + options={EDITABLE_ROLES} + multiple={true} + handleChange={handleRoleChange} + /> + + + + + + + ); +}; + +export default EditUser; diff --git a/client/src/Components/TabPanels/Account/PasswordPanel.jsx b/client/src/Pages/Account/components/PasswordPanel.jsx similarity index 97% rename from client/src/Components/TabPanels/Account/PasswordPanel.jsx rename to client/src/Pages/Account/components/PasswordPanel.jsx index 81ce93946..dabd67706 100644 --- a/client/src/Components/TabPanels/Account/PasswordPanel.jsx +++ b/client/src/Pages/Account/components/PasswordPanel.jsx @@ -2,10 +2,10 @@ import TabPanel from "@mui/lab/TabPanel"; import { useState } from "react"; import { useTheme } from "@emotion/react"; import { Box, Stack, Typography, Button } from "@mui/material"; -import { PasswordEndAdornment } from "../../Inputs/TextInput/Adornments"; -import TextInput from "../../Inputs/TextInput"; +import { PasswordEndAdornment } from "../../../Components/Inputs/TextInput/Adornments"; +import TextInput from "../../../Components/Inputs/TextInput"; import { newOrChangedCredentials } from "../../../Validation/validation"; -import Alert from "../../Alert"; +import Alert from "../../../Components/Alert"; import { update } from "../../../Features/Auth/authSlice"; import { useDispatch, useSelector } from "react-redux"; import { createToast } from "../../../Utils/toastUtils"; diff --git a/client/src/Components/TabPanels/Account/ProfilePanel.jsx b/client/src/Pages/Account/components/ProfilePanel.jsx similarity index 97% rename from client/src/Components/TabPanels/Account/ProfilePanel.jsx rename to client/src/Pages/Account/components/ProfilePanel.jsx index 9b209bf3e..7c73e7aa3 100644 --- a/client/src/Components/TabPanels/Account/ProfilePanel.jsx +++ b/client/src/Pages/Account/components/ProfilePanel.jsx @@ -2,16 +2,16 @@ import { useTheme } from "@emotion/react"; import { useState } from "react"; import TabPanel from "@mui/lab/TabPanel"; import { Box, Button, Divider, Stack, Typography } from "@mui/material"; -import Avatar from "../../Avatar"; -import TextInput from "../../Inputs/TextInput"; -import ImageUpload from "../../Inputs/ImageUpload"; +import Avatar from "../../../Components/Avatar"; +import TextInput from "../../../Components/Inputs/TextInput"; +import ImageUpload from "../../../Components/Inputs/ImageUpload"; import { newOrChangedCredentials } from "../../../Validation/validation"; import { useDispatch, useSelector } from "react-redux"; import { clearAuthState, deleteUser, update } from "../../../Features/Auth/authSlice"; import { createToast } from "../../../Utils/toastUtils"; import { logger } from "../../../Utils/Logger"; -import { GenericDialog } from "../../Dialog/genericDialog"; -import Dialog from "../../Dialog"; +import { GenericDialog } from "../../../Components/Dialog/genericDialog"; +import Dialog from "../../../Components/Dialog"; import { useTranslation } from "react-i18next"; /** @@ -191,7 +191,6 @@ const ProfilePanel = () => { > { + const { t } = useTranslation(); + const HEADERS = [ + { + id: "name", + content: {t("editUserPage.table.roleHeader")}, + render: (row) => { + return row; + }, + }, + { + id: "delete", + content: {t("editUserPage.table.actionHeader")}, + render: (row) => { + if (row === ROLES.SUPERADMIN) return null; + return ( + { + handleDeleteRole(row); + }} + sx={{ cursor: "pointer" }} + /> + ); + }, + }, + ]; + return ( + + ); +}; + +export default RoleTable; diff --git a/client/src/Components/TabPanels/Account/TeamPanel.jsx b/client/src/Pages/Account/components/TeamPanel.jsx similarity index 92% rename from client/src/Components/TabPanels/Account/TeamPanel.jsx rename to client/src/Pages/Account/components/TeamPanel.jsx index 45578cc6c..95ece219f 100644 --- a/client/src/Components/TabPanels/Account/TeamPanel.jsx +++ b/client/src/Pages/Account/components/TeamPanel.jsx @@ -3,14 +3,16 @@ import TabPanel from "@mui/lab/TabPanel"; import { Button, ButtonGroup, Stack, Typography } from "@mui/material"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import TextInput from "../../Inputs/TextInput"; +import TextInput from "../../../Components/Inputs/TextInput"; import { newOrChangedCredentials } from "../../../Validation/validation"; import { networkService } from "../../../main"; import { createToast } from "../../../Utils/toastUtils"; -import Select from "../../Inputs/Select"; -import { GenericDialog } from "../../Dialog/genericDialog"; -import DataTable from "../../Table/"; +import Select from "../../../Components/Inputs/Select"; +import { GenericDialog } from "../../../Components/Dialog/genericDialog"; +import DataTable from "../../../Components/Table"; import { useGetInviteToken } from "../../../Hooks/inviteHooks"; +import { useNavigate } from "react-router-dom"; +import { useIsSuperAdmin } from "../../../Hooks/useIsAdmin"; /** * TeamPanel component manages the organization and team members, * providing functionalities like renaming the organization, managing team members, @@ -34,8 +36,9 @@ const TeamPanel = () => { const [isDisabled, setIsDisabled] = useState(true); const [errors, setErrors] = useState({}); const [isSendingInvite, setIsSendingInvite] = useState(false); - + const navigate = useNavigate(); const [getInviteToken, clearToken, isLoading, error, token] = useGetInviteToken(); + const isSuperAdmin = useIsSuperAdmin(); const headers = [ { @@ -184,7 +187,6 @@ const TeamPanel = () => { return ( { { + if (isSuperAdmin) { + navigate(`/account/team/${row.id}`); + } + }, + }} /> diff --git a/client/src/Pages/Account/index.css b/client/src/Pages/Account/index.css deleted file mode 100644 index 41e25fffa..000000000 --- a/client/src/Pages/Account/index.css +++ /dev/null @@ -1,38 +0,0 @@ -.account h1.MuiTypography-root, -.account p.MuiTypography-root, -.account input, -.account button, -.account td, -.account .MuiSelect-select { - font-size: var(--env-var-font-size-medium); -} -.account h1.MuiTypography-root { - font-weight: 600; -} -.account .MuiTabPanel-root { - padding: 0; - margin-top: 50px; -} -.account button:not(.MuiIconButton-root) { - min-height: 34px; -} -.account .field { - flex: 1; -} -#modal-delete-account, -#modal-edit-org-name, -#modal-invite-member { - font-size: var(--env-var-font-size-large); -} - -.account .MuiStack-root:has(span.MuiTypography-root.input-error) { - position: relative; -} -.account:not(:has(#modal-invite-member)) span.MuiTypography-root.input-error { - position: absolute; - top: 100%; -} - -.account .MuiTableBody-root .MuiTableCell-root { - padding: var(--env-var-spacing-1-plus) var(--env-var-spacing-2); -} diff --git a/client/src/Pages/Account/index.jsx b/client/src/Pages/Account/index.jsx index d8672f51c..37c64aca6 100644 --- a/client/src/Pages/Account/index.jsx +++ b/client/src/Pages/Account/index.jsx @@ -5,11 +5,10 @@ import { useSelector } from "react-redux"; import { Box, Tab, useTheme } from "@mui/material"; import CustomTabList from "../../Components/Tab"; import TabContext from "@mui/lab/TabContext"; -import ProfilePanel from "../../Components/TabPanels/Account/ProfilePanel"; -import PasswordPanel from "../../Components/TabPanels/Account/PasswordPanel"; -import TeamPanel from "../../Components/TabPanels/Account/TeamPanel"; +import ProfilePanel from "./components/ProfilePanel"; +import PasswordPanel from "./components/PasswordPanel"; +import TeamPanel from "./components/TeamPanel"; import { useTranslation } from "react-i18next"; -import "./index.css"; /** * Account component renders a settings page with tabs for Profile, Password, and Team settings. @@ -33,7 +32,9 @@ const Account = ({ open = "profile" }) => { { name: t("menu.password"), value: "password" }, { name: t("menu.team"), value: "team" }, ]; + const hideTeams = !requiredRoles.some((role) => user.role.includes(role)); + if (hideTeams) { tabList = [ { name: t("menu.profile"), value: "profile" }, @@ -63,7 +64,6 @@ const Account = ({ open = "profile" }) => { return ( diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 3db8b772d..6de07a2de 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -42,6 +42,7 @@ import CreateNotifications from "../Pages/Notifications/create"; // Settings import Account from "../Pages/Account"; +import EditUser from "../Pages/Account/EditUser"; import Settings from "../Pages/Settings"; import Maintenance from "../Pages/Maintenance"; @@ -187,6 +188,14 @@ const Routes = () => { path="account/team" element={} /> + + + + } + /> Date: Tue, 22 Jul 2025 12:27:17 -0700 Subject: [PATCH 035/226] add edit user routes --- server/routes/authRoute.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/routes/authRoute.js b/server/routes/authRoute.js index 6d8af52f8..e790c60b0 100755 --- a/server/routes/authRoute.js +++ b/server/routes/authRoute.js @@ -25,6 +25,8 @@ class AuthRoutes { this.router.get("/users/superadmin", this.authController.checkSuperadminExists); this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers); + this.router.get("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.getUserById); + this.router.put("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById); this.router.put("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser); this.router.delete("/user", verifyJWT, this.authController.deleteUser); From e047309812d95b6eeab0bc8bdc59c1a4c10387a0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 12:27:33 -0700 Subject: [PATCH 036/226] add controller methods --- server/controllers/authController.js | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/server/controllers/authController.js b/server/controllers/authController.js index ace63b662..88580d147 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -393,6 +393,44 @@ class AuthController { SERVICE_NAME, "getAllUsers" ); + + getUserById = asyncHandler( + async (req, res, next) => { + const userId = req?.params?.userId; + const roles = req?.user?.role; + + if (!userId) { + throw new Error("No user ID in request"); + } + + if (!roles || roles.length === 0) { + throw new Error("No roles in request"); + } + + const user = await this.db.getUserById(roles, userId); + + return res.success({ msg: "ok", data: user }); + }, + SERVICE_NAME, + "getUserById" + ); + + editUserById = asyncHandler( + async (req, res, next) => { + const userId = req.params.userId; + const user = req.body; + + const roles = req?.user?.role; + if (!roles.includes("superadmin")) { + throw createError("Unauthorized", 403); + } + + await this.db.editUserById(userId, user); + return res.success({ msg: "ok" }); + }, + SERVICE_NAME, + "editUserById" + ); } export default AuthController; From c4d42dd9e3c3cce2305127bf9506e585b9256a23 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 12:27:41 -0700 Subject: [PATCH 037/226] add db --- server/db/mongo/modules/userModule.js | 31 ++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/server/db/mongo/modules/userModule.js b/server/db/mongo/modules/userModule.js index cd609bb5d..311a345e1 100755 --- a/server/db/mongo/modules/userModule.js +++ b/server/db/mongo/modules/userModule.js @@ -209,4 +209,33 @@ const logoutUser = async (userId) => { } }; -export { insertUser, getUserByEmail, updateUser, deleteUser, deleteTeam, deleteAllOtherUsers, getAllUsers, logoutUser }; +const getUserById = async (roles, userId) => { + try { + if (!roles.includes("superadmin")) { + throw new Error("User is not a superadmin"); + } + + const user = await UserModel.findById(userId).select("-password").select("-profileImage"); + if (!user) { + throw new Error("User not found"); + } + + return user; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getUserById"; + throw error; + } +}; + +const editUserById = async (userId, user) => { + try { + await UserModel.findByIdAndUpdate(userId, user, { new: true }).select("-password").select("-profileImage"); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "editUserById"; + throw error; + } +}; + +export { insertUser, getUserByEmail, updateUser, deleteUser, deleteTeam, deleteAllOtherUsers, getAllUsers, logoutUser, getUserById, editUserById }; From 73f2f29c2aa73fac349ea5c933e6e620a6ee179d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 13:26:01 -0700 Subject: [PATCH 038/226] add valid roles --- client/src/Utils/roleUtils.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/Utils/roleUtils.js b/client/src/Utils/roleUtils.js index 2e8eea639..f359246b2 100644 --- a/client/src/Utils/roleUtils.js +++ b/client/src/Utils/roleUtils.js @@ -5,6 +5,8 @@ export const ROLES = { DEMO: "demo", }; +export const VALID_ROLES = [ROLES.ADMIN, ROLES.USER, ROLES.DEMO]; + export const EDITABLE_ROLES = [ { role: ROLES.ADMIN, _id: ROLES.ADMIN }, { role: ROLES.USER, _id: ROLES.USER }, From 22826afd211978445ba70b5762826fc304d461bb Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 13:26:45 -0700 Subject: [PATCH 039/226] apply validation --- server/controllers/authController.js | 19 ++++++++++-- server/validation/joi.js | 44 +++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/server/controllers/authController.js b/server/controllers/authController.js index 88580d147..208b981fc 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -5,6 +5,10 @@ import { recoveryValidation, recoveryTokenValidation, newPasswordValidation, + getUserByIdParamValidation, + editUserByIdParamValidation, + editUserByIdBodyValidation, + editSuperadminUserByIdBodyValidation, } from "../validation/joi.js"; import jwt from "jsonwebtoken"; import { getTokenFromHeaders } from "../utils/utils.js"; @@ -396,6 +400,7 @@ class AuthController { getUserById = asyncHandler( async (req, res, next) => { + await getUserByIdParamValidation.validateAsync(req.params); const userId = req?.params?.userId; const roles = req?.user?.role; @@ -417,14 +422,22 @@ class AuthController { editUserById = asyncHandler( async (req, res, next) => { - const userId = req.params.userId; - const user = req.body; - const roles = req?.user?.role; if (!roles.includes("superadmin")) { throw createError("Unauthorized", 403); } + const userId = req.params.userId; + const user = { ...req.body }; + + await editUserByIdParamValidation.validateAsync(req.params); + // If this is superadmin self edit, allow "superadmin" role + if (userId === req.user._id) { + await editSuperadminUserByIdBodyValidation.validateAsync(req.body); + } else { + await editUserByIdBodyValidation.validateAsync(req.body); + } + await this.db.editUserById(userId, user); return res.success({ msg: "ok" }); }, diff --git a/server/validation/joi.js b/server/validation/joi.js index 57c8b7350..619a30ea9 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -1,4 +1,5 @@ import joi from "joi"; +import { ROLES, VALID_ROLES } from "../../client/src/Utils/roleUtils.js"; //**************************************** // Custom Validators @@ -164,11 +165,7 @@ const createMonitorBodyValidation = joi.object({ .custom((value, helpers) => { // 1. Standard URLs: must have protocol and pass canParse() if (/^(https?:\/\/)/.test(value)) { - if ( - typeof URL !== "undefined" && - typeof URL.canParse === "function" && - URL.canParse(value) - ) { + if (typeof URL !== "undefined" && typeof URL.canParse === "function" && URL.canParse(value)) { return value; } // else, it's a malformed URL with protocol @@ -197,8 +194,7 @@ const createMonitorBodyValidation = joi.object({ .messages({ "string.empty": "This field is required.", "string.uri": "The URL you provided is not valid.", - "string.invalidUrl": - "Please enter a valid URL, hostname, or container name (with optional port).", + "string.invalidUrl": "Please enter a valid URL, hostname, or container name (with optional port).", }), ignoreTlsErrors: joi.boolean().default(false), port: joi.number(), @@ -657,6 +653,36 @@ const sendTestEmailBodyValidation = joi.object({ systemEmailTLSServername: joi.string().allow("").optional(), }); +const getUserByIdParamValidation = joi.object({ + userId: joi.string().required(), +}); + +const editUserByIdParamValidation = joi.object({ + userId: joi.string().required(), +}); + +const editUserByIdBodyValidation = joi.object({ + firstName: nameValidation.required(), + lastName: nameValidation.required(), + email: joi.string().email().required(), + role: joi + .array() + .items(joi.string().valid(...VALID_ROLES)) + .min(1) + .required(), +}); + +const editSuperadminUserByIdBodyValidation = joi.object({ + firstName: nameValidation.required(), + lastName: nameValidation.required(), + email: joi.string().email().required(), + role: joi + .array() + .items(joi.string().valid(...VALID_ROLES, ROLES.SUPERADMIN)) + .min(1) + .required(), +}); + export { roleValidatior, loginValidation, @@ -724,4 +750,8 @@ export { webhookConfigValidation, createAnnouncementValidation, sendTestEmailBodyValidation, + getUserByIdParamValidation, + editUserByIdParamValidation, + editUserByIdBodyValidation, + editSuperadminUserByIdBodyValidation, }; From f74289fe49e9b79f5f301c2e4a3fff0c9eef55de Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 13:32:26 -0700 Subject: [PATCH 040/226] format --- client/src/Components/LanguageSelector.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/Components/LanguageSelector.jsx b/client/src/Components/LanguageSelector.jsx index 57d102a9e..56a9a898d 100644 --- a/client/src/Components/LanguageSelector.jsx +++ b/client/src/Components/LanguageSelector.jsx @@ -6,7 +6,6 @@ import { useSelector } from "react-redux"; import { useDispatch } from "react-redux"; import { setLanguage } from "../Features/UI/uiSlice"; - const langMap = { cs: "cz", ja: "jp", From 457408006b9ce329f80e15e65562fabd19c3d50b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 22 Jul 2025 13:33:11 -0700 Subject: [PATCH 041/226] sort en.json --- client/src/locales/en.json | 137 ++++++++++++++++++------------------- 1 file changed, 68 insertions(+), 69 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index a7785cbc5..1c948cd64 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -217,8 +217,8 @@ "checkFormError": "Please check the form for errors.", "checkFrequency": "Check frequency", "checkHooks": { - "failureResolveOne": "Failed to resolve incident.", - "failureResolveAll": "Failed to resolve all incidents." + "failureResolveAll": "Failed to resolve all incidents.", + "failureResolveOne": "Failed to resolve incident." }, "checkingEvery": "Checking every", "city": "CITY", @@ -234,9 +234,6 @@ "unknownError": "Unknown error" } }, - "general": { - "noOptionsFound": "No {{unit}} found" - }, "commonSave": "Save", "commonSaving": "Saving...", "companyName": "Company name", @@ -256,6 +253,8 @@ "createMonitor": "Create monitor", "createNew": "Create new", "createNotifications": { + "dialogDeleteConfirm": "Delete", + "dialogDeleteTitle": "Are you sure you want to delete this notification?", "discordSettings": { "description": "Configure your Discord webhook here", "title": "Discord", @@ -286,6 +285,7 @@ "webhookLabel": "Slack webhook URL", "webhookPlaceholder": "https://hooks.slack.com/services/..." }, + "testNotification": "Test notification", "title": "Create notification channel", "typeSettings": { "description": "Select the type of notification channel you want to create.", @@ -297,10 +297,7 @@ "title": "Webhook", "webhookLabel": "Webhook URL", "webhookPlaceholder": "https://your-server.com/webhook" - }, - "testNotification": "Test notification", - "dialogDeleteTitle": "Are you sure you want to delete this notification?", - "dialogDeleteConfirm": "Delete" + } }, "createYour": "Create your", "date&Time": "Date & Time", @@ -310,6 +307,27 @@ "deleteStatusPage": "Do you want to delete this status page?", "deleteStatusPageConfirm": "Yes, delete status page", "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", + "diagnosticsPage": { + "diagnosticDescription": "System diagnostics", + "gauges": { + "heapAllocationSubtitle": "% of available memory", + "heapAllocationTitle": "Heap allocation", + "heapUsageSubtitle": "% of available memory", + "heapUsageTitle": "Heap usage", + "heapUtilizationSubtitle": "% of allocated", + "heapUtilizationTitle": "Heap utilization", + "instantCpuUsageSubtitle": "% of 1s used by CPU", + "instantCpuUsageTitle": "Instant CPU usage" + }, + "stats": { + "eventLoopDelayTitle": "Event loop delay", + "osMemoryLimitTitle": "OS Memory Limit", + "totalHeapSizeTitle": "Total heap size", + "uptimeTitle": "Uptime", + "usedHeapSizeTitle": "Used heap size" + }, + "statsDescription": "System statistics" + }, "disk": "Disk", "diskUsage": "Disk Usage", "displayName": "Display name", @@ -360,6 +378,24 @@ "duration": "Duration", "edit": "Edit", "editMaintenance": "Edit maintenance", + "editUserPage": { + "form": { + "email": "Email", + "firstName": "First name", + "lastName": "Last name", + "role": "Roles", + "save": "Save" + }, + "table": { + "actionHeader": "Action", + "roleHeader": "Role" + }, + "title": "Edit user", + "toast": { + "successUserUpdate": "User updated successfully", + "validationErrors": "Validation errors" + } + }, "editing": "Editing...", "emailSent": "Email sent successfully", "errorInvalidFieldId": "Invalid field ID provided", @@ -390,6 +426,9 @@ "friendlyNameInput": "Friendly name", "friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes", "gb": "GB", + "general": { + "noOptionsFound": "No {{unit}} found" + }, "greeting": { "append": "The afternoon is your playground—let's make it epic!", "overview": "Here's an overview of your {{type}} monitors.", @@ -404,24 +443,24 @@ "incidentsOptionsHeader": "Incidents for:", "incidentsOptionsHeaderFilterAll": "All", "incidentsOptionsHeaderFilterBy": "Filter by:", - "incidentsOptionsHeaderTotalIncidents": "Total Incidents", "incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve", - "incidentsTableResolvedAt": "Resolved at", "incidentsOptionsHeaderFilterDown": "Down", - "incidentsTableActionResolve": "Resolve", - "incidentsPageActionResolve": "Resolve all incidents", "incidentsOptionsHeaderFilterResolved": "Resolved", "incidentsOptionsHeaderLastDay": "Last day", "incidentsOptionsHeaderLastHour": "Last hour", "incidentsOptionsHeaderLastWeek": "Last week", "incidentsOptionsHeaderShow": "Show:", + "incidentsOptionsHeaderTotalIncidents": "Total Incidents", "incidentsOptionsPlaceholderAllServers": "All servers", + "incidentsPageActionResolve": "Resolve all incidents", "incidentsPageTitle": "Incidents", + "incidentsTableActionResolve": "Resolve", "incidentsTableDateTime": "Date & Time", "incidentsTableMessage": "Message", "incidentsTableMonitorName": "Monitor Name", "incidentsTableNoIncidents": "No incidents recorded", "incidentsTablePaginationLabel": "incidents", + "incidentsTableResolvedAt": "Resolved at", "incidentsTableStatus": "Status", "incidentsTableStatusCode": "Status Code", "infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.", @@ -450,7 +489,6 @@ "invalidFileSize": "File size is too large!", "inviteNoTokenFound": "No invite token found", "loginHere": "Login here", - "logsPage": { "description": "System logs - last 1000 lines", "logLevelSelect": { @@ -464,36 +502,15 @@ } }, "tabs": { + "diagnostics": "Diagnostics", "logs": "Server logs", - "queue": "Job queue", - "diagnostics": "Diagnostics" + "queue": "Job queue" }, "title": "Logs", "toast": { "fetchLogsSuccess": "Logs fetched successfully" } }, - "diagnosticsPage": { - "diagnosticDescription": "System diagnostics", - "statsDescription": "System statistics", - "gauges": { - "heapAllocationTitle": "Heap allocation", - "heapAllocationSubtitle": "% of available memory", - "heapUsageTitle": "Heap usage", - "heapUsageSubtitle": "% of available memory", - "heapUtilizationTitle": "Heap utilization", - "heapUtilizationSubtitle": "% of allocated", - "instantCpuUsageTitle": "Instant CPU usage", - "instantCpuUsageSubtitle": "% of 1s used by CPU" - }, - "stats": { - "eventLoopDelayTitle": "Event loop delay", - "uptimeTitle": "Uptime", - "usedHeapSizeTitle": "Used heap size", - "totalHeapSizeTitle": "Total heap size", - "osMemoryLimitTitle": "OS Memory Limit" - } - }, "low": "low", "maintenance": "maintenance", "maintenanceRepeat": "Maintenance Repeat", @@ -506,24 +523,6 @@ "mem": "Mem", "memory": "Memory", "memoryUsage": "Memory usage", - "editUserPage": { - "title": "Edit user", - "form": { - "firstName": "First name", - "lastName": "Last name", - "email": "Email", - "role": "Roles", - "save": "Save" - }, - "table": { - "roleHeader": "Role", - "actionHeader": "Action" - }, - "toast": { - "validationErrors": "Validation errors", - "successUserUpdate": "User updated successfully" - } - }, "menu": { "changelog": "Changelog", "discussions": "Discussions", @@ -547,8 +546,8 @@ "message": "Message", "monitor": "monitor", "monitorActions": { - "deleteSuccess": "Monitor deleted successfully", "deleteFailed": "Failed to delete monitor", + "deleteSuccess": "Monitor deleted successfully", "details": "Details", "export": "Export Monitors", "import": "Import Monitors", @@ -846,6 +845,14 @@ "pending": "Pending...", "up": "Your site is up." }, + "statusPage": { + "contents": "Contents", + "createSuccess": "Status page created successfully", + "deleteFailed": "Failed to delete status page", + "deleteSuccess": "Status page deleted successfully", + "generalSettings": "General settings", + "updateSuccess": "Status page updated successfully" + }, "statusPageCreate": { "buttonSave": "Save" }, @@ -865,14 +872,6 @@ "statusPageStatusNoPage": "There's no status page here.", "statusPageStatusNotPublic": "This status page is not public.", "statusPageStatusServiceStatus": "Service status", - "statusPage": { - "deleteSuccess": "Status page deleted successfully", - "deleteFailed": "Failed to delete status page", - "createSuccess": "Status page created successfully", - "updateSuccess": "Status page updated successfully", - "generalSettings": "General settings", - "contents": "Contents" - }, "submit": "Submit", "teamPanel": { "cancel": "Cancel", @@ -900,13 +899,13 @@ "testLocale": "testLocale", "testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button", "time": { - "threeMinutes": "3 minutes", "fiveMinutes": "5 minutes", - "tenMinutes": "10 minutes", - "twentyMinutes": "20 minutes", - "oneHour": "1 hour", "oneDay": "1 day", - "oneWeek": "1 week" + "oneHour": "1 hour", + "oneWeek": "1 week", + "tenMinutes": "10 minutes", + "threeMinutes": "3 minutes", + "twentyMinutes": "20 minutes" }, "timeZoneInfo": "All dates and times are in GMT+0 time zone.", "timezone": "Timezone", From 22d17708cd55e1215976b0afdfeef70d4b9d7a02 Mon Sep 17 00:00:00 2001 From: karenvicent Date: Tue, 22 Jul 2025 16:46:43 -0400 Subject: [PATCH 042/226] format --- client/src/Components/LanguageSelector.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/Components/LanguageSelector.jsx b/client/src/Components/LanguageSelector.jsx index 57d102a9e..56a9a898d 100644 --- a/client/src/Components/LanguageSelector.jsx +++ b/client/src/Components/LanguageSelector.jsx @@ -6,7 +6,6 @@ import { useSelector } from "react-redux"; import { useDispatch } from "react-redux"; import { setLanguage } from "../Features/UI/uiSlice"; - const langMap = { cs: "cz", ja: "jp", From 8fcaf721adfe4c1ce62fb02c6ebe2637c170388a Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Wed, 23 Jul 2025 15:33:09 +0530 Subject: [PATCH 043/226] Fix: Infrastructure disks overflow --- client/src/Components/LanguageSelector.jsx | 1 - .../Details/Components/GaugeBoxes/index.jsx | 11 +++++++++-- client/src/Pages/Infrastructure/Details/index.jsx | 4 ++-- server/validation/joi.js | 9 ++------- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/client/src/Components/LanguageSelector.jsx b/client/src/Components/LanguageSelector.jsx index 57d102a9e..56a9a898d 100644 --- a/client/src/Components/LanguageSelector.jsx +++ b/client/src/Components/LanguageSelector.jsx @@ -6,7 +6,6 @@ import { useSelector } from "react-redux"; import { useDispatch } from "react-redux"; import { setLanguage } from "../Features/UI/uiSlice"; - const langMap = { cs: "cz", ja: "jp", diff --git a/client/src/Pages/Infrastructure/Details/Components/GaugeBoxes/index.jsx b/client/src/Pages/Infrastructure/Details/Components/GaugeBoxes/index.jsx index 4dd978018..3c1c68f8b 100644 --- a/client/src/Pages/Infrastructure/Details/Components/GaugeBoxes/index.jsx +++ b/client/src/Pages/Infrastructure/Details/Components/GaugeBoxes/index.jsx @@ -2,18 +2,19 @@ import { Stack } from "@mui/material"; import Gauge from "./Gauge"; import SkeletonLayout from "./skeleton"; +import PropTypes from "prop-types"; // Utils import { useHardwareUtils } from "../../Hooks/useHardwareUtils"; import { useTheme } from "@emotion/react"; import { useTranslation } from "react-i18next"; -const Gauges = ({ shouldRender, monitor }) => { +const Gauges = ({ isLoading = false, monitor }) => { const { decimalToPercentage, formatBytes } = useHardwareUtils(); const theme = useTheme(); const { t } = useTranslation(); - if (!shouldRender) { + if (isLoading) { return ; } @@ -60,6 +61,7 @@ const Gauges = ({ shouldRender, monitor }) => { return ( {gauges.map((gauge) => { @@ -79,4 +81,9 @@ const Gauges = ({ shouldRender, monitor }) => { ); }; +Gauges.propTypes = { + isLoading: PropTypes.bool, + monitor: PropTypes.object, +}; + export default Gauges; diff --git a/client/src/Pages/Infrastructure/Details/index.jsx b/client/src/Pages/Infrastructure/Details/index.jsx index b57e0b91e..30840851f 100644 --- a/client/src/Pages/Infrastructure/Details/index.jsx +++ b/client/src/Pages/Infrastructure/Details/index.jsx @@ -92,11 +92,11 @@ const InfrastructureDetails = () => { monitor={monitor} /> diff --git a/server/validation/joi.js b/server/validation/joi.js index 57c8b7350..b33283d98 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -164,11 +164,7 @@ const createMonitorBodyValidation = joi.object({ .custom((value, helpers) => { // 1. Standard URLs: must have protocol and pass canParse() if (/^(https?:\/\/)/.test(value)) { - if ( - typeof URL !== "undefined" && - typeof URL.canParse === "function" && - URL.canParse(value) - ) { + if (typeof URL !== "undefined" && typeof URL.canParse === "function" && URL.canParse(value)) { return value; } // else, it's a malformed URL with protocol @@ -197,8 +193,7 @@ const createMonitorBodyValidation = joi.object({ .messages({ "string.empty": "This field is required.", "string.uri": "The URL you provided is not valid.", - "string.invalidUrl": - "Please enter a valid URL, hostname, or container name (with optional port).", + "string.invalidUrl": "Please enter a valid URL, hostname, or container name (with optional port).", }), ignoreTlsErrors: joi.boolean().default(false), port: joi.number(), From de88e1d442032807980457c27fc4d05f878804c0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 09:37:37 -0700 Subject: [PATCH 044/226] return early if delete superadmin --- client/src/Pages/Account/EditUser/hooks/editUser.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/Pages/Account/EditUser/hooks/editUser.js b/client/src/Pages/Account/EditUser/hooks/editUser.js index 74914ee07..2ffa914a0 100644 --- a/client/src/Pages/Account/EditUser/hooks/editUser.js +++ b/client/src/Pages/Account/EditUser/hooks/editUser.js @@ -39,6 +39,7 @@ export const useEditUserForm = (user) => { }; const handleDeleteRole = (role) => { + if (role === ROLES.SUPERADMIN) return; setForm({ ...form, role: form?.role?.filter((r) => r !== role) }); }; From ac2b7bd65d53aa0ad3e7cefd82ef1be33d626842 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 10:12:47 -0700 Subject: [PATCH 045/226] fix import path --- server/utils/roleUtils.js | 13 +++++++++++++ server/validation/joi.js | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 server/utils/roleUtils.js diff --git a/server/utils/roleUtils.js b/server/utils/roleUtils.js new file mode 100644 index 000000000..f359246b2 --- /dev/null +++ b/server/utils/roleUtils.js @@ -0,0 +1,13 @@ +export const ROLES = { + SUPERADMIN: "superadmin", + ADMIN: "admin", + USER: "user", + DEMO: "demo", +}; + +export const VALID_ROLES = [ROLES.ADMIN, ROLES.USER, ROLES.DEMO]; + +export const EDITABLE_ROLES = [ + { role: ROLES.ADMIN, _id: ROLES.ADMIN }, + { role: ROLES.USER, _id: ROLES.USER }, +]; diff --git a/server/validation/joi.js b/server/validation/joi.js index 619a30ea9..dcbf9a606 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -1,5 +1,5 @@ import joi from "joi"; -import { ROLES, VALID_ROLES } from "../../client/src/Utils/roleUtils.js"; +import { ROLES, VALID_ROLES } from "../utils/roleUtils.js"; //**************************************** // Custom Validators From 7127c28c35af4b9a7fdd43b9753f98331c212007 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 10:35:32 -0700 Subject: [PATCH 046/226] bump dependency --- server/package-lock.json | 376 +++++++++++++++++++++++---------------- server/package.json | 2 +- 2 files changed, 227 insertions(+), 151 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 6fbb33de6..14dc57616 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -33,7 +33,7 @@ "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", - "super-simple-scheduler": "1.2.3", + "super-simple-scheduler": "1.3.0", "swagger-ui-express": "5.0.1", "winston": "^3.13.0" }, @@ -120,9 +120,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz", - "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", "license": "MIT", "optional": true, "dependencies": { @@ -197,9 +197,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -247,9 +247,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", - "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", + "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", "dev": true, "license": "MIT", "engines": { @@ -270,9 +270,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", - "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -283,19 +283,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", @@ -1092,15 +1079,6 @@ "dev": true, "license": "(Unlicense OR Apache-2.0)" }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "license": "ISC", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1123,9 +1101,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.0.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.11.tgz", - "integrity": "sha512-CJV8eqrYnwQJGMrvcRhQmZfpyniDavB+7nAZYJc6w99hFYJyFN3INV1/2W3QfQrqM36WTLrijJ1fxxvGBmCSxA==", + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", + "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", "license": "MIT", "dependencies": { "undici-types": "~7.8.0" @@ -1322,13 +1300,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -2274,12 +2252,12 @@ } }, "node_modules/css-tree": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", - "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", "license": "MIT", "dependencies": { - "mdn-data": "2.0.30", + "mdn-data": "2.12.2", "source-map-js": "^1.0.1" }, "engines": { @@ -2311,12 +2289,12 @@ } }, "node_modules/cssnano": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.0.7.tgz", - "integrity": "sha512-evKu7yiDIF7oS+EIpwFlMF730ijRyLFaM2o5cTxRGJR9OKHKkc+qP443ZEVR9kZG0syaAJJCPJyfv5pbrxlSng==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.0.tgz", + "integrity": "sha512-Pu3rlKkd0ZtlCUzBrKL1Z4YmhKppjC1H9jo7u1o4qaKqyhvixFgu5qLyNIAOjSTg9DjVPtUqdROq2EfpVMEe+w==", "license": "MIT", "dependencies": { - "cssnano-preset-default": "^7.0.7", + "cssnano-preset-default": "^7.0.8", "lilconfig": "^3.1.3" }, "engines": { @@ -2331,26 +2309,26 @@ } }, "node_modules/cssnano-preset-default": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.7.tgz", - "integrity": "sha512-jW6CG/7PNB6MufOrlovs1TvBTEVmhY45yz+bd0h6nw3h6d+1e+/TX+0fflZ+LzvZombbT5f+KC063w9VoHeHow==", + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.8.tgz", + "integrity": "sha512-d+3R2qwrUV3g4LEMOjnndognKirBZISylDZAF/TPeCWVjEwlXS2e4eN4ICkoobRe7pD3H6lltinKVyS1AJhdjQ==", "license": "MIT", "dependencies": { - "browserslist": "^4.24.5", + "browserslist": "^4.25.1", "css-declaration-sorter": "^7.2.0", "cssnano-utils": "^5.0.1", "postcss-calc": "^10.1.1", - "postcss-colormin": "^7.0.3", - "postcss-convert-values": "^7.0.5", + "postcss-colormin": "^7.0.4", + "postcss-convert-values": "^7.0.6", "postcss-discard-comments": "^7.0.4", "postcss-discard-duplicates": "^7.0.2", "postcss-discard-empty": "^7.0.1", "postcss-discard-overridden": "^7.0.1", "postcss-merge-longhand": "^7.0.5", - "postcss-merge-rules": "^7.0.5", + "postcss-merge-rules": "^7.0.6", "postcss-minify-font-values": "^7.0.1", "postcss-minify-gradients": "^7.0.1", - "postcss-minify-params": "^7.0.3", + "postcss-minify-params": "^7.0.4", "postcss-minify-selectors": "^7.0.5", "postcss-normalize-charset": "^7.0.1", "postcss-normalize-display-values": "^7.0.1", @@ -2358,13 +2336,13 @@ "postcss-normalize-repeat-style": "^7.0.1", "postcss-normalize-string": "^7.0.1", "postcss-normalize-timing-functions": "^7.0.1", - "postcss-normalize-unicode": "^7.0.3", + "postcss-normalize-unicode": "^7.0.4", "postcss-normalize-url": "^7.0.1", "postcss-normalize-whitespace": "^7.0.1", "postcss-ordered-values": "^7.0.2", - "postcss-reduce-initial": "^7.0.3", + "postcss-reduce-initial": "^7.0.4", "postcss-reduce-transforms": "^7.0.1", - "postcss-svgo": "^7.0.2", + "postcss-svgo": "^7.1.0", "postcss-unique-selectors": "^7.0.4" }, "engines": { @@ -2710,9 +2688,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.180", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.180.tgz", - "integrity": "sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA==", + "version": "1.5.190", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.190.tgz", + "integrity": "sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -2886,9 +2864,9 @@ } }, "node_modules/eslint": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", - "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", + "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2896,9 +2874,9 @@ "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.14.0", + "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.30.1", + "@eslint/js": "9.31.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3388,9 +3366,9 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4221,25 +4199,25 @@ } }, "node_modules/juice/node_modules/cheerio": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.0.tgz", - "integrity": "sha512-+0hMx9eYhJvWbgpKV9hN7jg0JcwydpopZE4hgi+KvQtByZXPp04NiCWU0LzcAbP63abZckIHkTQaXVF52mX3xQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", + "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", "license": "MIT", "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", - "encoding-sniffer": "^0.2.0", + "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", - "undici": "^7.10.0", + "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" }, "engines": { - "node": ">=18.17" + "node": ">=20.18.1" }, "funding": { "url": "https://github.com/cheeriojs/cheerio?sponsor=1" @@ -4524,9 +4502,9 @@ "license": "ISC" }, "node_modules/luxon": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", - "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==", "license": "MIT", "engines": { "node": ">=12" @@ -4569,9 +4547,9 @@ } }, "node_modules/mdn-data": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", "license": "CC0-1.0" }, "node_modules/media-typer": { @@ -5230,9 +5208,9 @@ } }, "node_modules/mongodb": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz", - "integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz", + "integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==", "license": "Apache-2.0", "dependencies": { "@mongodb-js/saslprep": "^1.1.9", @@ -5286,9 +5264,9 @@ } }, "node_modules/mongoose": { - "version": "8.16.2", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.2.tgz", - "integrity": "sha512-52T4XPhDgJL4cqooBsOORzYBH3ddMwABMEF/LV7TgvD2DEKZlnYTc2HF9ch1U2lcKjhE4pQ+WuInfLFJbguGcQ==", + "version": "8.16.4", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.4.tgz", + "integrity": "sha512-jslgdQ8pY2vcNSKPv3Dbi5ogo/NT8zcvf6kPDyD8Sdsjsa1at3AFAF0F5PT+jySPGSPbvlNaQ49nT9h+Kx2UDA==", "license": "MIT", "dependencies": { "bson": "^6.10.4", @@ -5307,6 +5285,52 @@ "url": "https://opencollective.com/mongoose" } }, + "node_modules/mongoose/node_modules/mongodb": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz", + "integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, "node_modules/mpath": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", @@ -5335,9 +5359,9 @@ "license": "MIT" }, "node_modules/msgpackr": { - "version": "1.11.4", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.4.tgz", - "integrity": "sha512-uaff7RG9VIC4jacFW9xzL3jc0iM32DNHe4jYVycBcjUePT/Klnfj7pqtWJt9khvDFizmjN2TlYniYmSS2LIaZg==", + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", "license": "MIT", "optionalDependencies": { "msgpackr-extract": "^3.0.2" @@ -5385,9 +5409,9 @@ } }, "node_modules/nan": { - "version": "2.22.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", - "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", + "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", "license": "MIT", "optional": true }, @@ -5965,12 +5989,12 @@ } }, "node_modules/postcss-colormin": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.3.tgz", - "integrity": "sha512-xZxQcSyIVZbSsl1vjoqZAcMYYdnJsIyG8OvqShuuqf12S88qQboxxEy0ohNCOLwVPXTU+hFHvJPACRL2B5ohTA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.4.tgz", + "integrity": "sha512-ziQuVzQZBROpKpfeDwmrG+Vvlr0YWmY/ZAk99XD+mGEBuEojoFekL41NCsdhyNUtZI7DPOoIWIR7vQQK9xwluw==", "license": "MIT", "dependencies": { - "browserslist": "^4.24.5", + "browserslist": "^4.25.1", "caniuse-api": "^3.0.0", "colord": "^2.9.3", "postcss-value-parser": "^4.2.0" @@ -5983,12 +6007,12 @@ } }, "node_modules/postcss-convert-values": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.5.tgz", - "integrity": "sha512-0VFhH8nElpIs3uXKnVtotDJJNX0OGYSZmdt4XfSfvOMrFw1jKfpwpZxfC4iN73CTM/MWakDEmsHQXkISYj4BXw==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.6.tgz", + "integrity": "sha512-MD/eb39Mr60hvgrqpXsgbiqluawYg/8K4nKsqRsuDX9f+xN1j6awZCUv/5tLH8ak3vYp/EMXwdcnXvfZYiejCQ==", "license": "MIT", "dependencies": { - "browserslist": "^4.24.5", + "browserslist": "^4.25.1", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -6066,12 +6090,12 @@ } }, "node_modules/postcss-merge-rules": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.5.tgz", - "integrity": "sha512-ZonhuSwEaWA3+xYbOdJoEReKIBs5eDiBVLAGpYZpNFPzXZcEE5VKR7/qBEQvTZpiwjqhhqEQ+ax5O3VShBj9Wg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.6.tgz", + "integrity": "sha512-2jIPT4Tzs8K87tvgCpSukRQ2jjd+hH6Bb8rEEOUDmmhOeTcqDg5fEFK8uKIu+Pvc3//sm3Uu6FRqfyv7YF7+BQ==", "license": "MIT", "dependencies": { - "browserslist": "^4.24.5", + "browserslist": "^4.25.1", "caniuse-api": "^3.0.0", "cssnano-utils": "^5.0.1", "postcss-selector-parser": "^7.1.0" @@ -6116,12 +6140,12 @@ } }, "node_modules/postcss-minify-params": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.3.tgz", - "integrity": "sha512-vUKV2+f5mtjewYieanLX0xemxIp1t0W0H/D11u+kQV/MWdygOO7xPMkbK+r9P6Lhms8MgzKARF/g5OPXhb8tgg==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.4.tgz", + "integrity": "sha512-3OqqUddfH8c2e7M35W6zIwv7jssM/3miF9cbCSb1iJiWvtguQjlxZGIHK9JRmc8XAKmE2PFGtHSM7g/VcW97sw==", "license": "MIT", "dependencies": { - "browserslist": "^4.24.5", + "browserslist": "^4.25.1", "cssnano-utils": "^5.0.1", "postcss-value-parser": "^4.2.0" }, @@ -6236,12 +6260,12 @@ } }, "node_modules/postcss-normalize-unicode": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.3.tgz", - "integrity": "sha512-EcoA29LvG3F+EpOh03iqu+tJY3uYYKzArqKJHxDhUYLa2u58aqGq16K6/AOsXD9yqLN8O6y9mmePKN5cx6krOw==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.4.tgz", + "integrity": "sha512-LvIURTi1sQoZqj8mEIE8R15yvM+OhbR1avynMtI9bUzj5gGKR/gfZFd8O7VMj0QgJaIFzxDwxGl/ASMYAkqO8g==", "license": "MIT", "dependencies": { - "browserslist": "^4.24.5", + "browserslist": "^4.25.1", "postcss-value-parser": "^4.2.0" }, "engines": { @@ -6298,12 +6322,12 @@ } }, "node_modules/postcss-reduce-initial": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.3.tgz", - "integrity": "sha512-RFvkZaqiWtGMlVjlUHpaxGqEL27lgt+Q2Ixjf83CRAzqdo+TsDyGPtJUbPx2MuYIJ+sCQc2TrOvRnhcXQfgIVA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.4.tgz", + "integrity": "sha512-rdIC9IlMBn7zJo6puim58Xd++0HdbvHeHaPgXsimMfG1ijC5A9ULvNLSE0rUKVJOvNMcwewW4Ga21ngyJjY/+Q==", "license": "MIT", "dependencies": { - "browserslist": "^4.24.5", + "browserslist": "^4.25.1", "caniuse-api": "^3.0.0" }, "engines": { @@ -6342,13 +6366,13 @@ } }, "node_modules/postcss-svgo": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.0.2.tgz", - "integrity": "sha512-5Dzy66JlnRM6pkdOTF8+cGsB1fnERTE8Nc+Eed++fOWo1hdsBptCsbG8UuJkgtZt75bRtMJIrPeZmtfANixdFA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.1.0.tgz", + "integrity": "sha512-KnAlfmhtoLz6IuU3Sij2ycusNs4jPW+QoFE5kuuUOK8awR6tMxZQrs5Ey3BUz7nFCzT3eqyFgqkyrHiaU2xx3w==", "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0", - "svgo": "^3.3.2" + "svgo": "^4.0.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >= 18" @@ -6754,6 +6778,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -7268,12 +7298,12 @@ } }, "node_modules/stylehacks": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.5.tgz", - "integrity": "sha512-5kNb7V37BNf0Q3w+1pxfa+oiNPS++/b4Jil9e/kPDgrk1zjEd6uR7SZeJiYaLYH6RRSC1XX2/37OTeU/4FvuIA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.6.tgz", + "integrity": "sha512-iitguKivmsueOmTO0wmxURXBP8uqOO+zikLGZ7Mm9e/94R4w5T999Js2taS/KBOnQ/wdC3jN3vNSrkGDrlnqQg==", "license": "MIT", "dependencies": { - "browserslist": "^4.24.5", + "browserslist": "^4.25.1", "postcss-selector-parser": "^7.1.0" }, "engines": { @@ -7284,9 +7314,9 @@ } }, "node_modules/super-simple-scheduler": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.2.3.tgz", - "integrity": "sha512-lpA/xzwRXl9KXN/+mznHfF2cMDQOXejJYna/bk3L0SPBDr28afrtx3orEKZ2JVDwF+f3Np01YlYamT51UU8OCw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.3.0.tgz", + "integrity": "sha512-iEGZ+a9Xv7pIaA+XhVuHSGaasQg2T6/afTRlQhosQiiU/7ykpZBsiZB/L2Hqfem5YUrbr3Q8tzrjjXCXKMg81A==", "license": "MIT", "dependencies": { "human-interval": "2.0.1", @@ -7296,6 +7326,52 @@ "winston": "3.17.0" } }, + "node_modules/super-simple-scheduler/node_modules/mongodb": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz", + "integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, "node_modules/super-simple-scheduler/node_modules/mongoose": { "version": "8.16.1", "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.1.tgz", @@ -7345,24 +7421,24 @@ } }, "node_modules/svgo": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", - "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", + "integrity": "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==", "license": "MIT", "dependencies": { - "@trysound/sax": "0.2.0", - "commander": "^7.2.0", + "commander": "^11.1.0", "css-select": "^5.1.0", - "css-tree": "^2.3.1", + "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1", + "sax": "^1.4.1" }, "bin": { - "svgo": "bin/svgo" + "svgo": "bin/svgo.js" }, "engines": { - "node": ">=14.0.0" + "node": ">=16" }, "funding": { "type": "opencollective", @@ -7370,18 +7446,18 @@ } }, "node_modules/svgo/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", "license": "MIT", "engines": { - "node": ">= 10" + "node": ">=16" } }, "node_modules/swagger-ui-dist": { - "version": "5.26.2", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.26.2.tgz", - "integrity": "sha512-WmMS9iMlHQejNm/Uw5ZTo4e3M2QMmEavRz7WLWVsq7Mlx4PSHJbY+VCrLsAz9wLxyHVgrJdt7N8+SdQLa52Ykg==", + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.27.0.tgz", + "integrity": "sha512-tS6LRyBhY6yAqxrfsA9IYpGWPUJOri6sclySa7TdC7XQfGLvTwDY531KLgfQwHEtQsn+sT4JlUspbeQDBVGWig==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -7617,9 +7693,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.11.0.tgz", - "integrity": "sha512-heTSIac3iLhsmZhUCjyS3JQEkZELateufzZuBaVM5RHXdSBMb1LPMQf5x+FH7qjsZYDP0ttAc3nnVpUB+wYbOg==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.12.0.tgz", + "integrity": "sha512-GrKEsc3ughskmGA9jevVlIOPMiiAHJ4OFUtaAH+NhfTUSiZ1wMPIQqQvAJUrJspFXJt3EBWgpAeoHEDVT1IBug==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/server/package.json b/server/package.json index 494b81e2c..62a91921b 100755 --- a/server/package.json +++ b/server/package.json @@ -40,7 +40,7 @@ "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", - "super-simple-scheduler": "1.2.3", + "super-simple-scheduler": "1.3.0", "swagger-ui-express": "5.0.1", "winston": "^3.13.0" }, From f90b5bab4bcaa0208f7821c696f75b3b172f6b8f Mon Sep 17 00:00:00 2001 From: Fernando Canchola Cruz Date: Wed, 23 Jul 2025 12:36:07 -0600 Subject: [PATCH 047/226] =?UTF-8?q?Agrega=20traducci=C3=B3n=20al=20espa?= =?UTF-8?q?=C3=B1ol=20del=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.es.md | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 README.es.md diff --git a/README.es.md b/README.es.md new file mode 100644 index 000000000..a13ca6a75 --- /dev/null +++ b/README.es.md @@ -0,0 +1,149 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +![](https://img.shields.io/github/license/bluewave-labs/checkmate) +![](https://img.shields.io/github/repo-size/bluewave-labs/checkmate) +![](https://img.shields.io/github/commit-activity/m/bluewave-labs/checkmate) +![](https://img.shields.io/github/last-commit/bluewave-labs/checkmate) +![](https://img.shields.io/github/languages/top/bluewave-labs/checkmate) +![](https://img.shields.io/github/issues/bluewave-labs/checkmate) +![](https://img.shields.io/github/issues-pr/bluewave-labs/checkmate) +[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9901/badge)](https://www.bestpractices.dev/projects/9901) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

Una aplicación de monitoreo de infraestructura y disponibilidad de código abierto

+ +image + +Este repositorio contiene tanto el frontend como el backend de Checkmate, una herramienta de monitoreo de código abierto y autoalojada para rastrear hardware de servidores, tiempo de actividad, tiempos de respuesta e incidentes en tiempo real con visualizaciones atractivas. Checkmate verifica regularmente si un servidor/sitio web es accesible y funciona de manera óptima, proporcionando alertas y reportes en tiempo real sobre disponibilidad, tiempo de inactividad y respuesta de los servicios monitoreados. + +Checkmate también tiene un agente llamado [Capture](https://github.com/bluewave-labs/capture) para obtener datos de servidores remotos. Aunque no es obligatorio para ejecutar Checkmate, proporciona información adicional sobre el estado del CPU, RAM, disco y temperatura de tus servidores. + +Checkmate ha sido probado con más de 1000 monitores activos sin problemas significativos ni cuellos de botella en el rendimiento. + +**Si deseas patrocinar una función, [consulta este enlace](https://checkmate.so/sponsored-features).** + +## 📚 Tabla de contenidos + +- [📦 Demo](#-demo) +- [🔗 Guía del usuario](#-guía-del-usuario) +- [🛠️ Instalación](#️-instalación) +- [🚀 Despliegue con Helm](#-despliegue-con-helm) +- [🏁 Traducciones](#-traducciones) +- [🚀 Rendimiento](#-rendimiento) +- [💚 Preguntas e ideas](#-preguntas-e-ideas) +- [🧩 Funcionalidades](#-funcionalidades) +- [🏗️ Capturas de pantalla](#-capturas-de-pantalla) +- [🏗️ Tecnología utilizada](#-tecnología-utilizada) +- [🔗 Enlaces útiles](#enlaces-útiles) +- [🤝 Cómo contribuir](#-cómo-contribuir) +- [💰 Patrocinadores](#-patrocinadores) + +## 📦 Demo + +Puedes ver la última versión de [Checkmate](https://checkmate-demo.bluewavelabs.ca/) en acción. Usuario: `uptimedemo@demo.com`, contraseña: `Demouser1!`. (Actualizamos el servidor demo periódicamente. Si no funciona, contáctanos en el canal de Discussions). + +## 🔗 Guía del usuario + +Instrucciones de uso disponibles [aquí](https://docs.checkmate.so/checkmate-2.1). Aún está en desarrollo, pero se actualiza constantemente. + +## 🛠️ Instalación + +Consulta las instrucciones de instalación en el [portal de documentación de Checkmate](https://docs.checkmate.so/checkmate-2.1/users-guide/quickstart). + +También puedes usar [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) o [Pikapods](https://www.pikapods.com/) para desplegar rápidamente una instancia. Si quieres monitorear tu infraestructura, necesitarás el agente [Capture](https://github.com/bluewave-labs/capture). + +## 🏁 Traducciones + +Si deseas usar Checkmate en otro idioma, [únete a este proyecto en Poeditor](https://poeditor.com/join/project/lRUoGZFCsJ) para colaborar con las traducciones. + +## 🚀 Rendimiento + +Gracias a optimizaciones avanzadas, Checkmate tiene un consumo de memoria muy bajo. Aquí el uso de memoria monitoreando 323 servidores cada minuto: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +Uso de MongoDB y Redis en el mismo servidor (398Mb y 15Mb): + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + +## 💚 Preguntas e ideas + +Puedes contactarnos por: + +- [Canal de Discord](https://discord.gg/NAb6H3UTjK) +- [Discusiones en GitHub](https://github.com/bluewave-labs/bluewave-uptime/discussions) +- [Grupo en Reddit](https://www.reddit.com/r/CheckmateMonitoring/) + +¡Tus ideas son bienvenidas! + +## 🧩 Funcionalidades + +- Código abierto, autoalojable (Raspberry Pi incluido) +- Monitoreo de sitios web, puertos, ping, SSL +- Velocidad de página +- Monitoreo de infraestructura (requiere agente Capture) +- Monitoreo Docker +- Incidentes y mantenimiento programado +- Notificaciones (email, Discord, Slack, Telegram, Webhooks) +- Consulta JSON +- Multilenguaje + +**Próximas funciones (Milestone 2.2):** +- Mejores notificaciones +- Monitoreo de red +- Otras mejoras + +## 🏗️ Capturas de pantalla + +(Ver imágenes en el repo original) + +## 🏗️ Tecnología utilizada + +- ReactJs +- MUI +- Node.js +- MongoDB +- Recharts +- Y muchos componentes de código abierto + +## Enlaces útiles + +- ⭐ Dale una estrella si te gusta el proyecto +- 📌 Participa en [Discussions](https://github.com/bluewave-labs/checkmate/discussions) +- 📥 Usa [NewReleases](https://newreleases.io/) para recibir actualizaciones +- 🎥 Mira un [video de instalación](https://www.youtube.com/watch?v=GfFOc0xHIwY) + +## 🤝 Cómo contribuir + +Somos [Alex](http://github.com/ajhollid), [Vishnu](http://github.com/vishnusn77), [Mohadeseh](http://github.com/mohicody), [Gorkem](http://github.com/gorkem-bwl/), [Owaise](http://github.com/Owaiseimdad), [Aryaman](https://github.com/Br0wnHammer) y [Mert](https://github.com/mertssmnoglu). + +Checkmate tiene más de 7000 estrellas y 90+ contribuidores globales. Está marcado por empleados de Google, Microsoft, Intel, Cisco, etc. + +Pasos para contribuir: + +1. ⭐ Dale estrella al repositorio +2. Lee la [guía de contribución](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md) +3. Revisa la [estructura del proyecto](https://docs.checkmate.so/checkmate-2.1/developers-guide/general-project-structure) +4. Aprende más con [DeepWiki](https://deepwiki.com/bluewave-labs/Checkmate) +5. Abre un issue si encuentras errores +6. Mira las tareas con `good-first-issue` +7. Envía un PR con mejoras o nuevas funciones + +![Contribuidores](https://contrib.rocks/image?repo=bluewave-labs/checkmate) + +[![Historial de estrellas](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/bluewave-uptime&Date) + +## 💰 Patrocinadores + +Gracias a [Gitbook](https://gitbook.io/) y [Poeditor](https://poeditor.com/) por sus cuentas gratuitas. + +¿Quieres patrocinar? Escríbenos a hello@bluewavelabs.ca +También puedes [ver funciones patrocinadas](https://checkmate.so/sponsored-features) + +Otros proyectos: + +- [VerifyWise](https://github.com/bluewave-labs/verifywise): Gobernanza de IA +- [DataRoom](https://github.com/bluewave-labs/bluewave-dataroom): Compartir archivos seguro +- [Guidefox](https://github.com/bluewave-labs/guidefox): Onboarding con tours y ayudas visuales \ No newline at end of file From 74fd20cfa80b5015d9b789d53e4fe5e7ea6e5918 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 11:46:59 -0700 Subject: [PATCH 048/226] move checksuperadmin --- server/controllers/authController.js | 163 ++------------------------ server/db/mongo/MongoDB.js | 7 -- server/db/mongo/modules/userModule.js | 22 +++- 3 files changed, 30 insertions(+), 162 deletions(-) diff --git a/server/controllers/authController.js b/server/controllers/authController.js index 208b981fc..63b35e8b0 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -12,160 +12,50 @@ import { } from "../validation/joi.js"; import jwt from "jsonwebtoken"; import { getTokenFromHeaders } from "../utils/utils.js"; -import crypto from "crypto"; -import { asyncHandler, createAuthError, createError } from "../utils/errorUtils.js"; +import { asyncHandler, createError } from "../utils/errorUtils.js"; const SERVICE_NAME = "authController"; class AuthController { - constructor({ db, settingsService, emailService, jobQueue, stringService, logger }) { + constructor({ db, settingsService, emailService, jobQueue, stringService, logger, userService }) { this.db = db; this.settingsService = settingsService; this.emailService = emailService; this.jobQueue = jobQueue; this.stringService = stringService; this.logger = logger; + this.userService = userService; } - /** - * Creates and returns JWT token with an arbitrary payload - * @function - * @param {Object} payload - * @param {Object} appSettings - * @returns {String} - * @throws {Error} - */ - issueToken = (payload, appSettings) => { - const tokenTTL = appSettings?.jwtTTL ?? "2h"; - const tokenSecret = appSettings?.jwtSecret; - const payloadData = payload; - return jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL }); - }; - - /** - * Registers a new user. If the user is the first account, a JWT secret is created. If not, an invite token is required. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.inviteToken - The invite token for registration. - * @property {Object} req.file - The file object for the user's profile image. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the creation of the user, the created user data, and a JWT token. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422). - */ registerUser = asyncHandler( async (req, res, next) => { if (req.body?.email) { req.body.email = req.body.email?.toLowerCase(); } await registrationBodyValidation.validateAsync(req.body); - - // Create a new user - const user = req.body; - // If superAdmin exists, a token should be attached to all further register requests - const superAdminExists = await this.db.checkSuperadmin(req, res); - if (superAdminExists) { - const invitedUser = await this.db.getInviteTokenAndDelete(user.inviteToken); - user.role = invitedUser.role; - user.teamId = invitedUser.teamId; - } else { - // This is the first account, create JWT secret to use if one is not supplied by env - const jwtSecret = crypto.randomBytes(64).toString("hex"); - await this.db.updateAppSettings({ jwtSecret }); - } - - const newUser = await this.db.insertUser({ ...req.body }, req.file); - this.logger.info({ - message: this.stringService.authCreateUser, - service: SERVICE_NAME, - details: newUser._id, - }); - - const userForToken = { ...newUser._doc }; - delete userForToken.profileImage; - delete userForToken.avatarImage; - - const appSettings = await this.settingsService.getSettings(); - - const token = this.issueToken(userForToken, appSettings); - - try { - const html = await this.emailService.buildEmail("welcomeEmailTemplate", { - name: newUser.firstName, - }); - this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error) => { - this.logger.warn({ - message: error.message, - service: SERVICE_NAME, - method: "registerUser", - stack: error.stack, - }); - }); - } catch (error) { - this.logger.warn({ - message: error.message, - service: SERVICE_NAME, - method: "registerUser", - stack: error.stack, - }); - } - + const { user, token } = await this.userService.registerUser(req.body, req.file); res.success({ msg: this.stringService.authCreateUser, - data: { user: newUser, token: token }, + data: { user, token }, }); }, SERVICE_NAME, "registerUser" ); - /** - * Logs in a user by validating the user's credentials and issuing a JWT token. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.email - The email of the user. - * @property {string} req.body.password - The password of the user. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the login of the user, the user data (without password and avatar image), and a JWT token. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422) or the password is incorrect. - */ loginUser = asyncHandler( async (req, res, next) => { if (req.body?.email) { req.body.email = req.body.email?.toLowerCase(); } await loginValidation.validateAsync(req.body); - - const { email, password } = req.body; - - // Check if user exists - const user = await this.db.getUserByEmail(email); - - // Compare password - const match = await user.comparePassword(password); - if (match !== true) { - throw createAuthError(this.stringService.authIncorrectPassword); - } - - // Remove password from user object. Should this be abstracted to DB layer? - const userWithoutPassword = { ...user._doc }; - delete userWithoutPassword.password; - delete userWithoutPassword.avatarImage; - - // Happy path, return token - const appSettings = await this.settingsService.getSettings(); - const token = this.issueToken(userWithoutPassword, appSettings); - // reset avatar image - userWithoutPassword.avatarImage = user.avatarImage; + const { user, token } = await this.userService.loginUser(req.body.email, req.body.password); return res.success({ msg: this.stringService.authLoginUser, data: { - user: userWithoutPassword, - token: token, + user, + token, }, }); }, @@ -173,47 +63,12 @@ class AuthController { "loginUser" ); - /** - * Edits a user's information. If the user wants to change their password, the current password is checked before updating to the new password. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.params - The parameters of the request. - * @property {string} req.params.userId - The ID of the user to be edited. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.password - The current password of the user. - * @property {string} req.body.newPassword - The new password of the user. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the update of the user, and the updated user data. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422), the user is unauthorized (401), or the password is incorrect (403). - */ editUser = asyncHandler( async (req, res, next) => { await editUserBodyValidation.validateAsync(req.body); - // Change Password check - if (req.body.password && req.body.newPassword) { - // Get token from headers - const token = getTokenFromHeaders(req.headers); - // Get email from token - const { jwtSecret } = this.settingsService.getSettings(); - const { email } = jwt.verify(token, jwtSecret); - // Add user email to body for DB operation - req.body.email = email; - // Get user - const user = await this.db.getUserByEmail(email); - // Compare passwords - const match = await user.comparePassword(req.body.password); - // If not a match, throw a 403 - // 403 instead of 401 to avoid triggering axios interceptor - if (!match) { - throw createError(this.stringService.authIncorrectPassword, 403); - } - // If a match, update the password - req.body.password = req.body.newPassword; - } + const updatedUser = await this.userService.editUser(req.body, req.file, req.user); - const updatedUser = await this.db.updateUser({ userId: req?.user?._id, user: req.body, file: req.file }); res.success({ msg: this.stringService.authUpdateUser, data: updatedUser, diff --git a/server/db/mongo/MongoDB.js b/server/db/mongo/MongoDB.js index 6f6a13d64..6eb82384f 100755 --- a/server/db/mongo/MongoDB.js +++ b/server/db/mongo/MongoDB.js @@ -138,13 +138,6 @@ class MongoDB { }); } }; - checkSuperadmin = async (req, res) => { - const superAdmin = await UserModel.findOne({ role: "superadmin" }); - if (superAdmin !== null) { - return true; - } - return false; - }; } export default MongoDB; diff --git a/server/db/mongo/modules/userModule.js b/server/db/mongo/modules/userModule.js index 311a345e1..0c4543499 100755 --- a/server/db/mongo/modules/userModule.js +++ b/server/db/mongo/modules/userModule.js @@ -8,6 +8,14 @@ import ServiceRegistry from "../../../service/serviceRegistry.js"; import StringService from "../../../service/stringService.js"; const SERVICE_NAME = "userModule"; +const checkSuperadmin = async () => { + const superAdmin = await UserModel.findOne({ role: "superadmin" }); + if (superAdmin !== null) { + return true; + } + return false; +}; + /** * Insert a User * @async @@ -238,4 +246,16 @@ const editUserById = async (userId, user) => { } }; -export { insertUser, getUserByEmail, updateUser, deleteUser, deleteTeam, deleteAllOtherUsers, getAllUsers, logoutUser, getUserById, editUserById }; +export { + checkSuperadmin, + insertUser, + getUserByEmail, + updateUser, + deleteUser, + deleteTeam, + deleteAllOtherUsers, + getAllUsers, + logoutUser, + getUserById, + editUserById, +}; From 1fa2dbcf859e0d739a222e496dc4d556fc702024 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 11:47:13 -0700 Subject: [PATCH 049/226] Add user serivce, move register, login, and edit --- server/service/userService.js | 123 ++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 server/service/userService.js diff --git a/server/service/userService.js b/server/service/userService.js new file mode 100644 index 000000000..6707c64dd --- /dev/null +++ b/server/service/userService.js @@ -0,0 +1,123 @@ +const SERVICE_NAME = "userService"; +import { createAuthError, createError } from "../utils/errorUtils.js"; + +class UserService { + static SERVICE_NAME = SERVICE_NAME; + constructor({ db, emailService, settingsService, logger, stringService, jwt }) { + this.db = db; + this.emailService = emailService; + this.settingsService = settingsService; + this.logger = logger; + this.stringService = stringService; + this.jwt = jwt; + } + + issueToken = (payload, appSettings) => { + const tokenTTL = appSettings?.jwtTTL ?? "2h"; + const tokenSecret = appSettings?.jwtSecret; + const payloadData = payload; + return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL }); + }; + + registerUser = async (user, file) => { + // Create a new user + // If superAdmin exists, a token should be attached to all further register requests + const superAdminExists = await this.db.checkSuperadmin(); + if (superAdminExists) { + const invitedUser = await this.db.getInviteTokenAndDelete(user.inviteToken); + user.role = invitedUser.role; + user.teamId = invitedUser.teamId; + } else { + // This is the first account, create JWT secret to use if one is not supplied by env + const jwtSecret = crypto.randomBytes(64).toString("hex"); + await this.db.updateAppSettings({ jwtSecret }); + } + + const newUser = await this.db.insertUser({ ...user }, file); + + this.logger.debug({ + message: "New user created", + service: SERVICE_NAME, + method: "registerUser", + details: newUser._id, + }); + + const userForToken = { ...newUser._doc }; + delete userForToken.profileImage; + delete userForToken.avatarImage; + + const appSettings = await this.settingsService.getSettings(); + + const token = this.issueToken(userForToken, appSettings); + + try { + const html = await this.emailService.buildEmail("welcomeEmailTemplate", { + name: newUser.firstName, + }); + this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error) => { + this.logger.warn({ + message: error.message, + service: SERVICE_NAME, + method: "registerUser", + stack: error.stack, + }); + }); + } catch (error) { + this.logger.warn({ + message: error.message, + service: SERVICE_NAME, + method: "registerUser", + stack: error.stack, + }); + } + + return { user: newUser, token }; + }; + + loginUser = async (email, password) => { + // Check if user exists + const user = await this.db.getUserByEmail(email); + // Compare password + const match = await user.comparePassword(password); + if (match !== true) { + throw createAuthError(this.stringService.authIncorrectPassword); + } + + // Remove password from user object. Should this be abstracted to DB layer? + const userWithoutPassword = { ...user._doc }; + delete userWithoutPassword.password; + delete userWithoutPassword.avatarImage; + + // Happy path, return token + const appSettings = await this.settingsService.getSettings(); + const token = this.issueToken(userWithoutPassword, appSettings); + // reset avatar image + userWithoutPassword.avatarImage = user.avatarImage; + return { user: userWithoutPassword, token }; + }; + + editUser = async (updates, file, currentUser) => { + // Change Password check + if (updates?.password && updates?.newPassword) { + // Get user's email + // Add user email to body for DB operation + updates.email = currentUser.email; + // Get user + const user = await this.db.getUserByEmail(currentUser.email); + // Compare passwords + const match = await user.comparePassword(updates?.password); + // If not a match, throw a 403 + // 403 instead of 401 to avoid triggering axios interceptor + if (!match) { + throw createError(this.stringService.authIncorrectPassword, 403); + } + // If a match, update the password + updates.password = updates.newPassword; + } + + const updatedUser = await this.db.updateUser({ userId: currentUser?._id, user: updates, file: file }); + return updatedUser; + }; +} + +export default UserService; From e76b44fcbaaea00ace4638c3e7f019b08383ae37 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 11:47:21 -0700 Subject: [PATCH 050/226] fix validaiton for editing user --- server/index.js | 13 +++++++++++++ server/validation/joi.js | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/server/index.js b/server/index.js index 8fb8942de..e20858208 100755 --- a/server/index.js +++ b/server/index.js @@ -1,6 +1,7 @@ import path from "path"; import fs from "fs"; import swaggerUi from "swagger-ui-express"; +import jwt from "jsonwebtoken"; import express from "express"; import helmet from "helmet"; @@ -56,6 +57,8 @@ import PulseQueueHelper from "./service/PulseQueue/PulseQueueHelper.js"; import SuperSimpleQueue from "./service/SuperSimpleQueue/SuperSimpleQueue.js"; import SuperSimpleQueueHelper from "./service/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import UserService from "./service/userService.js"; + //Network service and dependencies import NetworkService from "./service/networkService.js"; import axios from "axios"; @@ -176,6 +179,14 @@ const startApp = async () => { }); const redisService = new RedisService({ Redis: IORedis, logger }); + const userService = new UserService({ + db, + emailService, + settingsService, + logger, + stringService, + jwt, + }); // const jobQueueHelper = new JobQueueHelper({ // redisService, @@ -235,6 +246,7 @@ const startApp = async () => { ServiceRegistry.register(NotificationService.SERVICE_NAME, notificationService); ServiceRegistry.register(TranslationService.SERVICE_NAME, translationService); ServiceRegistry.register(RedisService.SERVICE_NAME, redisService); + ServiceRegistry.register(UserService.SERVICE_NAME, userService); await translationService.initialize(); @@ -255,6 +267,7 @@ const startApp = async () => { jobQueue: ServiceRegistry.get(JobQueue.SERVICE_NAME), stringService: ServiceRegistry.get(StringService.SERVICE_NAME), logger: logger, + userService: ServiceRegistry.get(UserService.SERVICE_NAME), }); const monitorController = new MonitorController( diff --git a/server/validation/joi.js b/server/validation/joi.js index dcbf9a606..c141bbd34 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -58,8 +58,8 @@ const registrationBodyValidation = joi.object({ }); const editUserBodyValidation = joi.object({ - firstName: nameValidation.required(), - lastName: nameValidation.required(), + firstName: nameValidation.optional(), + lastName: nameValidation.optional(), profileImage: joi.any(), newPassword: joi.string().min(8).pattern(passwordPattern), password: joi.string().min(8).pattern(passwordPattern), From a08ded4093cacb609510d0c860071a40bebd9ddd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 11:48:47 -0700 Subject: [PATCH 051/226] move checksuperadmin to userservice --- server/controllers/authController.js | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/server/controllers/authController.js b/server/controllers/authController.js index 63b35e8b0..bf38430a7 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -78,18 +78,9 @@ class AuthController { "editUser" ); - /** - * Checks if a superadmin account exists in the database. - * @async - * @param {Object} req - The Express request object. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the existence of a superadmin, and a boolean indicating the existence of a superadmin. - * @throws {Error} If there is an error during the process. - */ checkSuperadminExists = asyncHandler( async (req, res, next) => { - const superAdminExists = await this.db.checkSuperadmin(req, res); + const superAdminExists = await this.userService.checkSuperadminExists(); return res.success({ msg: this.stringService.authAdminExists, data: superAdminExists, @@ -98,17 +89,7 @@ class AuthController { SERVICE_NAME, "checkSuperadminExists" ); - /** - * Requests a recovery token for a user. The user's email is validated and a recovery token is created and sent via email. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.email - The email of the user requesting recovery. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the creation of the recovery token, and the message ID of the sent email. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422). - */ + requestRecovery = asyncHandler( async (req, res, next) => { await recoveryValidation.validateAsync(req.body); From 6e321afa7244cf86bc65705a723011a81e74821d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 12:09:23 -0700 Subject: [PATCH 052/226] add remaining methods to service --- server/controllers/authController.js | 104 +++------------------- server/db/mongo/modules/recoveryModule.js | 17 ++-- server/db/mongo/modules/userModule.js | 2 +- server/service/userService.js | 87 +++++++++++++++++- server/validation/joi.js | 4 +- 5 files changed, 107 insertions(+), 107 deletions(-) diff --git a/server/controllers/authController.js b/server/controllers/authController.js index bf38430a7..da0f2c8f7 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -3,7 +3,7 @@ import { loginValidation, editUserBodyValidation, recoveryValidation, - recoveryTokenValidation, + recoveryTokenBodyValidation, newPasswordValidation, getUserByIdParamValidation, editUserByIdParamValidation, @@ -93,20 +93,8 @@ class AuthController { requestRecovery = asyncHandler( async (req, res, next) => { await recoveryValidation.validateAsync(req.body); - const { email } = req.body; - const user = await this.db.getUserByEmail(email); - const recoveryToken = await this.db.requestRecoveryToken(req, res); - const name = user.firstName; - const { clientHost } = this.settingsService.getSettings(); - const url = `${clientHost}/set-new-password/${recoveryToken.token}`; - - const html = await this.emailService.buildEmail("passwordResetTemplate", { - name, - email, - url, - }); - const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html); - + const email = req?.body?.email; + const msgId = await this.userService.requestRecovery(email); return res.success({ msg: this.stringService.authCreateRecoveryToken, data: msgId, @@ -115,21 +103,11 @@ class AuthController { SERVICE_NAME, "requestRecovery" ); - /** - * Validates a recovery token. The recovery token is validated and if valid, a success message is returned. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.token - The recovery token to be validated. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status and a message indicating the validation of the recovery token. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422). - */ + validateRecovery = asyncHandler( async (req, res, next) => { - await recoveryTokenValidation.validateAsync(req.body); - await this.db.validateRecoveryToken(req, res); + await recoveryTokenBodyValidation.validateAsync(req.body); + await this.userService.validateRecovery(req.body.recoveryToken); return res.success({ msg: this.stringService.authVerifyRecoveryToken, }); @@ -138,24 +116,10 @@ class AuthController { "validateRecovery" ); - /** - * Resets a user's password. The new password is validated and if valid, the user's password is updated in the database and a new JWT token is issued. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.token - The recovery token. - * @property {string} req.body.password - The new password of the user. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the reset of the password, the updated user data (without password and avatar image), and a new JWT token. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422). - */ resetPassword = asyncHandler( async (req, res, next) => { await newPasswordValidation.validateAsync(req.body); - const user = await this.db.resetPassword(req, res); - const appSettings = await this.settingsService.getSettings(); - const token = this.issueToken(user._doc, appSettings); + const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken); return res.success({ msg: this.stringService.authResetPassword, data: { user, token }, @@ -165,55 +129,9 @@ class AuthController { "resetPassword" ); - /** - * Deletes a user and all associated monitors, checks, and alerts. - * - * @param {Object} req - The request object. - * @param {Object} res - The response object. - * @param {Function} next - The next middleware function. - * @returns {Object} The response object with success status and message. - * @throws {Error} If user validation fails or user is not found in the database. - */ deleteUser = asyncHandler( async (req, res, next) => { - const email = req?.user?.email; - if (!email) { - throw new Error("No email in request"); - } - - const teamId = req?.user?.teamId; - const userId = req?.user?._id; - - if (!teamId) { - throw new Error("No team ID in request"); - } - - if (!userId) { - throw new Error("No user ID in request"); - } - - const roles = req.user.role; - if (roles.includes("demo")) { - throw new Error("Demo user cannot be deleted"); - } - - // 1. Find all the monitors associated with the team ID if superadmin - const result = await this.db.getMonitorsByTeamId({ - teamId: teamId, - }); - - if (roles.includes("superadmin")) { - // 2. Remove all jobs, delete checks and alerts - result?.monitors.length > 0 && - (await Promise.all( - result.monitors.map(async (monitor) => { - await this.jobQueue.deleteJob(monitor); - }) - )); - } - // 6. Delete the user by id - await this.db.deleteUser(userId); - + await this.userService.deleteUser(req.user); return res.success({ msg: this.stringService.authDeleteUser, }); @@ -224,7 +142,7 @@ class AuthController { getAllUsers = asyncHandler( async (req, res, next) => { - const allUsers = await this.db.getAllUsers(req, res); + const allUsers = await this.userService.getAllUsers(); return res.success({ msg: this.stringService.authGetAllUsers, data: allUsers, @@ -248,7 +166,7 @@ class AuthController { throw new Error("No roles in request"); } - const user = await this.db.getUserById(roles, userId); + const user = await this.userService.getUserById(roles, userId); return res.success({ msg: "ok", data: user }); }, @@ -274,7 +192,7 @@ class AuthController { await editUserByIdBodyValidation.validateAsync(req.body); } - await this.db.editUserById(userId, user); + await this.userService.editUserById(userId, user); return res.success({ msg: "ok" }); }, SERVICE_NAME, diff --git a/server/db/mongo/modules/recoveryModule.js b/server/db/mongo/modules/recoveryModule.js index 3b39e847c..3ea18cd8a 100755 --- a/server/db/mongo/modules/recoveryModule.js +++ b/server/db/mongo/modules/recoveryModule.js @@ -1,6 +1,5 @@ import UserModel from "../../models/User.js"; import RecoveryToken from "../../models/RecoveryToken.js"; -import crypto from "crypto"; import serviceRegistry from "../../../service/serviceRegistry.js"; import StringService from "../../../service/stringService.js"; @@ -9,17 +8,16 @@ const SERVICE_NAME = "recoveryModule"; /** * Request a recovery token * @async - * @param {Express.Request} req - * @param {Express.Response} res + * @param {string} email * @returns {Promise} * @throws {Error} */ -const requestRecoveryToken = async (req, res) => { +const requestRecoveryToken = async (email) => { try { // Delete any existing tokens - await RecoveryToken.deleteMany({ email: req.body.email }); + await RecoveryToken.deleteMany({ email }); let recoveryToken = new RecoveryToken({ - email: req.body.email, + email, token: crypto.randomBytes(32).toString("hex"), }); await recoveryToken.save(); @@ -31,10 +29,9 @@ const requestRecoveryToken = async (req, res) => { } }; -const validateRecoveryToken = async (req, res) => { +const validateRecoveryToken = async (candidateToken) => { const stringService = serviceRegistry.get(StringService.SERVICE_NAME); try { - const candidateToken = req.body.recoveryToken; const recoveryToken = await RecoveryToken.findOne({ token: candidateToken, }); @@ -50,10 +47,10 @@ const validateRecoveryToken = async (req, res) => { } }; -const resetPassword = async (req, res) => { +const resetPassword = async (password, candidateToken) => { const stringService = serviceRegistry.get(StringService.SERVICE_NAME); try { - const newPassword = req.body.password; + const newPassword = password; // Validate token again const recoveryToken = await validateRecoveryToken(req, res); diff --git a/server/db/mongo/modules/userModule.js b/server/db/mongo/modules/userModule.js index 0c4543499..a1f056157 100755 --- a/server/db/mongo/modules/userModule.js +++ b/server/db/mongo/modules/userModule.js @@ -195,7 +195,7 @@ const deleteAllOtherUsers = async () => { } }; -const getAllUsers = async (req, res) => { +const getAllUsers = async () => { try { const users = await UserModel.find().select("-password").select("-profileImage"); return users; diff --git a/server/service/userService.js b/server/service/userService.js index 6707c64dd..3e1bf285f 100644 --- a/server/service/userService.js +++ b/server/service/userService.js @@ -118,6 +118,91 @@ class UserService { const updatedUser = await this.db.updateUser({ userId: currentUser?._id, user: updates, file: file }); return updatedUser; }; -} + checkSuperadminExists = async () => { + const superAdminExists = await this.db.checkSuperadmin(); + return superAdminExists; + }; + + requestRecovery = async (email) => { + const user = await this.db.getUserByEmail(email); + const recoveryToken = await this.db.requestRecoveryToken(email); + const name = user.firstName; + const { clientHost } = this.settingsService.getSettings(); + const url = `${clientHost}/set-new-password/${recoveryToken.token}`; + + const html = await this.emailService.buildEmail("passwordResetTemplate", { + name, + email, + url, + }); + const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html); + return msgId; + }; + + validateRecovery = async (recoveryToken) => { + await this.db.validateRecoveryToken(recoveryToken); + }; + + resetPassword = async (password, recoveryToken) => { + const user = await this.db.resetPassword(password, recoveryToken); + const appSettings = await this.settingsService.getSettings(); + const token = this.issueToken(user._doc, appSettings); + return { user, token }; + }; + + deleteUser = async (user) => { + const email = user?.email; + if (!email) { + throw new Error("No email in request"); + } + + const teamId = user?.teamId; + const userId = user?._id; + + if (!teamId) { + throw new Error("No team ID in request"); + } + + if (!userId) { + throw new Error("No user ID in request"); + } + + const roles = user?.role; + if (roles.includes("demo")) { + throw new Error("Demo user cannot be deleted"); + } + + // 1. Find all the monitors associated with the team ID if superadmin + const result = await this.db.getMonitorsByTeamId({ + teamId: teamId, + }); + + if (roles.includes("superadmin")) { + // 2. Remove all jobs, delete checks and alerts + result?.monitors.length > 0 && + (await Promise.all( + result.monitors.map(async (monitor) => { + await this.jobQueue.deleteJob(monitor); + }) + )); + } + // 6. Delete the user by id + await this.db.deleteUser(userId); + }; + + getAllUsers = async () => { + const users = await this.db.getAllUsers(); + return users; + }; + + getUserById = async (roles, userId) => { + const user = await this.db.getUserById(roles, userId); + return user; + }; + + editUserById = async (userId, user) => { + await this.db.editUserById(userId, user); + }; +} export default UserService; diff --git a/server/validation/joi.js b/server/validation/joi.js index c141bbd34..41b6fa471 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -73,7 +73,7 @@ const recoveryValidation = joi.object({ .required(), }); -const recoveryTokenValidation = joi.object({ +const recoveryTokenBodyValidation = joi.object({ recoveryToken: joi.string().required(), }); @@ -688,7 +688,7 @@ export { loginValidation, registrationBodyValidation, recoveryValidation, - recoveryTokenValidation, + recoveryTokenBodyValidation, newPasswordValidation, inviteRoleValidation, inviteBodyValidation, From 0b4ec46f7941fa862f0a5a32741e594a509bb5dd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 12:12:22 -0700 Subject: [PATCH 053/226] add updated JSdoc --- server/controllers/authController.js | 283 +++++++++++++++++++++++++-- 1 file changed, 270 insertions(+), 13 deletions(-) diff --git a/server/controllers/authController.js b/server/controllers/authController.js index da0f2c8f7..12d2f77f4 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -10,13 +10,32 @@ import { editUserByIdBodyValidation, editSuperadminUserByIdBodyValidation, } from "../validation/joi.js"; -import jwt from "jsonwebtoken"; -import { getTokenFromHeaders } from "../utils/utils.js"; import { asyncHandler, createError } from "../utils/errorUtils.js"; const SERVICE_NAME = "authController"; +/** + * Authentication Controller + * + * Handles all authentication-related HTTP requests including user registration, + * login, password recovery, and user management operations. + * + * @class AuthController + * @description Manages user authentication and authorization operations + */ class AuthController { + /** + * Creates an instance of AuthController. + * + * @param {Object} dependencies - The dependencies required by the controller + * @param {Object} dependencies.db - Database service for data operations + * @param {Object} dependencies.settingsService - Service for application settings + * @param {Object} dependencies.emailService - Service for email operations + * @param {Object} dependencies.jobQueue - Service for job queue operations + * @param {Object} dependencies.stringService - Service for string/localization + * @param {Object} dependencies.logger - Logger service + * @param {Object} dependencies.userService - User business logic service + */ constructor({ db, settingsService, emailService, jobQueue, stringService, logger, userService }) { this.db = db; this.settingsService = settingsService; @@ -27,8 +46,47 @@ class AuthController { this.userService = userService; } + /** + * Registers a new user in the system. + * + * @async + * @function registerUser + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing user registration data + * @param {string} req.body.firstName - User's first name + * @param {string} req.body.lastName - User's last name + * @param {string} req.body.email - User's email address (will be converted to lowercase) + * @param {string} req.body.password - User's password + * @param {string} [req.body.inviteToken] - Invite token for registration (required if superadmin exists) + * @param {string} [req.body.teamId] - Team ID (auto-assigned if superadmin) + * @param {Array} [req.body.role] - User roles (auto-assigned if superadmin) + * @param {Object} [req.file] - Profile image file uploaded via multer + * @param {Object} res - Express response object + * @returns {Promise} Success response with user data and JWT token + * @throws {Error} 422 - Validation error if request body is invalid + * @throws {Error} 409 - Conflict if user already exists + * @example + * // Register first user (becomes superadmin) + * POST /auth/register + * { + * "firstName": "John", + * "lastName": "Doe", + * "email": "john@example.com", + * "password": "SecurePass123!" + * } + * + * // Register subsequent user (requires invite token) + * POST /auth/register + * { + * "firstName": "Jane", + * "lastName": "Smith", + * "email": "jane@example.com", + * "password": "SecurePass123!", + * "inviteToken": "abc123..." + * } + */ registerUser = asyncHandler( - async (req, res, next) => { + async (req, res) => { if (req.body?.email) { req.body.email = req.body.email?.toLowerCase(); } @@ -43,8 +101,28 @@ class AuthController { "registerUser" ); + /** + * Authenticates a user and returns a JWT token. + * + * @async + * @function loginUser + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing login credentials + * @param {string} req.body.email - User's email address (will be converted to lowercase) + * @param {string} req.body.password - User's password + * @param {Object} res - Express response object + * @returns {Promise} Success response with user data and JWT token + * @throws {Error} 422 - Validation error if request body is invalid + * @throws {Error} 401 - Unauthorized if credentials are incorrect + * @example + * POST /auth/login + * { + * "email": "john@example.com", + * "password": "SecurePass123!" + * } + */ loginUser = asyncHandler( - async (req, res, next) => { + async (req, res) => { if (req.body?.email) { req.body.email = req.body.email?.toLowerCase(); } @@ -63,8 +141,40 @@ class AuthController { "loginUser" ); + /** + * Updates the current user's profile information. + * + * @async + * @function editUser + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing user update data + * @param {string} [req.body.firstName] - Updated first name + * @param {string} [req.body.lastName] - Updated last name + * @param {string} [req.body.password] - Current password (required for password change) + * @param {string} [req.body.newPassword] - New password (required for password change) + * @param {boolean} [req.body.deleteProfileImage] - Flag to delete profile image + * @param {Object} [req.file] - New profile image file + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {Object} res - Express response object + * @returns {Promise} Success response with updated user data + * @throws {Error} 422 - Validation error if request body is invalid + * @throws {Error} 403 - Forbidden if current password is incorrect + * @example + * PUT /auth/user + * { + * "firstName": "John Updated", + * "lastName": "Doe Updated" + * } + * + * // Change password + * PUT /auth/user + * { + * "password": "OldPass123!", + * "newPassword": "NewPass123!" + * } + */ editUser = asyncHandler( - async (req, res, next) => { + async (req, res) => { await editUserBodyValidation.validateAsync(req.body); const updatedUser = await this.userService.editUser(req.body, req.file, req.user); @@ -78,8 +188,20 @@ class AuthController { "editUser" ); + /** + * Checks if a superadmin account exists in the system. + * + * @async + * @function checkSuperadminExists + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {Promise} Success response with boolean indicating superadmin existence + * @example + * GET /auth/users/superadmin + * // Response: { "data": true } or { "data": false } + */ checkSuperadminExists = asyncHandler( - async (req, res, next) => { + async (req, res) => { const superAdminExists = await this.userService.checkSuperadminExists(); return res.success({ msg: this.stringService.authAdminExists, @@ -90,8 +212,26 @@ class AuthController { "checkSuperadminExists" ); + /** + * Initiates password recovery process by sending a recovery email. + * + * @async + * @function requestRecovery + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing email + * @param {string} req.body.email - Email address for password recovery + * @param {Object} res - Express response object + * @returns {Promise} Success response with message ID + * @throws {Error} 422 - Validation error if email is invalid + * @throws {Error} 404 - Not found if user doesn't exist + * @example + * POST /auth/recovery/request + * { + * "email": "john@example.com" + * } + */ requestRecovery = asyncHandler( - async (req, res, next) => { + async (req, res) => { await recoveryValidation.validateAsync(req.body); const email = req?.body?.email; const msgId = await this.userService.requestRecovery(email); @@ -104,8 +244,26 @@ class AuthController { "requestRecovery" ); + /** + * Validates a password recovery token. + * + * @async + * @function validateRecovery + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing recovery token + * @param {string} req.body.recoveryToken - Recovery token to validate + * @param {Object} res - Express response object + * @returns {Promise} Success response if token is valid + * @throws {Error} 422 - Validation error if token format is invalid + * @throws {Error} 400 - Bad request if token is invalid or expired + * @example + * POST /auth/recovery/validate + * { + * "recoveryToken": "abc123..." + * } + */ validateRecovery = asyncHandler( - async (req, res, next) => { + async (req, res) => { await recoveryTokenBodyValidation.validateAsync(req.body); await this.userService.validateRecovery(req.body.recoveryToken); return res.success({ @@ -116,8 +274,28 @@ class AuthController { "validateRecovery" ); + /** + * Resets user password using a valid recovery token. + * + * @async + * @function resetPassword + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing new password and recovery token + * @param {string} req.body.password - New password + * @param {string} req.body.recoveryToken - Valid recovery token + * @param {Object} res - Express response object + * @returns {Promise} Success response with user data and JWT token + * @throws {Error} 422 - Validation error if password format is invalid + * @throws {Error} 400 - Bad request if token is invalid or expired + * @example + * POST /auth/recovery/reset + * { + * "password": "NewSecurePass123!", + * "recoveryToken": "abc123..." + * } + */ resetPassword = asyncHandler( - async (req, res, next) => { + async (req, res) => { await newPasswordValidation.validateAsync(req.body); const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken); return res.success({ @@ -129,8 +307,27 @@ class AuthController { "resetPassword" ); + /** + * Deletes the current user's account and associated data. + * + * @async + * @function deleteUser + * @param {Object} req - Express request object + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user._id - User ID + * @param {string} req.user.email - User email + * @param {string} req.user.teamId - User's team ID + * @param {Array} req.user.role - User roles + * @param {Object} res - Express response object + * @returns {Promise} Success response confirming user deletion + * @throws {Error} 400 - Bad request if user is demo user + * @throws {Error} 404 - Not found if user doesn't exist + * @example + * DELETE /auth/user + * // Requires JWT authentication + */ deleteUser = asyncHandler( - async (req, res, next) => { + async (req, res) => { await this.userService.deleteUser(req.user); return res.success({ msg: this.stringService.authDeleteUser, @@ -140,8 +337,21 @@ class AuthController { "deleteUser" ); + /** + * Retrieves all users in the system (admin/superadmin only). + * + * @async + * @function getAllUsers + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {Promise} Success response with array of users + * @throws {Error} 403 - Forbidden if user doesn't have admin/superadmin role + * @example + * GET /auth/users + * // Requires JWT authentication with admin/superadmin role + */ getAllUsers = asyncHandler( - async (req, res, next) => { + async (req, res) => { const allUsers = await this.userService.getAllUsers(); return res.success({ msg: this.stringService.authGetAllUsers, @@ -152,8 +362,27 @@ class AuthController { "getAllUsers" ); + /** + * Retrieves a specific user by ID (superadmin only). + * + * @async + * @function getUserById + * @param {Object} req - Express request object + * @param {Object} req.params - URL parameters + * @param {string} req.params.userId - ID of the user to retrieve + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {Array} req.user.role - Current user's roles + * @param {Object} res - Express response object + * @returns {Promise} Success response with user data + * @throws {Error} 422 - Validation error if userId is invalid + * @throws {Error} 403 - Forbidden if user doesn't have superadmin role + * @throws {Error} 404 - Not found if user doesn't exist + * @example + * GET /auth/users/507f1f77bcf86cd799439011 + * // Requires JWT authentication with superadmin role + */ getUserById = asyncHandler( - async (req, res, next) => { + async (req, res) => { await getUserByIdParamValidation.validateAsync(req.params); const userId = req?.params?.userId; const roles = req?.user?.role; @@ -174,8 +403,36 @@ class AuthController { "getUserById" ); + /** + * Updates a specific user by ID (superadmin only). + * + * @async + * @function editUserById + * @param {Object} req - Express request object + * @param {Object} req.params - URL parameters + * @param {string} req.params.userId - ID of the user to update + * @param {Object} req.body - Request body containing user update data + * @param {string} [req.body.firstName] - Updated first name + * @param {string} [req.body.lastName] - Updated last name + * @param {Array} [req.body.role] - Updated user roles + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user._id - Current user's ID + * @param {Array} req.user.role - Current user's roles + * @param {Object} res - Express response object + * @returns {Promise} Success response confirming user update + * @throws {Error} 422 - Validation error if parameters or body are invalid + * @throws {Error} 403 - Forbidden if user doesn't have superadmin role + * @throws {Error} 404 - Not found if user doesn't exist + * @example + * PUT /auth/users/507f1f77bcf86cd799439011 + * { + * "firstName": "Updated Name", + * "role": ["admin"] + * } + * // Requires JWT authentication with superadmin role + */ editUserById = asyncHandler( - async (req, res, next) => { + async (req, res) => { const roles = req?.user?.role; if (!roles.includes("superadmin")) { throw createError("Unauthorized", 403); From 6da52adc7444e9970e60a8b2315d206cc7f228fe Mon Sep 17 00:00:00 2001 From: "Gorkem Cetin (BWL)" <167266851+gorkem-bwl@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:41:44 -0400 Subject: [PATCH 054/226] Trying to fix anchors --- README.md | 45 ++++++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4be16fef0..d584d832d 100644 --- a/README.md +++ b/README.md @@ -26,41 +26,40 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul ## 📚 Table of contents -- [📦 Demo](#-demo) -- [🔗 User's guide](#-users-guide) -- [🛠️ Installation](#️-installation) -- [🚀 Deploying Checkmate with Helm](#-deploying-checkmate-with-helm) -- [🏁 Translations](#-translations) -- [🚀 Performance](#-performance) -- [💚 Questions & Ideas](#-questions--ideas) -- [🧩 Features](#-features) -- [🏗️ Screenshots](#-screenshots) -- [🏗️ Tech stack](#-tech-stack) +- [📦 Demo](#demo) +- [🔗 User's guide](#users-guide) +- [🛠️ Installation](#installation) +- [🏁 Translations](#translations) +- [🚀 Performance](#performance) +- [💚 Questions & Ideas](#questions--ideas) +- [🧩 Features](#features) +- [🏗️ Screenshots](#screenshots) +- [🏗️ Tech stack](#tech-stack) - [🔗 A few links](#a-few-links) -- [🤝 Contributing](#-contributing) -- [💰 Our sponsors](#-our-sponsors) +- [🤝 Contributing](#contributing) +- [💰 Our sponsors](#our-sponsors) -## 📦 Demo +## Demo You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is uptimedemo@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel). -## 🔗 User's guide +## User's guide Usage instructions can be found [here](https://docs.checkmate.so/checkmate-2.1). It's still WIP and some of the information there might be outdated as we continuously add features weekly. Rest assured, we are doing our best! :) -## 🛠️ Installation +## Installation See installation instructions in [Checkmate documentation portal](https://docs.checkmate.so/checkmate-2.1/users-guide/quickstart). Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions. -## 🏁 Translations +## Translations If you would like to use Checkmate in your language, please [go to this page](https://poeditor.com/join/project/lRUoGZFCsJ) and register for the language you would like to translate Checkmate to. -## 🚀 Performance +## Performance Thanks to extensive optimizations, Checkmate operates with an exceptionally small memory footprint, requiring minimal memory and CPU resources. Here’s the memory usage of a Node.js instance running on a server that monitors 323 servers every minute: @@ -70,7 +69,7 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb ![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) -## 💚 Questions & Ideas +## Questions & Ideas If you have any questions, suggestions or comments, you have several options: @@ -80,7 +79,7 @@ If you have any questions, suggestions or comments, you have several options: Feel free to ask questions or share your ideas - we'd love to hear from you! -## 🧩 Features +## Features - Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5) - Website monitoring @@ -103,7 +102,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you! - Network monitoring - ..and a few more features -## 🏗️ Screenshots +## Screenshots

image @@ -120,7 +119,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you! -## 🏗️ Tech stack +## Tech stack - [ReactJs](https://react.dev/) - [MUI (React framework)](https://mui.com/) @@ -136,7 +135,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you! - Need a ping when there's a new release? Use [Newreleases](https://newreleases.io/), a free service to track releases. - Watch a Checkmate [installation and usage video](https://www.youtube.com/watch?v=GfFOc0xHIwY) -## 🤝 Contributing +## Contributing We are [Alex](http://github.com/ajhollid) (team lead), [Vishnu](http://github.com/vishnusn77), [Mohadeseh](http://github.com/mohicody), [Gorkem](http://github.com/gorkem-bwl/), [Owaise](http://github.com/Owaiseimdad), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers. @@ -160,7 +159,7 @@ Here's how you can contribute: [![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/bluewave-uptime&Date) -## 💰 Our sponsors +## Our sponsors Thanks to [Gitbook](https://gitbook.io/) for giving us a free tier for their documentation platform, and [Poeditor](https://poeditor.com/) providing us a free account to use their i18n services. If you would like to sponsor Checkmate, please send an email to hello@bluewavelabs.ca From 06fa79e09da7933dc4c77302f069c4dd646589f3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 13:03:57 -0700 Subject: [PATCH 055/226] refactor service dir structure --- server/db/mongo/modules/inviteModule.js | 4 +- server/db/mongo/modules/monitorModule.js | 4 +- server/db/mongo/modules/recoveryModule.js | 7 +- server/db/mongo/modules/statusPageModule.js | 4 +- server/db/mongo/modules/userModule.js | 4 +- server/index.js | 36 +-- server/middleware/handleErrors.js | 4 +- server/middleware/isAllowed.js | 6 +- server/middleware/verifyJWT.js | 6 +- server/middleware/verifyOwnership.js | 4 +- server/service/business/userService.js | 208 ++++++++++++++++++ server/service/{ => data}/redisService.js | 0 .../{ => infrastructure}/JobQueue/JobQueue.js | 0 .../JobQueue/JobQueueHelper.js | 0 .../PulseQueue/PulseQueue.js | 0 .../PulseQueue/PulseQueueHelper.js | 0 .../SuperSimpleQueue/SuperSimpleQueue.js | 0 .../SuperSimpleQueueHelper.js | 0 .../{ => infrastructure}/bufferService.js | 0 .../{ => infrastructure}/emailService.js | 0 .../{ => infrastructure}/networkService.js | 0 .../notificationService.js | 0 .../{ => infrastructure}/notificationUtils.js | 0 .../{ => infrastructure}/statusService.js | 4 +- .../service/{ => system}/serviceRegistry.js | 2 +- .../service/{ => system}/settingsService.js | 0 server/service/{ => system}/stringService.js | 0 .../{ => system}/translationService.js | 0 28 files changed, 251 insertions(+), 42 deletions(-) create mode 100644 server/service/business/userService.js rename server/service/{ => data}/redisService.js (100%) rename server/service/{ => infrastructure}/JobQueue/JobQueue.js (100%) rename server/service/{ => infrastructure}/JobQueue/JobQueueHelper.js (100%) rename server/service/{ => infrastructure}/PulseQueue/PulseQueue.js (100%) rename server/service/{ => infrastructure}/PulseQueue/PulseQueueHelper.js (100%) rename server/service/{ => infrastructure}/SuperSimpleQueue/SuperSimpleQueue.js (100%) rename server/service/{ => infrastructure}/SuperSimpleQueue/SuperSimpleQueueHelper.js (100%) rename server/service/{ => infrastructure}/bufferService.js (100%) rename server/service/{ => infrastructure}/emailService.js (100%) rename server/service/{ => infrastructure}/networkService.js (100%) rename server/service/{ => infrastructure}/notificationService.js (100%) rename server/service/{ => infrastructure}/notificationUtils.js (100%) rename server/service/{ => infrastructure}/statusService.js (98%) rename server/service/{ => system}/serviceRegistry.js (93%) rename server/service/{ => system}/settingsService.js (100%) rename server/service/{ => system}/stringService.js (100%) rename server/service/{ => system}/translationService.js (100%) diff --git a/server/db/mongo/modules/inviteModule.js b/server/db/mongo/modules/inviteModule.js index f5c960697..89d8ad8d7 100755 --- a/server/db/mongo/modules/inviteModule.js +++ b/server/db/mongo/modules/inviteModule.js @@ -1,7 +1,7 @@ import InviteToken from "../../models/InviteToken.js"; import crypto from "crypto"; -import ServiceRegistry from "../../../service/serviceRegistry.js"; -import StringService from "../../../service/stringService.js"; +import ServiceRegistry from "../../../service/system/serviceRegistry.js"; +import StringService from "../../../service/system/stringService.js"; const SERVICE_NAME = "inviteModule"; /** diff --git a/server/db/mongo/modules/monitorModule.js b/server/db/mongo/modules/monitorModule.js index 3192b711c..18cb3c63f 100755 --- a/server/db/mongo/modules/monitorModule.js +++ b/server/db/mongo/modules/monitorModule.js @@ -4,8 +4,8 @@ import Check from "../../models/Check.js"; import PageSpeedCheck from "../../models/PageSpeedCheck.js"; import HardwareCheck from "../../models/HardwareCheck.js"; import { NormalizeData, NormalizeDataUptimeDetails } from "../../../utils/dataUtils.js"; -import ServiceRegistry from "../../../service/serviceRegistry.js"; -import StringService from "../../../service/stringService.js"; +import ServiceRegistry from "../../../service/system/serviceRegistry.js"; +import StringService from "../../../service/system/stringService.js"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; diff --git a/server/db/mongo/modules/recoveryModule.js b/server/db/mongo/modules/recoveryModule.js index 3ea18cd8a..2678f49c4 100755 --- a/server/db/mongo/modules/recoveryModule.js +++ b/server/db/mongo/modules/recoveryModule.js @@ -1,7 +1,8 @@ import UserModel from "../../models/User.js"; import RecoveryToken from "../../models/RecoveryToken.js"; -import serviceRegistry from "../../../service/serviceRegistry.js"; -import StringService from "../../../service/stringService.js"; +import crypto from "crypto"; +import serviceRegistry from "../../../service/system/serviceRegistry.js"; +import StringService from "../../../service/system/stringService.js"; const SERVICE_NAME = "recoveryModule"; @@ -53,7 +54,7 @@ const resetPassword = async (password, candidateToken) => { const newPassword = password; // Validate token again - const recoveryToken = await validateRecoveryToken(req, res); + const recoveryToken = await validateRecoveryToken(candidateToken); const user = await UserModel.findOne({ email: recoveryToken.email }); if (user === null) { diff --git a/server/db/mongo/modules/statusPageModule.js b/server/db/mongo/modules/statusPageModule.js index c126d913f..757fdd266 100755 --- a/server/db/mongo/modules/statusPageModule.js +++ b/server/db/mongo/modules/statusPageModule.js @@ -1,7 +1,7 @@ import StatusPage from "../../models/StatusPage.js"; import { NormalizeData } from "../../../utils/dataUtils.js"; -import ServiceRegistry from "../../../service/serviceRegistry.js"; -import StringService from "../../../service/stringService.js"; +import ServiceRegistry from "../../../service/system/serviceRegistry.js"; +import StringService from "../../../service/system/stringService.js"; const SERVICE_NAME = "statusPageModule"; diff --git a/server/db/mongo/modules/userModule.js b/server/db/mongo/modules/userModule.js index a1f056157..974f2d2ed 100755 --- a/server/db/mongo/modules/userModule.js +++ b/server/db/mongo/modules/userModule.js @@ -4,8 +4,8 @@ import { GenerateAvatarImage } from "../../../utils/imageProcessing.js"; const DUPLICATE_KEY_CODE = 11000; // MongoDB error code for duplicate key import { ParseBoolean } from "../../../utils/utils.js"; -import ServiceRegistry from "../../../service/serviceRegistry.js"; -import StringService from "../../../service/stringService.js"; +import ServiceRegistry from "../../../service/system/serviceRegistry.js"; +import StringService from "../../../service/system/stringService.js"; const SERVICE_NAME = "userModule"; const checkSuperadmin = async () => { diff --git a/server/index.js b/server/index.js index e20858208..996eae4d1 100755 --- a/server/index.js +++ b/server/index.js @@ -47,58 +47,58 @@ import DiagnosticRoutes from "./routes/diagnosticRoute.js"; import DiagnosticController from "./controllers/diagnosticController.js"; //JobQueue service and dependencies -import JobQueue from "./service/JobQueue/JobQueue.js"; -import JobQueueHelper from "./service/JobQueue/JobQueueHelper.js"; +import JobQueue from "./service/infrastructure/JobQueue/JobQueue.js"; +import JobQueueHelper from "./service/infrastructure/JobQueue/JobQueueHelper.js"; import { Queue, Worker } from "bullmq"; -import PulseQueue from "./service/PulseQueue/PulseQueue.js"; -import PulseQueueHelper from "./service/PulseQueue/PulseQueueHelper.js"; +import PulseQueue from "./service/infrastructure/PulseQueue/PulseQueue.js"; +import PulseQueueHelper from "./service/infrastructure/PulseQueue/PulseQueueHelper.js"; -import SuperSimpleQueue from "./service/SuperSimpleQueue/SuperSimpleQueue.js"; -import SuperSimpleQueueHelper from "./service/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import SuperSimpleQueue from "./service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; +import SuperSimpleQueueHelper from "./service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; -import UserService from "./service/userService.js"; +import UserService from "./service/business/userService.js"; //Network service and dependencies -import NetworkService from "./service/networkService.js"; +import NetworkService from "./service/infrastructure/networkService.js"; import axios from "axios"; import ping from "ping"; import http from "http"; import Docker from "dockerode"; import net from "net"; // Email service and dependencies -import EmailService from "./service/emailService.js"; +import EmailService from "./service/infrastructure/emailService.js"; import nodemailer from "nodemailer"; import pkg from "handlebars"; const { compile } = pkg; import mjml2html from "mjml"; // Settings Service and dependencies -import SettingsService from "./service/settingsService.js"; +import SettingsService from "./service/system/settingsService.js"; import AppSettings from "./db/models/AppSettings.js"; // Status Service and dependencies -import StatusService from "./service/statusService.js"; +import StatusService from "./service/infrastructure/statusService.js"; // Notification Service and dependencies -import NotificationService from "./service/notificationService.js"; -import NotificationUtils from "./service/notificationUtils.js"; +import NotificationService from "./service/infrastructure/notificationService.js"; +import NotificationUtils from "./service/infrastructure/notificationUtils.js"; // Buffer Service and dependencies -import BufferService from "./service/bufferService.js"; +import BufferService from "./service/infrastructure/bufferService.js"; // Service Registry -import ServiceRegistry from "./service/serviceRegistry.js"; +import ServiceRegistry from "./service/system/serviceRegistry.js"; import MongoDB from "./db/mongo/MongoDB.js"; // Redis Service and dependencies import IORedis from "ioredis"; -import RedisService from "./service/redisService.js"; +import RedisService from "./service/data/redisService.js"; -import TranslationService from "./service/translationService.js"; +import TranslationService from "./service/system/translationService.js"; import languageMiddleware from "./middleware/languageMiddleware.js"; -import StringService from "./service/stringService.js"; +import StringService from "./service/system/stringService.js"; const SERVICE_NAME = "Server"; const SHUTDOWN_TIMEOUT = 1000; diff --git a/server/middleware/handleErrors.js b/server/middleware/handleErrors.js index f04df1a08..1efce6f67 100755 --- a/server/middleware/handleErrors.js +++ b/server/middleware/handleErrors.js @@ -1,6 +1,6 @@ import logger from "../utils/logger.js"; -import ServiceRegistry from "../service/serviceRegistry.js"; -import StringService from "../service/stringService.js"; +import ServiceRegistry from "../service/system/serviceRegistry.js"; +import StringService from "../service/system/stringService.js"; const handleErrors = (error, req, res, next) => { console.log("ERROR", error); diff --git a/server/middleware/isAllowed.js b/server/middleware/isAllowed.js index 27fb875ed..5d61974be 100755 --- a/server/middleware/isAllowed.js +++ b/server/middleware/isAllowed.js @@ -1,9 +1,9 @@ import jwt from "jsonwebtoken"; const TOKEN_PREFIX = "Bearer "; const SERVICE_NAME = "allowedRoles"; -import ServiceRegistry from "../service/serviceRegistry.js"; -import StringService from "../service/stringService.js"; -import SettingsService from "../service/settingsService.js"; +import ServiceRegistry from "../service/system/serviceRegistry.js"; +import StringService from "../service/system/stringService.js"; +import SettingsService from "../service/system/settingsService.js"; const isAllowed = (allowedRoles) => { return (req, res, next) => { diff --git a/server/middleware/verifyJWT.js b/server/middleware/verifyJWT.js index 46611c0dd..d01db0a97 100755 --- a/server/middleware/verifyJWT.js +++ b/server/middleware/verifyJWT.js @@ -1,7 +1,7 @@ import jwt from "jsonwebtoken"; -import ServiceRegistry from "../service/serviceRegistry.js"; -import SettingsService from "../service/settingsService.js"; -import StringService from "../service/stringService.js"; +import ServiceRegistry from "../service/system/serviceRegistry.js"; +import SettingsService from "../service/system/settingsService.js"; +import StringService from "../service/system/stringService.js"; const SERVICE_NAME = "verifyJWT"; const TOKEN_PREFIX = "Bearer "; diff --git a/server/middleware/verifyOwnership.js b/server/middleware/verifyOwnership.js index 2040d54ee..890c40b24 100755 --- a/server/middleware/verifyOwnership.js +++ b/server/middleware/verifyOwnership.js @@ -1,6 +1,6 @@ import logger from "../utils/logger.js"; -import ServiceRegistry from "../service/serviceRegistry.js"; -import StringService from "../service/stringService.js"; +import ServiceRegistry from "../service/system/serviceRegistry.js"; +import StringService from "../service/system/stringService.js"; import { ObjectId } from "mongodb"; const SERVICE_NAME = "verifyOwnership"; diff --git a/server/service/business/userService.js b/server/service/business/userService.js new file mode 100644 index 000000000..90906552d --- /dev/null +++ b/server/service/business/userService.js @@ -0,0 +1,208 @@ +const SERVICE_NAME = "userService"; +import { createAuthError, createError } from "../../utils/errorUtils.js"; + +class UserService { + static SERVICE_NAME = SERVICE_NAME; + constructor({ db, emailService, settingsService, logger, stringService, jwt }) { + this.db = db; + this.emailService = emailService; + this.settingsService = settingsService; + this.logger = logger; + this.stringService = stringService; + this.jwt = jwt; + } + + issueToken = (payload, appSettings) => { + const tokenTTL = appSettings?.jwtTTL ?? "2h"; + const tokenSecret = appSettings?.jwtSecret; + const payloadData = payload; + return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL }); + }; + + registerUser = async (user, file) => { + // Create a new user + // If superAdmin exists, a token should be attached to all further register requests + const superAdminExists = await this.db.checkSuperadmin(); + if (superAdminExists) { + const invitedUser = await this.db.getInviteTokenAndDelete(user.inviteToken); + user.role = invitedUser.role; + user.teamId = invitedUser.teamId; + } else { + // This is the first account, create JWT secret to use if one is not supplied by env + const jwtSecret = crypto.randomBytes(64).toString("hex"); + await this.db.updateAppSettings({ jwtSecret }); + } + + const newUser = await this.db.insertUser({ ...user }, file); + + this.logger.debug({ + message: "New user created", + service: SERVICE_NAME, + method: "registerUser", + details: newUser._id, + }); + + const userForToken = { ...newUser._doc }; + delete userForToken.profileImage; + delete userForToken.avatarImage; + + const appSettings = await this.settingsService.getSettings(); + + const token = this.issueToken(userForToken, appSettings); + + try { + const html = await this.emailService.buildEmail("welcomeEmailTemplate", { + name: newUser.firstName, + }); + this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error) => { + this.logger.warn({ + message: error.message, + service: SERVICE_NAME, + method: "registerUser", + stack: error.stack, + }); + }); + } catch (error) { + this.logger.warn({ + message: error.message, + service: SERVICE_NAME, + method: "registerUser", + stack: error.stack, + }); + } + + return { user: newUser, token }; + }; + + loginUser = async (email, password) => { + // Check if user exists + const user = await this.db.getUserByEmail(email); + // Compare password + const match = await user.comparePassword(password); + if (match !== true) { + throw createAuthError(this.stringService.authIncorrectPassword); + } + + // Remove password from user object. Should this be abstracted to DB layer? + const userWithoutPassword = { ...user._doc }; + delete userWithoutPassword.password; + delete userWithoutPassword.avatarImage; + + // Happy path, return token + const appSettings = await this.settingsService.getSettings(); + const token = this.issueToken(userWithoutPassword, appSettings); + // reset avatar image + userWithoutPassword.avatarImage = user.avatarImage; + return { user: userWithoutPassword, token }; + }; + + editUser = async (updates, file, currentUser) => { + // Change Password check + if (updates?.password && updates?.newPassword) { + // Get user's email + // Add user email to body for DB operation + updates.email = currentUser.email; + // Get user + const user = await this.db.getUserByEmail(currentUser.email); + // Compare passwords + const match = await user.comparePassword(updates?.password); + // If not a match, throw a 403 + // 403 instead of 401 to avoid triggering axios interceptor + if (!match) { + throw createError(this.stringService.authIncorrectPassword, 403); + } + // If a match, update the password + updates.password = updates.newPassword; + } + + const updatedUser = await this.db.updateUser({ userId: currentUser?._id, user: updates, file: file }); + return updatedUser; + }; + + checkSuperadminExists = async () => { + const superAdminExists = await this.db.checkSuperadmin(); + return superAdminExists; + }; + + requestRecovery = async (email) => { + const user = await this.db.getUserByEmail(email); + const recoveryToken = await this.db.requestRecoveryToken(email); + const name = user.firstName; + const { clientHost } = this.settingsService.getSettings(); + const url = `${clientHost}/set-new-password/${recoveryToken.token}`; + + const html = await this.emailService.buildEmail("passwordResetTemplate", { + name, + email, + url, + }); + const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html); + return msgId; + }; + + validateRecovery = async (recoveryToken) => { + await this.db.validateRecoveryToken(recoveryToken); + }; + + resetPassword = async (password, recoveryToken) => { + const user = await this.db.resetPassword(password, recoveryToken); + const appSettings = await this.settingsService.getSettings(); + const token = this.issueToken(user._doc, appSettings); + return { user, token }; + }; + + deleteUser = async (user) => { + const email = user?.email; + if (!email) { + throw new Error("No email in request"); + } + + const teamId = user?.teamId; + const userId = user?._id; + + if (!teamId) { + throw new Error("No team ID in request"); + } + + if (!userId) { + throw new Error("No user ID in request"); + } + + const roles = user?.role; + if (roles.includes("demo")) { + throw new Error("Demo user cannot be deleted"); + } + + // 1. Find all the monitors associated with the team ID if superadmin + const result = await this.db.getMonitorsByTeamId({ + teamId: teamId, + }); + + if (roles.includes("superadmin")) { + // 2. Remove all jobs, delete checks and alerts + result?.monitors.length > 0 && + (await Promise.all( + result.monitors.map(async (monitor) => { + await this.jobQueue.deleteJob(monitor); + }) + )); + } + // 6. Delete the user by id + await this.db.deleteUser(userId); + }; + + getAllUsers = async () => { + const users = await this.db.getAllUsers(); + return users; + }; + + getUserById = async (roles, userId) => { + const user = await this.db.getUserById(roles, userId); + return user; + }; + + editUserById = async (userId, user) => { + await this.db.editUserById(userId, user); + }; +} +export default UserService; diff --git a/server/service/redisService.js b/server/service/data/redisService.js similarity index 100% rename from server/service/redisService.js rename to server/service/data/redisService.js diff --git a/server/service/JobQueue/JobQueue.js b/server/service/infrastructure/JobQueue/JobQueue.js similarity index 100% rename from server/service/JobQueue/JobQueue.js rename to server/service/infrastructure/JobQueue/JobQueue.js diff --git a/server/service/JobQueue/JobQueueHelper.js b/server/service/infrastructure/JobQueue/JobQueueHelper.js similarity index 100% rename from server/service/JobQueue/JobQueueHelper.js rename to server/service/infrastructure/JobQueue/JobQueueHelper.js diff --git a/server/service/PulseQueue/PulseQueue.js b/server/service/infrastructure/PulseQueue/PulseQueue.js similarity index 100% rename from server/service/PulseQueue/PulseQueue.js rename to server/service/infrastructure/PulseQueue/PulseQueue.js diff --git a/server/service/PulseQueue/PulseQueueHelper.js b/server/service/infrastructure/PulseQueue/PulseQueueHelper.js similarity index 100% rename from server/service/PulseQueue/PulseQueueHelper.js rename to server/service/infrastructure/PulseQueue/PulseQueueHelper.js diff --git a/server/service/SuperSimpleQueue/SuperSimpleQueue.js b/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js similarity index 100% rename from server/service/SuperSimpleQueue/SuperSimpleQueue.js rename to server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js diff --git a/server/service/SuperSimpleQueue/SuperSimpleQueueHelper.js b/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js similarity index 100% rename from server/service/SuperSimpleQueue/SuperSimpleQueueHelper.js rename to server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js diff --git a/server/service/bufferService.js b/server/service/infrastructure/bufferService.js similarity index 100% rename from server/service/bufferService.js rename to server/service/infrastructure/bufferService.js diff --git a/server/service/emailService.js b/server/service/infrastructure/emailService.js similarity index 100% rename from server/service/emailService.js rename to server/service/infrastructure/emailService.js diff --git a/server/service/networkService.js b/server/service/infrastructure/networkService.js similarity index 100% rename from server/service/networkService.js rename to server/service/infrastructure/networkService.js diff --git a/server/service/notificationService.js b/server/service/infrastructure/notificationService.js similarity index 100% rename from server/service/notificationService.js rename to server/service/infrastructure/notificationService.js diff --git a/server/service/notificationUtils.js b/server/service/infrastructure/notificationUtils.js similarity index 100% rename from server/service/notificationUtils.js rename to server/service/infrastructure/notificationUtils.js diff --git a/server/service/statusService.js b/server/service/infrastructure/statusService.js similarity index 98% rename from server/service/statusService.js rename to server/service/infrastructure/statusService.js index 1982bbb30..1c91a407c 100755 --- a/server/service/statusService.js +++ b/server/service/infrastructure/statusService.js @@ -1,5 +1,5 @@ -import MonitorStats from "../db/models/MonitorStats.js"; -import { safelyParseFloat } from "../utils/dataUtils.js"; +import MonitorStats from "../../db/models/MonitorStats.js"; +import { safelyParseFloat } from "../../utils/dataUtils.js"; const SERVICE_NAME = "StatusService"; class StatusService { diff --git a/server/service/serviceRegistry.js b/server/service/system/serviceRegistry.js similarity index 93% rename from server/service/serviceRegistry.js rename to server/service/system/serviceRegistry.js index 69a3b8be5..5f8b3bfe6 100755 --- a/server/service/serviceRegistry.js +++ b/server/service/system/serviceRegistry.js @@ -1,5 +1,5 @@ const SERVICE_NAME = "ServiceRegistry"; -import logger from "../utils/logger.js"; +import logger from "../../utils/logger.js"; class ServiceRegistry { static SERVICE_NAME = SERVICE_NAME; constructor() { diff --git a/server/service/settingsService.js b/server/service/system/settingsService.js similarity index 100% rename from server/service/settingsService.js rename to server/service/system/settingsService.js diff --git a/server/service/stringService.js b/server/service/system/stringService.js similarity index 100% rename from server/service/stringService.js rename to server/service/system/stringService.js diff --git a/server/service/translationService.js b/server/service/system/translationService.js similarity index 100% rename from server/service/translationService.js rename to server/service/system/translationService.js From f58d05f1b36255ab043d989f484546e17fb35e85 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 13:04:26 -0700 Subject: [PATCH 056/226] format --- .../SuperSimpleQueueHelper.js | 6 +- server/templates/passwordReset.mjml | 90 +++++++++------ server/templates/serverIsDown.mjml | 108 +++++++++++------- server/templates/welcomeEmail.mjml | 84 ++++++++------ 4 files changed, 169 insertions(+), 119 deletions(-) diff --git a/server/service/SuperSimpleQueue/SuperSimpleQueueHelper.js b/server/service/SuperSimpleQueue/SuperSimpleQueueHelper.js index 45c5978b9..fd41a5179 100644 --- a/server/service/SuperSimpleQueue/SuperSimpleQueueHelper.js +++ b/server/service/SuperSimpleQueue/SuperSimpleQueueHelper.js @@ -31,11 +31,7 @@ class SuperSimpleQueueHelper { throw new Error("No network response"); } - const { - monitor: updatedMonitor, - statusChanged, - prevStatus, - } = await this.statusService.updateStatus(networkResponse); + const { monitor: updatedMonitor, statusChanged, prevStatus } = await this.statusService.updateStatus(networkResponse); this.notificationService .handleNotifications({ diff --git a/server/templates/passwordReset.mjml b/server/templates/passwordReset.mjml index 365199473..568b363ee 100755 --- a/server/templates/passwordReset.mjml +++ b/server/templates/passwordReset.mjml @@ -1,42 +1,56 @@ - - - - - - - - - - - - - Message from Checkmate Service - - - - - - -

Hello {{ name }}!

-

- You are receiving this email because a password reset request has been made for {{ email }}. Please use the link below on the site to - reset your password. -

- Reset Password -

If you didn't request this, please ignore this email.

+ + + + + + + + + + + + + Message from Checkmate Service + + + + + + +

Hello {{ name }}!

+

+ You are receiving this email because a password reset request has been made for {{ email }}. Please use the link below on the site to + reset your password. +

+ Reset Password +

If you didn't request this, please ignore this email.

-

Thank you.

-
-
- - - -

This email was sent by Checkmate.

-
-
-
-
- \ No newline at end of file +

Thank you.

+ + + + + +

This email was sent by Checkmate.

+
+
+ + + diff --git a/server/templates/serverIsDown.mjml b/server/templates/serverIsDown.mjml index 5375412ab..bf6424a6b 100755 --- a/server/templates/serverIsDown.mjml +++ b/server/templates/serverIsDown.mjml @@ -1,42 +1,68 @@ - - - - - - - - - - - - - Message from Checkmate Service - - - - - {{ monitor }} is down - - - - - - - -

Hello {{ name }}!

-

We detected an incident on one of your monitors. Your service is currently down. We'll send a message to you once it is up again.

-

Monitor name: {{ monitor }}

-

URL: {{ url }}

-
-
- - - View incident details - -

This email was sent by Checkmate.

-
-
-
-
-
\ No newline at end of file + + + + + + + + + + + + + Message from Checkmate Service + + + + + {{ monitor }} is down + + + + + + + +

Hello {{ name }}!

+

We detected an incident on one of your monitors. Your service is currently down. We'll send a message to you once it is up again.

+

Monitor name: {{ monitor }}

+

URL: {{ url }}

+
+
+ + + View incident details + +

This email was sent by Checkmate.

+
+
+
+
+ diff --git a/server/templates/welcomeEmail.mjml b/server/templates/welcomeEmail.mjml index b5e90ffaf..e9f569e3f 100755 --- a/server/templates/welcomeEmail.mjml +++ b/server/templates/welcomeEmail.mjml @@ -1,37 +1,51 @@ - - - - - - - - - - - - - Message from Checkmate Service - - - - - - -

Hello {{ name }}!

-

Thank you for trying out Checkmate! We developed it with great care to meet our own needs, and we're excited to share it with you.

-

Checkmate is an automated way of checking whether a service such as a website or an application is available or not.

-

We hope you find our service as valuable as we do.

-

Thank you.

-
-
- - - -

This email was sent by Checkmate.

-
-
-
-
-
\ No newline at end of file + + + + + + + + + + + + + Message from Checkmate Service + + + + + + +

Hello {{ name }}!

+

Thank you for trying out Checkmate! We developed it with great care to meet our own needs, and we're excited to share it with you.

+

Checkmate is an automated way of checking whether a service such as a website or an application is available or not.

+

We hope you find our service as valuable as we do.

+

Thank you.

+
+
+ + + +

This email was sent by Checkmate.

+
+
+
+
+ From df97b93a9a082a0f33774363429b2cdbb914b309 Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Thu, 24 Jul 2025 01:35:31 +0530 Subject: [PATCH 057/226] Button Text Dynamic --- client/src/Pages/Incidents/index.jsx | 2 +- client/src/locales/en.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/Pages/Incidents/index.jsx b/client/src/Pages/Incidents/index.jsx index 7b4c22e41..c19aea69b 100644 --- a/client/src/Pages/Incidents/index.jsx +++ b/client/src/Pages/Incidents/index.jsx @@ -84,7 +84,7 @@ const Incidents = () => { onClick={handleAckAllChecks} disabled={ackAllLoading} > - {t("incidentsPageActionResolve")} + {selectedMonitor === "0" ? t("incidentsPageActionResolveAll") : t("incidentsPageActionResolveMonitor")} Date: Wed, 23 Jul 2025 13:06:23 -0700 Subject: [PATCH 058/226] sync package lock --- server/package-lock.json | 114 +++++++++++++++++++++++++++++++-------- 1 file changed, 93 insertions(+), 21 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 14dc57616..d2ba13f13 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1079,6 +1079,17 @@ "dev": true, "license": "(Unlicense OR Apache-2.0)" }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2252,12 +2263,14 @@ } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "mdn-data": "2.12.2", + "mdn-data": "2.0.30", "source-map-js": "^1.0.1" }, "engines": { @@ -4547,10 +4560,12 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "license": "CC0-1.0" + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "license": "CC0-1.0", + "optional": true, + "peer": true }, "node_modules/media-typer": { "version": "0.3.0", @@ -6381,6 +6396,59 @@ "postcss": "^8.4.32" } }, + "node_modules/postcss-svgo/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/postcss-svgo/node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/postcss-svgo/node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "license": "CC0-1.0" + }, + "node_modules/postcss-svgo/node_modules/svgo": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", + "integrity": "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.4.1" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, "node_modules/postcss-unique-selectors": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.4.tgz", @@ -7421,24 +7489,26 @@ } }, "node_modules/svgo": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", - "integrity": "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", + "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "commander": "^11.1.0", + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", "css-select": "^5.1.0", - "css-tree": "^3.0.1", + "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", - "picocolors": "^1.1.1", - "sax": "^1.4.1" + "picocolors": "^1.0.0" }, "bin": { - "svgo": "bin/svgo.js" + "svgo": "bin/svgo" }, "engines": { - "node": ">=16" + "node": ">=14.0.0" }, "funding": { "type": "opencollective", @@ -7446,12 +7516,14 @@ } }, "node_modules/svgo/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "license": "MIT", + "optional": true, + "peer": true, "engines": { - "node": ">=16" + "node": ">= 10" } }, "node_modules/swagger-ui-dist": { From 4933d11dfdb6618fa25798ace095062c93276cfb Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Thu, 24 Jul 2025 01:42:46 +0530 Subject: [PATCH 059/226] Network Service: Monitor Check Func --- client/src/Hooks/checkHooks.js | 18 ++++++++++++++++++ client/src/Utils/NetworkService.js | 6 ++++++ client/src/locales/en.json | 1 + 3 files changed, 25 insertions(+) diff --git a/client/src/Hooks/checkHooks.js b/client/src/Hooks/checkHooks.js index f23e1eaea..3e5567cf6 100644 --- a/client/src/Hooks/checkHooks.js +++ b/client/src/Hooks/checkHooks.js @@ -185,6 +185,23 @@ const useResolveIncident = () => { return [resolveIncident, isLoading]; }; +const useAckMonitorChecks = () => { + const [isLoading, setIsLoading] = useState(false); + const { t } = useTranslation(); + + const ackMonitorChecks = async (monitorId, setUpdateTrigger) => { + try { + setIsLoading(true); + } catch (error) { + createToast({ body: t("checkHooks.failureResolveMonitor") }); + } finally { + setIsLoading(false); + } + }; + + return [ackMonitorChecks, isLoading]; +}; + const useAckAllChecks = () => { const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); @@ -209,5 +226,6 @@ export { useFetchChecksTeam, useFetchChecksSummaryByTeamId, useResolveIncident, + useAckMonitorChecks, useAckAllChecks, }; diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index 5b33f97af..a711ceb21 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -631,6 +631,12 @@ class NetworkService { }); } + async updateMonitorChecksStatus(config) { + return this.axiosInstance.put(`/checks/monitor/${config.monitorId}`, { + ack: config.ack, + }); + } + async updateAllChecksStatus(config) { return this.axiosInstance.put(`/checks/team/`, { ack: config.ack, diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 131b39952..b88b1d8a1 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -218,6 +218,7 @@ "checkFrequency": "Check frequency", "checkHooks": { "failureResolveAll": "Failed to resolve all incidents.", + "failureResolveMonitor": "Failed to resolve monitor incidents.", "failureResolveOne": "Failed to resolve incident." }, "checkingEvery": "Checking every", From 3650c400ca7f63de7d1944be415b7033f4f185c6 Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Thu, 24 Jul 2025 01:47:42 +0530 Subject: [PATCH 060/226] Feat: Monitor Incidents Resolve --- client/src/Hooks/checkHooks.js | 5 +++++ client/src/Pages/Incidents/index.jsx | 11 ++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/client/src/Hooks/checkHooks.js b/client/src/Hooks/checkHooks.js index 3e5567cf6..f24466792 100644 --- a/client/src/Hooks/checkHooks.js +++ b/client/src/Hooks/checkHooks.js @@ -192,6 +192,11 @@ const useAckMonitorChecks = () => { const ackMonitorChecks = async (monitorId, setUpdateTrigger) => { try { setIsLoading(true); + await networkService.updateMonitorChecksStatus({ + monitorId, + ack: true, + }); + setUpdateTrigger((prev) => !prev); } catch (error) { createToast({ body: t("checkHooks.failureResolveMonitor") }); } finally { diff --git a/client/src/Pages/Incidents/index.jsx b/client/src/Pages/Incidents/index.jsx index c19aea69b..780c91fe8 100644 --- a/client/src/Pages/Incidents/index.jsx +++ b/client/src/Pages/Incidents/index.jsx @@ -11,7 +11,7 @@ import { Box, Button } from "@mui/material"; import { useTheme } from "@emotion/react"; import { useFetchMonitorsByTeamId } from "../../Hooks/monitorHooks"; import { useFetchChecksSummaryByTeamId } from "../../Hooks/checkHooks"; -import { useAckAllChecks } from "../../Hooks/checkHooks"; +import { useAckAllChecks, useAckMonitorChecks } from "../../Hooks/checkHooks"; import { useState, useEffect } from "react"; import NetworkError from "../../Components/GenericFallback/NetworkError"; import { useTranslation } from "react-i18next"; @@ -35,6 +35,7 @@ const Incidents = () => { //Hooks const [ackAllChecks, ackAllLoading] = useAckAllChecks(); + const [ackMonitorChecks, ackMonitorLoading] = useAckMonitorChecks(); //Utils const theme = useTheme(); @@ -63,7 +64,11 @@ const Incidents = () => { }, [monitors]); const handleAckAllChecks = () => { - ackAllChecks(setUpdateTrigger); + if (selectedMonitor === "0") { + ackAllChecks(setUpdateTrigger); + } else { + ackMonitorChecks(selectedMonitor, setUpdateTrigger); + } }; if (networkError || networkErrorSummary) { @@ -82,7 +87,7 @@ const Incidents = () => { variant="contained" color="accent" onClick={handleAckAllChecks} - disabled={ackAllLoading} + disabled={selectedMonitor === "0" ? ackAllLoading : ackMonitorLoading} > {selectedMonitor === "0" ? t("incidentsPageActionResolveAll") : t("incidentsPageActionResolveMonitor")} From b7093b5de54c894650bbfa888b66c1d000e5ca30 Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Thu, 24 Jul 2025 01:54:58 +0530 Subject: [PATCH 061/226] Feat: One Hook for Incidents Ack --- client/src/Hooks/checkHooks.js | 27 +++++ client/src/Pages/Incidents/index.jsx | 20 +++- .../SuperSimpleQueueHelper.js | 6 +- server/templates/passwordReset.mjml | 90 +++++++++------ server/templates/serverIsDown.mjml | 108 +++++++++++------- server/templates/welcomeEmail.mjml | 84 ++++++++------ 6 files changed, 212 insertions(+), 123 deletions(-) diff --git a/client/src/Hooks/checkHooks.js b/client/src/Hooks/checkHooks.js index f24466792..6fd48e828 100644 --- a/client/src/Hooks/checkHooks.js +++ b/client/src/Hooks/checkHooks.js @@ -226,6 +226,32 @@ const useAckAllChecks = () => { return [ackAllChecks, isLoading]; }; +const useAcknowledgeChecks = () => { + const [isLoading, setIsLoading] = useState(false); + const { t } = useTranslation(); + + const acknowledge = async (setUpdateTrigger, monitorId = null) => { + setIsLoading(true); + try { + if (monitorId) { + await networkService.updateMonitorChecksStatus({ monitorId, ack: true }); + } else { + await networkService.updateAllChecksStatus({ ack: true }); + } + setUpdateTrigger((prev) => !prev); + } catch (error) { + const toastMessage = monitorId + ? t("checkHooks.failureResolveMonitor") + : t("checkHooks.failureResolveAll"); + createToast({ body: toastMessage }); + } finally { + setIsLoading(false); + } + }; + + return { acknowledge, isLoading }; +}; + export { useFetchChecksByMonitor, useFetchChecksTeam, @@ -233,4 +259,5 @@ export { useResolveIncident, useAckMonitorChecks, useAckAllChecks, + useAcknowledgeChecks, }; diff --git a/client/src/Pages/Incidents/index.jsx b/client/src/Pages/Incidents/index.jsx index 780c91fe8..d963db9e7 100644 --- a/client/src/Pages/Incidents/index.jsx +++ b/client/src/Pages/Incidents/index.jsx @@ -11,7 +11,11 @@ import { Box, Button } from "@mui/material"; import { useTheme } from "@emotion/react"; import { useFetchMonitorsByTeamId } from "../../Hooks/monitorHooks"; import { useFetchChecksSummaryByTeamId } from "../../Hooks/checkHooks"; -import { useAckAllChecks, useAckMonitorChecks } from "../../Hooks/checkHooks"; +import { + useAckAllChecks, + useAckMonitorChecks, + useAcknowledgeChecks, +} from "../../Hooks/checkHooks"; import { useState, useEffect } from "react"; import NetworkError from "../../Components/GenericFallback/NetworkError"; import { useTranslation } from "react-i18next"; @@ -36,6 +40,7 @@ const Incidents = () => { //Hooks const [ackAllChecks, ackAllLoading] = useAckAllChecks(); const [ackMonitorChecks, ackMonitorLoading] = useAckMonitorChecks(); + const { acknowledge, isLoadingAcknowledge } = useAcknowledgeChecks(); //Utils const theme = useTheme(); @@ -71,6 +76,11 @@ const Incidents = () => { } }; + const handleAcknowledge = () => { + const monitorId = selectedMonitor === "0" ? null : selectedMonitor; + acknowledge(setUpdateTrigger, monitorId); + }; + if (networkError || networkErrorSummary) { return ( @@ -86,10 +96,12 @@ const Incidents = () => { - - - - - - - - - - - - - Message from Checkmate Service - - - - - - -

Hello {{ name }}!

-

- You are receiving this email because a password reset request has been made for {{ email }}. Please use the link below on the site to - reset your password. -

- Reset Password -

If you didn't request this, please ignore this email.

+ + + + + + + + + + + + + Message from Checkmate Service + + + + + + +

Hello {{ name }}!

+

+ You are receiving this email because a password reset request has been made for {{ email }}. Please use the link below on the site to + reset your password. +

+ Reset Password +

If you didn't request this, please ignore this email.

-

Thank you.

-
-
- - - -

This email was sent by Checkmate.

-
-
-
-
-
\ No newline at end of file +

Thank you.

+ + + + + +

This email was sent by Checkmate.

+
+
+ + + diff --git a/server/templates/serverIsDown.mjml b/server/templates/serverIsDown.mjml index 5375412ab..bf6424a6b 100755 --- a/server/templates/serverIsDown.mjml +++ b/server/templates/serverIsDown.mjml @@ -1,42 +1,68 @@ - - - - - - - - - - - - - Message from Checkmate Service - - - - - {{ monitor }} is down - - - - - - - -

Hello {{ name }}!

-

We detected an incident on one of your monitors. Your service is currently down. We'll send a message to you once it is up again.

-

Monitor name: {{ monitor }}

-

URL: {{ url }}

-
-
- - - View incident details - -

This email was sent by Checkmate.

-
-
-
-
-
\ No newline at end of file + + + + + + + + + + + + + Message from Checkmate Service + + + + + {{ monitor }} is down + + + + + + + +

Hello {{ name }}!

+

We detected an incident on one of your monitors. Your service is currently down. We'll send a message to you once it is up again.

+

Monitor name: {{ monitor }}

+

URL: {{ url }}

+
+
+ + + View incident details + +

This email was sent by Checkmate.

+
+
+
+
+ diff --git a/server/templates/welcomeEmail.mjml b/server/templates/welcomeEmail.mjml index b5e90ffaf..e9f569e3f 100755 --- a/server/templates/welcomeEmail.mjml +++ b/server/templates/welcomeEmail.mjml @@ -1,37 +1,51 @@ - - - - - - - - - - - - - Message from Checkmate Service - - - - - - -

Hello {{ name }}!

-

Thank you for trying out Checkmate! We developed it with great care to meet our own needs, and we're excited to share it with you.

-

Checkmate is an automated way of checking whether a service such as a website or an application is available or not.

-

We hope you find our service as valuable as we do.

-

Thank you.

-
-
- - - -

This email was sent by Checkmate.

-
-
-
-
-
\ No newline at end of file + + + + + + + + + + + + + Message from Checkmate Service + + + + + + +

Hello {{ name }}!

+

Thank you for trying out Checkmate! We developed it with great care to meet our own needs, and we're excited to share it with you.

+

Checkmate is an automated way of checking whether a service such as a website or an application is available or not.

+

We hope you find our service as valuable as we do.

+

Thank you.

+
+
+ + + +

This email was sent by Checkmate.

+
+
+
+
+ From 7236e8532131827e9e34acc44202aa473402b811 Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Thu, 24 Jul 2025 01:56:05 +0530 Subject: [PATCH 062/226] Removal of old code --- client/src/Hooks/checkHooks.js | 43 ---------------------------- client/src/Pages/Incidents/index.jsx | 16 +---------- 2 files changed, 1 insertion(+), 58 deletions(-) diff --git a/client/src/Hooks/checkHooks.js b/client/src/Hooks/checkHooks.js index 6fd48e828..bdb1a73b7 100644 --- a/client/src/Hooks/checkHooks.js +++ b/client/src/Hooks/checkHooks.js @@ -185,47 +185,6 @@ const useResolveIncident = () => { return [resolveIncident, isLoading]; }; -const useAckMonitorChecks = () => { - const [isLoading, setIsLoading] = useState(false); - const { t } = useTranslation(); - - const ackMonitorChecks = async (monitorId, setUpdateTrigger) => { - try { - setIsLoading(true); - await networkService.updateMonitorChecksStatus({ - monitorId, - ack: true, - }); - setUpdateTrigger((prev) => !prev); - } catch (error) { - createToast({ body: t("checkHooks.failureResolveMonitor") }); - } finally { - setIsLoading(false); - } - }; - - return [ackMonitorChecks, isLoading]; -}; - -const useAckAllChecks = () => { - const [isLoading, setIsLoading] = useState(false); - const { t } = useTranslation(); - - const ackAllChecks = async (setUpdateTrigger) => { - try { - setIsLoading(true); - await networkService.updateAllChecksStatus({ ack: true }); - setUpdateTrigger((prev) => !prev); - } catch (error) { - createToast({ body: t("checkHooks.failureResolveAll") }); - } finally { - setIsLoading(false); - } - }; - - return [ackAllChecks, isLoading]; -}; - const useAcknowledgeChecks = () => { const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); @@ -257,7 +216,5 @@ export { useFetchChecksTeam, useFetchChecksSummaryByTeamId, useResolveIncident, - useAckMonitorChecks, - useAckAllChecks, useAcknowledgeChecks, }; diff --git a/client/src/Pages/Incidents/index.jsx b/client/src/Pages/Incidents/index.jsx index d963db9e7..7696c453b 100644 --- a/client/src/Pages/Incidents/index.jsx +++ b/client/src/Pages/Incidents/index.jsx @@ -11,11 +11,7 @@ import { Box, Button } from "@mui/material"; import { useTheme } from "@emotion/react"; import { useFetchMonitorsByTeamId } from "../../Hooks/monitorHooks"; import { useFetchChecksSummaryByTeamId } from "../../Hooks/checkHooks"; -import { - useAckAllChecks, - useAckMonitorChecks, - useAcknowledgeChecks, -} from "../../Hooks/checkHooks"; +import { useAcknowledgeChecks } from "../../Hooks/checkHooks"; import { useState, useEffect } from "react"; import NetworkError from "../../Components/GenericFallback/NetworkError"; import { useTranslation } from "react-i18next"; @@ -38,8 +34,6 @@ const Incidents = () => { const [updateTrigger, setUpdateTrigger] = useState(false); //Hooks - const [ackAllChecks, ackAllLoading] = useAckAllChecks(); - const [ackMonitorChecks, ackMonitorLoading] = useAckMonitorChecks(); const { acknowledge, isLoadingAcknowledge } = useAcknowledgeChecks(); //Utils @@ -68,14 +62,6 @@ const Incidents = () => { setMonitorLookup(monitorLookup); }, [monitors]); - const handleAckAllChecks = () => { - if (selectedMonitor === "0") { - ackAllChecks(setUpdateTrigger); - } else { - ackMonitorChecks(selectedMonitor, setUpdateTrigger); - } - }; - const handleAcknowledge = () => { const monitorId = selectedMonitor === "0" ? null : selectedMonitor; acknowledge(setUpdateTrigger, monitorId); From c766dc9f004420bea8bbb6594c45c4a666a9a814 Mon Sep 17 00:00:00 2001 From: Br0wnHammer Date: Thu, 24 Jul 2025 02:06:55 +0530 Subject: [PATCH 063/226] JSDOC for both network calls --- client/src/Utils/NetworkService.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index a711ceb21..3abd726da 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -631,12 +631,35 @@ class NetworkService { }); } + /** + * ************************************ + * Update the status of all checks for a given monitor + * ************************************ + * + * @async + * @param {Object} config - The configuration object. + * @param {string} config.monitorId - The ID of the monitor. + * @param {boolean} config.ack - The acknowledgment to update the checks to. + * @returns {Promise} The response from the axios PUT request. + * + */ async updateMonitorChecksStatus(config) { return this.axiosInstance.put(`/checks/monitor/${config.monitorId}`, { ack: config.ack, }); } + /** + * ************************************ + * Update the status of all checks for a given team + * ************************************ + * + * @async + * @param {Object} config - The configuration object. + * @param {boolean} config.ack - The acknowledgment to update the checks to. + * @returns {Promise} The response from the axios PUT request. + * + */ async updateAllChecksStatus(config) { return this.axiosInstance.put(`/checks/team/`, { ack: config.ack, From 8c0dfb2cc5cf9f5c2db41b1ac37775a66ebede6f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 13:41:10 -0700 Subject: [PATCH 064/226] add a check service --- server/controllers/checkController.js | 382 ++++++++++++++---------- server/index.js | 21 +- server/service/business/checkService.js | 165 ++++++++++ server/validation/joi.js | 3 - 4 files changed, 409 insertions(+), 162 deletions(-) create mode 100644 server/service/business/checkService.js diff --git a/server/controllers/checkController.js b/server/controllers/checkController.js index 96dabef80..304f89c6d 100755 --- a/server/controllers/checkController.js +++ b/server/controllers/checkController.js @@ -1,7 +1,6 @@ import { getChecksParamValidation, getChecksQueryValidation, - getTeamChecksParamValidation, getTeamChecksQueryValidation, deleteChecksParamValidation, deleteChecksByTeamIdParamValidation, @@ -14,58 +13,69 @@ import { asyncHandler } from "../utils/errorUtils.js"; const SERVICE_NAME = "checkController"; +/** + * Check Controller + * + * Handles all check-related HTTP requests including retrieving checks, + * acknowledging checks, deleting checks, and managing check TTL settings. + * + * @class CheckController + * @description Manages check operations and monitoring data + */ class CheckController { - constructor(db, settingsService, stringService) { + /** + * Creates an instance of CheckController. + * + * @param {Object} dependencies - The dependencies required by the controller + * @param {Object} dependencies.db - Database service for data operations + * @param {Object} dependencies.settingsService - Service for application settings + * @param {Object} dependencies.stringService - Service for string/localization + * @param {Object} dependencies.checkService - Check business logic service + */ + constructor({ db, settingsService, stringService, checkService }) { this.db = db; this.settingsService = settingsService; this.stringService = stringService; + this.checkService = checkService; } + /** + * Retrieves checks for a specific monitor with filtering and pagination. + * + * @async + * @function getChecksByMonitor + * @param {Object} req - Express request object + * @param {Object} req.params - URL parameters + * @param {string} req.params.monitorId - ID of the monitor to get checks for + * @param {Object} req.query - Query parameters for filtering and pagination + * @param {string} [req.query.type] - Type of checks to filter by + * @param {string} [req.query.sortOrder] - Sort order (asc/desc) + * @param {string} [req.query.dateRange] - Date range filter + * @param {string} [req.query.filter] - General filter string + * @param {boolean} [req.query.ack] - Filter by acknowledgment status + * @param {number} [req.query.page] - Page number for pagination + * @param {number} [req.query.rowsPerPage] - Number of rows per page + * @param {string} [req.query.status] - Filter by check status + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user.teamId - User's team ID + * @param {Object} res - Express response object + * @returns {Promise} Success response with checks data + * @throws {Error} 422 - Validation error if parameters are invalid + * @throws {Error} 404 - Not found if monitor doesn't exist + * @throws {Error} 403 - Forbidden if user doesn't have access to monitor + * @example + * GET /checks/monitor/507f1f77bcf86cd799439011?page=1&rowsPerPage=10&status=down + * // Requires JWT authentication + */ getChecksByMonitor = asyncHandler( - async (req, res, next) => { + async (req, res) => { await getChecksParamValidation.validateAsync(req.params); await getChecksQueryValidation.validateAsync(req.query); - const monitorId = req?.params?.monitorId; - - if (!monitorId) { - throw new Error("No monitor ID in request"); - } - - const teamId = req?.user?.teamId; - if (!teamId) { - throw new Error("No team ID in request"); - } - - const monitor = await this.db.getMonitorById(monitorId); - - if (!monitor) { - const err = new Error("Monitor not found"); - err.status = 404; - err.service = SERVICE_NAME; - err.method = "getChecksByMonitor"; - throw err; - } - - if (!monitor.teamId.equals(teamId)) { - const err = new Error("Unauthorized"); - err.status = 403; - err.service = SERVICE_NAME; - err.method = "getChecksByMonitor"; - throw err; - } - - let { type, sortOrder, dateRange, filter, ack, page, rowsPerPage, status } = req.query; - const result = await this.db.getChecksByMonitor({ - monitorId, - type, - sortOrder, - dateRange, - filter, - ack, - page, - rowsPerPage, - status, + const result = await this.checkService.getChecksByMonitor({ + monitorId: req?.params?.monitorId, + query: req?.query, + teamId: req?.user?.teamId, }); return res.success({ @@ -77,26 +87,34 @@ class CheckController { "getChecksByMonitor" ); + /** + * Retrieves all checks for the current user's team with filtering and pagination. + * + * @async + * @function getChecksByTeam + * @param {Object} req - Express request object + * @param {Object} req.query - Query parameters for filtering and pagination + * @param {string} [req.query.sortOrder] - Sort order (asc/desc) + * @param {string} [req.query.dateRange] - Date range filter + * @param {string} [req.query.filter] - General filter string + * @param {boolean} [req.query.ack] - Filter by acknowledgment status + * @param {number} [req.query.page] - Page number for pagination + * @param {number} [req.query.rowsPerPage] - Number of rows per page + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user.teamId - User's team ID + * @param {Object} res - Express response object + * @returns {Promise} Success response with checks data + * @throws {Error} 422 - Validation error if query parameters are invalid + * @example + * GET /checks/team?page=1&rowsPerPage=20&status=down&ack=false + * // Requires JWT authentication + */ getChecksByTeam = asyncHandler( - async (req, res, next) => { - await getTeamChecksParamValidation.validateAsync(req.params); + async (req, res) => { await getTeamChecksQueryValidation.validateAsync(req.query); - - let { sortOrder, dateRange, filter, ack, page, rowsPerPage } = req.query; - const teamId = req?.user?.teamId; - - if (!teamId) { - throw new Error("No team ID in request"); - } - - const checkData = await this.db.getChecksByTeam({ - sortOrder, - dateRange, - filter, - ack, - page, - rowsPerPage, - teamId, + const checkData = await this.checkService.getChecksByTeam({ + teamId: req?.user?.teamId, + query: req?.query, }); return res.success({ msg: this.stringService.checkGet, @@ -107,15 +125,24 @@ class CheckController { "getChecksByTeam" ); + /** + * Retrieves a summary of checks for the current user's team. + * + * @async + * @function getChecksSummaryByTeamId + * @param {Object} req - Express request object + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user.teamId - User's team ID + * @param {Object} res - Express response object + * @returns {Promise} Success response with checks summary + * @example + * GET /checks/summary + * // Requires JWT authentication + * // Response includes counts by status, time ranges, etc. + */ getChecksSummaryByTeamId = asyncHandler( - async (req, res, next) => { - const teamId = req?.user?.teamId; - - if (!teamId) { - throw new Error("No team ID in request"); - } - - const summary = await this.db.getChecksSummaryByTeamId({ teamId }); + async (req, res) => { + const summary = await this.checkService.getChecksSummaryByTeamId({ teamId: req?.user?.teamId }); return res.success({ msg: this.stringService.checkGetSummary, data: summary, @@ -125,23 +152,39 @@ class CheckController { "getChecksSummaryByTeamId" ); + /** + * Acknowledges a specific check by ID. + * + * @async + * @function ackCheck + * @param {Object} req - Express request object + * @param {Object} req.params - URL parameters + * @param {string} req.params.checkId - ID of the check to acknowledge + * @param {Object} req.body - Request body containing acknowledgment data + * @param {boolean} req.body.ack - Acknowledgment status (true/false) + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user.teamId - User's team ID + * @param {Object} res - Express response object + * @returns {Promise} Success response with updated check data + * @throws {Error} 422 - Validation error if request body is invalid + * @throws {Error} 404 - Not found if check doesn't exist + * @throws {Error} 403 - Forbidden if user doesn't have access to check + * @example + * PUT /checks/507f1f77bcf86cd799439011/ack + * { + * "ack": true + * } + * // Requires JWT authentication + */ ackCheck = asyncHandler( - async (req, res, next) => { + async (req, res) => { await ackCheckBodyValidation.validateAsync(req.body); - const checkId = req?.params?.checkId; - const ack = req?.body?.ack; - const teamId = req?.user?.teamId; - - if (!checkId) { - throw new Error("No check ID in request"); - } - - if (!teamId) { - throw new Error("No team ID in request"); - } - - const updatedCheck = await this.db.ackCheck(checkId, teamId, ack); + const updatedCheck = await this.checkService.ackCheck({ + checkId: req?.params?.checkId, + teamId: req?.user?.teamId, + ack: req?.body?.ack, + }); return res.success({ msg: this.stringService.checkUpdateStatus, @@ -152,8 +195,33 @@ class CheckController { "ackCheck" ); + /** + * Acknowledges all checks for a specific monitor or path. + * + * @async + * @function ackAllChecks + * @param {Object} req - Express request object + * @param {Object} req.params - URL parameters + * @param {string} req.params.monitorId - ID of the monitor + * @param {string} req.params.path - Path for acknowledgment (e.g., "monitor") + * @param {Object} req.body - Request body containing acknowledgment data + * @param {boolean} req.body.ack - Acknowledgment status (true/false) + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user.teamId - User's team ID + * @param {Object} res - Express response object + * @returns {Promise} Success response with updated checks data + * @throws {Error} 422 - Validation error if parameters or body are invalid + * @throws {Error} 404 - Not found if monitor doesn't exist + * @throws {Error} 403 - Forbidden if user doesn't have access to monitor + * @example + * PUT /checks/monitor/507f1f77bcf86cd799439011/ack + * { + * "ack": true + * } + * // Requires JWT authentication + */ ackAllChecks = asyncHandler( - async (req, res, next) => { + async (req, res) => { await ackAllChecksParamValidation.validateAsync(req.params); await ackAllChecksBodyValidation.validateAsync(req.body); @@ -162,32 +230,12 @@ class CheckController { throw new Error("No team ID in request"); } - const monitorId = req?.params?.monitorId; - - const path = req?.params?.path; - - const ack = req?.body?.ack; - - if (path === "monitor") { - if (!monitorId) { - throw new Error("No monitor ID in request"); - } - - const monitor = await this.db.getMonitorById(monitorId); - if (!monitor) { - throw new Error("Monitor not found"); - } - - if (!monitor.teamId.equals(teamId)) { - const err = new Error("Unauthorized"); - err.status = 403; - err.service = SERVICE_NAME; - err.method = "ackAllChecks"; - throw err; - } - } - - const updatedChecks = await this.db.ackAllChecks(monitorId, teamId, ack, path); + const updatedChecks = await this.checkService.ackAllChecks({ + monitorId: req?.params?.monitorId, + path: req?.params?.path, + teamId: req?.user?.teamId, + ack: req?.body?.ack, + }); return res.success({ msg: this.stringService.checkUpdateStatus, @@ -198,40 +246,34 @@ class CheckController { "ackAllChecks" ); + /** + * Deletes all checks for a specific monitor. + * + * @async + * @function deleteChecks + * @param {Object} req - Express request object + * @param {Object} req.params - URL parameters + * @param {string} req.params.monitorId - ID of the monitor whose checks to delete + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user.teamId - User's team ID + * @param {Object} res - Express response object + * @returns {Promise} Success response with deletion count + * @throws {Error} 422 - Validation error if monitorId is invalid + * @throws {Error} 404 - Not found if monitor doesn't exist + * @throws {Error} 403 - Forbidden if user doesn't have access to monitor + * @example + * DELETE /checks/monitor/507f1f77bcf86cd799439011 + * // Requires JWT authentication + * // Response: { "data": { "deletedCount": 150 } } + */ deleteChecks = asyncHandler( - async (req, res, next) => { + async (req, res) => { await deleteChecksParamValidation.validateAsync(req.params); - const monitorId = req?.params?.monitorId; - const teamId = req?.user?.teamId; - - if (!monitorId) { - throw new Error("No monitor ID in request"); - } - - if (!teamId) { - throw new Error("No team ID in request"); - } - - const monitor = await this.db.getMonitorById(monitorId); - - if (!monitor) { - const err = new Error("Monitor not found"); - err.status = 404; - err.service = SERVICE_NAME; - err.method = "deleteChecks"; - throw err; - } - - if (!monitor.teamId.equals(teamId)) { - const err = new Error("Unauthorized"); - err.status = 403; - err.service = SERVICE_NAME; - err.method = "deleteChecks"; - throw err; - } - - const deletedCount = await this.db.deleteChecks(req.params.monitorId); + const deletedCount = await this.checkService.deleteChecks({ + monitorId: req.params.monitorId, + teamId: req?.user?.teamId, + }); return res.success({ msg: this.stringService.checkDelete, @@ -242,16 +284,27 @@ class CheckController { "deleteChecks" ); + /** + * Deletes all checks for the current user's team. + * + * @async + * @function deleteChecksByTeamId + * @param {Object} req - Express request object + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user.teamId - User's team ID + * @param {Object} res - Express response object + * @returns {Promise} Success response with deletion count + * @throws {Error} 422 - Validation error if parameters are invalid + * @example + * DELETE /checks/team + * // Requires JWT authentication + * // Response: { "data": { "deletedCount": 1250 } } + */ deleteChecksByTeamId = asyncHandler( - async (req, res, next) => { + async (req, res) => { await deleteChecksByTeamIdParamValidation.validateAsync(req.params); - const teamId = req?.user?.teamId; - if (!teamId) { - throw new Error("No team ID in request"); - } - - const deletedCount = await this.db.deleteChecksByTeamId(teamId); + const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId: req?.user?.teamId }); return res.success({ msg: this.stringService.checkDelete, @@ -262,15 +315,35 @@ class CheckController { "deleteChecksByTeamId" ); + /** + * Updates the TTL (Time To Live) setting for checks in the current user's team. + * + * @async + * @function updateChecksTTL + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing TTL data + * @param {number} req.body.ttl - TTL value in days + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user.teamId - User's team ID + * @param {Object} res - Express response object + * @returns {Promise} Success response confirming TTL update + * @throws {Error} 422 - Validation error if TTL value is invalid + * @example + * PUT /checks/ttl + * { + * "ttl": 30 + * } + * // Requires JWT authentication + * // Sets check TTL to 30 days + */ updateChecksTTL = asyncHandler( - async (req, res, next) => { - const SECONDS_PER_DAY = 86400; - + async (req, res) => { await updateChecksTTLBodyValidation.validateAsync(req.body); - const { teamId } = req.user; - const ttl = parseInt(req.body.ttl, 10) * SECONDS_PER_DAY; - await this.db.updateChecksTTL(teamId, ttl); + await this.checkService.updateChecksTTL({ + teamId: req?.user?.teamId, + ttl: req?.body?.ttl, + }); return res.success({ msg: this.stringService.checkUpdateTTL, @@ -280,4 +353,5 @@ class CheckController { "updateChecksTtl" ); } + export default CheckController; diff --git a/server/index.js b/server/index.js index 996eae4d1..6b91874c4 100755 --- a/server/index.js +++ b/server/index.js @@ -57,7 +57,9 @@ import PulseQueueHelper from "./service/infrastructure/PulseQueue/PulseQueueHelp import SuperSimpleQueue from "./service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; import SuperSimpleQueueHelper from "./service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +// Business services import UserService from "./service/business/userService.js"; +import CheckService from "./service/business/checkService.js"; //Network service and dependencies import NetworkService from "./service/infrastructure/networkService.js"; @@ -179,6 +181,8 @@ const startApp = async () => { }); const redisService = new RedisService({ Redis: IORedis, logger }); + + // Business services const userService = new UserService({ db, emailService, @@ -187,6 +191,11 @@ const startApp = async () => { stringService, jwt, }); + const checkService = new CheckService({ + db, + settingsService, + stringService, + }); // const jobQueueHelper = new JobQueueHelper({ // redisService, @@ -247,6 +256,7 @@ const startApp = async () => { ServiceRegistry.register(TranslationService.SERVICE_NAME, translationService); ServiceRegistry.register(RedisService.SERVICE_NAME, redisService); ServiceRegistry.register(UserService.SERVICE_NAME, userService); + ServiceRegistry.register(CheckService.SERVICE_NAME, checkService); await translationService.initialize(); @@ -285,11 +295,12 @@ const startApp = async () => { emailService: ServiceRegistry.get(EmailService.SERVICE_NAME), }); - const checkController = new CheckController( - ServiceRegistry.get(MongoDB.SERVICE_NAME), - ServiceRegistry.get(SettingsService.SERVICE_NAME), - ServiceRegistry.get(StringService.SERVICE_NAME) - ); + const checkController = new CheckController({ + db: ServiceRegistry.get(MongoDB.SERVICE_NAME), + settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), + stringService: ServiceRegistry.get(StringService.SERVICE_NAME), + checkService: ServiceRegistry.get(CheckService.SERVICE_NAME), + }); const inviteController = new InviteController( ServiceRegistry.get(MongoDB.SERVICE_NAME), diff --git a/server/service/business/checkService.js b/server/service/business/checkService.js new file mode 100644 index 000000000..e52a4919a --- /dev/null +++ b/server/service/business/checkService.js @@ -0,0 +1,165 @@ +const SERVICE_NAME = "checkService"; + +class CheckService { + static SERVICE_NAME = SERVICE_NAME; + + constructor({ db, settingsService, stringService }) { + this.db = db; + this.settingsService = settingsService; + this.stringService = stringService; + } + + getChecksByMonitor = async ({ monitorId, query, teamId }) => { + if (!monitorId) { + throw new Error("No monitor ID in request"); + } + + if (!teamId) { + throw new Error("No team ID in request"); + } + + const monitor = await this.db.getMonitorById(monitorId); + + if (!monitor) { + const err = new Error("Monitor not found"); + err.status = 404; + err.service = SERVICE_NAME; + err.method = "getChecksByMonitor"; + throw err; + } + + if (!monitor.teamId.equals(teamId)) { + const err = new Error("Unauthorized"); + err.status = 403; + err.service = SERVICE_NAME; + err.method = "getChecksByMonitor"; + throw err; + } + + let { type, sortOrder, dateRange, filter, ack, page, rowsPerPage, status } = query; + const result = await this.db.getChecksByMonitor({ + monitorId, + type, + sortOrder, + dateRange, + filter, + ack, + page, + rowsPerPage, + status, + }); + return result; + }; + + getChecksByTeam = async ({ teamId, query }) => { + let { sortOrder, dateRange, filter, ack, page, rowsPerPage } = query; + + if (!teamId) { + throw new Error("No team ID in request"); + } + + const checkData = await this.db.getChecksByTeam({ + sortOrder, + dateRange, + filter, + ack, + page, + rowsPerPage, + teamId, + }); + return checkData; + }; + + getChecksSummaryByTeamId = async ({ teamId }) => { + if (!teamId) { + throw new Error("No team ID in request"); + } + + const summary = await this.db.getChecksSummaryByTeamId({ teamId }); + return summary; + }; + + ackCheck = async ({ checkId, teamId, ack }) => { + if (!checkId) { + throw new Error("No check ID in request"); + } + + if (!teamId) { + throw new Error("No team ID in request"); + } + + const updatedCheck = await this.db.ackCheck(checkId, teamId, ack); + return updatedCheck; + }; + + ackAllChecks = async ({ monitorId, path, teamId, ack }) => { + if (path === "monitor") { + if (!monitorId) { + throw new Error("No monitor ID in request"); + } + + const monitor = await this.db.getMonitorById(monitorId); + if (!monitor) { + throw new Error("Monitor not found"); + } + + if (!monitor.teamId.equals(teamId)) { + const err = new Error("Unauthorized"); + err.status = 403; + err.service = SERVICE_NAME; + err.method = "ackAllChecks"; + throw err; + } + } + + const updatedChecks = await this.db.ackAllChecks(monitorId, teamId, ack, path); + return updatedChecks; + }; + + deleteChecks = async ({ monitorId, teamId }) => { + if (!monitorId) { + throw new Error("No monitor ID in request"); + } + + if (!teamId) { + throw new Error("No team ID in request"); + } + + const monitor = await this.db.getMonitorById(monitorId); + + if (!monitor) { + const err = new Error("Monitor not found"); + err.status = 404; + err.service = SERVICE_NAME; + err.method = "deleteChecks"; + throw err; + } + + if (!monitor.teamId.equals(teamId)) { + const err = new Error("Unauthorized"); + err.status = 403; + err.service = SERVICE_NAME; + err.method = "deleteChecks"; + throw err; + } + + const deletedCount = await this.db.deleteChecks(monitorId); + return deletedCount; + }; + deleteChecksByTeamId = async ({ teamId }) => { + if (!teamId) { + throw new Error("No team ID in request"); + } + + const deletedCount = await this.db.deleteChecksByTeamId(teamId); + return deletedCount; + }; + + updateChecksTTL = async ({ teamId, ttl }) => { + const SECONDS_PER_DAY = 86400; + const newTTL = parseInt(ttl, 10) * SECONDS_PER_DAY; + await this.db.updateChecksTTL(teamId, newTTL); + }; +} + +export default CheckService; diff --git a/server/validation/joi.js b/server/validation/joi.js index 41b6fa471..7de5c89b2 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -345,8 +345,6 @@ const getChecksQueryValidation = joi.object({ status: joi.boolean(), }); -const getTeamChecksParamValidation = joi.object({}); - const getTeamChecksQueryValidation = joi.object({ sortOrder: joi.string().valid("asc", "desc"), limit: joi.number(), @@ -720,7 +718,6 @@ export { createCheckBodyValidation, getChecksParamValidation, getChecksQueryValidation, - getTeamChecksParamValidation, getTeamChecksQueryValidation, ackCheckBodyValidation, ackAllChecksParamValidation, From bf861700fab54727f5b42231d91c822dd3d286f7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 14:15:43 -0700 Subject: [PATCH 065/226] resest client validation --- client/src/Validation/validation.js | 67 +++++++++++++++++------------ 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index da59cff2d..7754ee475 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -129,37 +129,48 @@ const monitorValidation = joi.object({ .string() .trim() .custom((value, helpers) => { - // 1. Standard URLs: must have protocol and pass canParse() - if (/^(https?:\/\/)/.test(value)) { - if ( - typeof URL !== "undefined" && - typeof URL.canParse === "function" && - URL.canParse(value) - ) { - return value; - } - // else, it's a malformed URL with protocol + // 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"); } - // 2. Docker/internal hostnames (no protocol) - if (/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:\d{1,5})?$/.test(value)) { - if (value.includes("..")) { - return helpers.error("string.invalidUrl"); - } - // Split by dot, check each label - const labels = value.split(":")[0].split("."); - for (const label of labels) { - if (!label) return helpers.error("string.invalidUrl"); - if (label.startsWith("-") || label.endsWith("-")) { - return helpers.error("string.invalidUrl"); - } - } - return value; - } - - // 3. Everything else is invalid - return helpers.error("string.invalidUrl"); + return value; }) .messages({ "string.empty": "This field is required.", From c1dc575f535ce26ad49a60df519215bd7c023103 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 14:17:47 -0700 Subject: [PATCH 066/226] reset server validation --- server/validation/joi.js | 38 +------------------------------------- 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/server/validation/joi.js b/server/validation/joi.js index 41b6fa471..580b94543 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -159,43 +159,7 @@ const createMonitorBodyValidation = joi.object({ name: joi.string().required(), description: joi.string().required(), type: joi.string().required(), - url: joi - .string() - .required() - .custom((value, helpers) => { - // 1. Standard URLs: must have protocol and pass canParse() - if (/^(https?:\/\/)/.test(value)) { - if (typeof URL !== "undefined" && typeof URL.canParse === "function" && URL.canParse(value)) { - return value; - } - // else, it's a malformed URL with protocol - return helpers.error("string.invalidUrl"); - } - - // 2. Docker/internal hostnames (no protocol) - if (/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?(:\d{1,5})?$/.test(value)) { - if (value.includes("..")) { - return helpers.error("string.invalidUrl"); - } - // Split by dot, check each label - const labels = value.split(":")[0].split("."); - for (const label of labels) { - if (!label) return helpers.error("string.invalidUrl"); - if (label.startsWith("-") || label.endsWith("-")) { - return helpers.error("string.invalidUrl"); - } - } - return value; - } - - // 3. Everything else is invalid - return helpers.error("string.invalidUrl"); - }) - .messages({ - "string.empty": "This field is required.", - "string.uri": "The URL you provided is not valid.", - "string.invalidUrl": "Please enter a valid URL, hostname, or container name (with optional port).", - }), + url: joi.string().required(), ignoreTlsErrors: joi.boolean().default(false), port: joi.number(), isActive: joi.boolean(), From cfebc770fd98b5bac9b92307e7f3c8a168b21eae Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 14:28:16 -0700 Subject: [PATCH 067/226] add diagnostic service --- server/controllers/diagnosticController.js | 70 +----------------- server/index.js | 6 +- server/service/business/diagnosticService.js | 78 ++++++++++++++++++++ 3 files changed, 86 insertions(+), 68 deletions(-) create mode 100644 server/service/business/diagnosticService.js diff --git a/server/controllers/diagnosticController.js b/server/controllers/diagnosticController.js index 6ba880dcb..a4ade8f84 100755 --- a/server/controllers/diagnosticController.js +++ b/server/controllers/diagnosticController.js @@ -1,5 +1,3 @@ -import v8 from "v8"; -import os from "os"; import { asyncHandler } from "../utils/errorUtils.js"; const SERVICE_NAME = "diagnosticController"; @@ -10,75 +8,13 @@ const obs = new PerformanceObserver((items) => { }); obs.observe({ entryTypes: ["measure"] }); class DiagnosticController { - constructor(db) { - this.db = db; + constructor({ diagnosticService }) { + this.diagnosticService = diagnosticService; } - getCPUUsage = async () => { - const startUsage = process.cpuUsage(); - const timingPeriod = 1000; // measured in ms - await new Promise((resolve) => setTimeout(resolve, timingPeriod)); - const endUsage = process.cpuUsage(startUsage); - const cpuUsage = { - userUsageMs: endUsage.user / 1000, - systemUsageMs: endUsage.system / 1000, - usagePercentage: ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100, - }; - return cpuUsage; - }; - getSystemStats = asyncHandler( async (req, res, next) => { - // Memory Usage - const totalMemory = os.totalmem(); - const freeMemory = os.freemem(); - - const osStats = { - freeMemoryBytes: freeMemory, // bytes - totalMemoryBytes: totalMemory, // bytes - }; - - const used = process.memoryUsage(); - const memoryUsage = {}; - for (let key in used) { - memoryUsage[`${key}Mb`] = Math.round((used[key] / 1024 / 1024) * 100) / 100; // MB - } - - // CPU Usage - const cpuMetrics = await this.getCPUUsage(); - - // V8 Heap Statistics - const heapStats = v8.getHeapStatistics(); - const v8Metrics = { - totalHeapSizeBytes: heapStats.total_heap_size, // bytes - usedHeapSizeBytes: heapStats.used_heap_size, // bytes - heapSizeLimitBytes: heapStats.heap_size_limit, // bytes - }; - - // Event Loop Delay - let eventLoopDelay = 0; - performance.mark("start"); - await new Promise((resolve) => setTimeout(resolve, 0)); - performance.mark("end"); - performance.measure("eventLoopDelay", "start", "end"); - const entries = performance.getEntriesByName("eventLoopDelay"); - if (entries.length > 0) { - eventLoopDelay = entries[0].duration; - } - - // Uptime - const uptimeMs = process.uptime() * 1000; // ms - - // Combine Metrics - const diagnostics = { - osStats, - memoryUsage, - cpuUsage: cpuMetrics, - v8HeapStats: v8Metrics, - eventLoopDelayMs: eventLoopDelay, - uptimeMs, - }; - + const diagnostics = await this.diagnosticService.getSystemStats(); return res.success({ msg: "OK", data: diagnostics, diff --git a/server/index.js b/server/index.js index 6b91874c4..c3c23995e 100755 --- a/server/index.js +++ b/server/index.js @@ -60,6 +60,7 @@ import SuperSimpleQueueHelper from "./service/infrastructure/SuperSimpleQueue/Su // Business services import UserService from "./service/business/userService.js"; import CheckService from "./service/business/checkService.js"; +import DiagnosticService from "./service/business/diagnosticService.js"; //Network service and dependencies import NetworkService from "./service/infrastructure/networkService.js"; @@ -196,6 +197,7 @@ const startApp = async () => { settingsService, stringService, }); + const diagnosticService = new DiagnosticService(); // const jobQueueHelper = new JobQueueHelper({ // redisService, @@ -328,7 +330,9 @@ const startApp = async () => { db: ServiceRegistry.get(MongoDB.SERVICE_NAME), }); - const diagnosticController = new DiagnosticController(ServiceRegistry.get(MongoDB.SERVICE_NAME)); + const diagnosticController = new DiagnosticController({ + diagnosticService, + }); //Create routes const authRoutes = new AuthRoutes(authController); diff --git a/server/service/business/diagnosticService.js b/server/service/business/diagnosticService.js new file mode 100644 index 000000000..68b6565cd --- /dev/null +++ b/server/service/business/diagnosticService.js @@ -0,0 +1,78 @@ +import v8 from "v8"; +import os from "os"; + +const SERVICE_NAME = "diagnosticService"; + +class DiagnosticService { + static SERVICE_NAME = SERVICE_NAME; + constructor() {} + + getCPUUsage = async () => { + const startUsage = process.cpuUsage(); + const timingPeriod = 1000; // measured in ms + await new Promise((resolve) => setTimeout(resolve, timingPeriod)); + const endUsage = process.cpuUsage(startUsage); + const cpuUsage = { + userUsageMs: endUsage.user / 1000, + systemUsageMs: endUsage.system / 1000, + usagePercentage: ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100, + }; + return cpuUsage; + }; + + getSystemStats = async () => { + // Memory Usage + const totalMemory = os.totalmem(); + const freeMemory = os.freemem(); + + const osStats = { + freeMemoryBytes: freeMemory, // bytes + totalMemoryBytes: totalMemory, // bytes + }; + + const used = process.memoryUsage(); + const memoryUsage = {}; + for (let key in used) { + memoryUsage[`${key}Mb`] = Math.round((used[key] / 1024 / 1024) * 100) / 100; // MB + } + + // CPU Usage + const cpuMetrics = await this.getCPUUsage(); + + // V8 Heap Statistics + const heapStats = v8.getHeapStatistics(); + const v8Metrics = { + totalHeapSizeBytes: heapStats.total_heap_size, // bytes + usedHeapSizeBytes: heapStats.used_heap_size, // bytes + heapSizeLimitBytes: heapStats.heap_size_limit, // bytes + }; + + // Event Loop Delay + let eventLoopDelay = 0; + performance.mark("start"); + await new Promise((resolve) => setTimeout(resolve, 0)); + performance.mark("end"); + performance.measure("eventLoopDelay", "start", "end"); + const entries = performance.getEntriesByName("eventLoopDelay"); + if (entries.length > 0) { + eventLoopDelay = entries[0].duration; + } + + // Uptime + const uptimeMs = process.uptime() * 1000; // ms + + // Combine Metrics + const diagnostics = { + osStats, + memoryUsage, + cpuUsage: cpuMetrics, + v8HeapStats: v8Metrics, + eventLoopDelayMs: eventLoopDelay, + uptimeMs, + }; + + return diagnostics; + }; +} + +export default DiagnosticService; From da49e155896f53c135d87cff87a77dc5bc9060cd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 14:31:40 -0700 Subject: [PATCH 068/226] jsdocs, move observer --- server/controllers/diagnosticController.js | 44 ++++++++++++++++---- server/service/business/diagnosticService.js | 13 +++++- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/server/controllers/diagnosticController.js b/server/controllers/diagnosticController.js index a4ade8f84..585684211 100755 --- a/server/controllers/diagnosticController.js +++ b/server/controllers/diagnosticController.js @@ -1,19 +1,48 @@ import { asyncHandler } from "../utils/errorUtils.js"; const SERVICE_NAME = "diagnosticController"; - -const obs = new PerformanceObserver((items) => { - const entry = items.getEntries()[0]; - performance.clearMarks(); -}); -obs.observe({ entryTypes: ["measure"] }); +/** + * Diagnostic Controller + * + * Handles system diagnostic and monitoring requests including system statistics, + * performance metrics, and health checks. + * + * @class DiagnosticController + * @description Manages system diagnostics and performance monitoring + */ class DiagnosticController { + /** + * Creates an instance of DiagnosticController. + * + * @param {Object} dependencies - The dependencies required by the controller + * @param {Object} dependencies.diagnosticService - Service for system diagnostics and monitoring + */ constructor({ diagnosticService }) { this.diagnosticService = diagnosticService; } + /** + * Retrieves comprehensive system statistics and performance metrics. + * + * @async + * @function getSystemStats + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {Promise} Success response with system diagnostics data + * @description Returns system performance metrics, memory usage, CPU statistics, + * and other diagnostic information useful for monitoring system health. + * @example + * GET /diagnostics/stats + * // Response includes: + * // - Memory usage (heap, external, arrayBuffers) + * // - CPU usage statistics + * // - System uptime + * // - Performance metrics + * // - Database connection status + * // - Active processes/connections + */ getSystemStats = asyncHandler( - async (req, res, next) => { + async (req, res) => { const diagnostics = await this.diagnosticService.getSystemStats(); return res.success({ msg: "OK", @@ -24,4 +53,5 @@ class DiagnosticController { "getSystemStats" ); } + export default DiagnosticController; diff --git a/server/service/business/diagnosticService.js b/server/service/business/diagnosticService.js index 68b6565cd..5c37cba14 100644 --- a/server/service/business/diagnosticService.js +++ b/server/service/business/diagnosticService.js @@ -5,7 +5,18 @@ const SERVICE_NAME = "diagnosticService"; class DiagnosticService { static SERVICE_NAME = SERVICE_NAME; - constructor() {} + constructor() { + /** + * Performance Observer for monitoring system performance metrics. + * Clears performance marks after each measurement to prevent memory leaks. + */ + const obs = new PerformanceObserver((items) => { + // Get the first entry but we don't need to store it + items.getEntries()[0]; + performance.clearMarks(); + }); + obs.observe({ entryTypes: ["measure"] }); + } getCPUUsage = async () => { const startUsage = process.cpuUsage(); From aea79fec2e2767f0d34910262d3dc45515e258b9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 14:58:44 -0700 Subject: [PATCH 069/226] add invite service --- server/controllers/inviteController.js | 106 +++++++++++++---------- server/index.js | 21 +++-- server/routes/inviteRoute.js | 4 +- server/service/business/inviteService.js | 40 +++++++++ server/validation/joi.js | 5 -- 5 files changed, 115 insertions(+), 61 deletions(-) create mode 100644 server/service/business/inviteService.js diff --git a/server/controllers/inviteController.js b/server/controllers/inviteController.js index f7f33c5d3..06e38a2e2 100755 --- a/server/controllers/inviteController.js +++ b/server/controllers/inviteController.js @@ -1,41 +1,46 @@ -import { inviteRoleValidation, inviteBodyValidation, inviteVerificationBodyValidation } from "../validation/joi.js"; -import jwt from "jsonwebtoken"; -import { getTokenFromHeaders } from "../utils/utils.js"; -import { asyncHandler, createServerError } from "../utils/errorUtils.js"; +import { inviteBodyValidation, inviteVerificationBodyValidation } from "../validation/joi.js"; +import { asyncHandler } from "../utils/errorUtils.js"; const SERVICE_NAME = "inviteController"; +/** + * Controller for handling user invitation operations + * Manages invite token generation, email sending, and token verification + */ class InviteController { - constructor(db, settingsService, emailService, stringService) { + /** + * Creates a new InviteController instance + * @param {Object} dependencies - Dependencies injected into the controller + * @param {Object} dependencies.db - Database connection instance + * @param {Object} dependencies.settingsService - Service for managing application settings + * @param {Object} dependencies.emailService - Service for sending emails + * @param {Object} dependencies.stringService - Service for internationalized strings + * @param {Object} dependencies.inviteService - Service for invite-related operations + */ + constructor({ db, settingsService, emailService, stringService, inviteService }) { this.db = db; this.settingsService = settingsService; this.emailService = emailService; this.stringService = stringService; + this.inviteService = inviteService; } /** - * Issues an invitation to a new user. Only admins can invite new users. An invitation token is created and sent via email. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.headers - The headers of the request. - * @property {string} req.headers.authorization - The authorization header containing the JWT token. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.email - The email of the user to be invited. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the sending of the invitation, and the invitation token. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + * Generates an invite token for a user invitation + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing invite details + * @param {Object} req.user - Authenticated user object + * @param {string} req.user.teamId - Team ID of the authenticated user + * @param {Object} res - Express response object + * @returns {Promise} Response with invite token data */ getInviteToken = asyncHandler( - async (req, res, next) => { - // Only admins can invite - const token = getTokenFromHeaders(req.headers); - const { role, teamId } = jwt.decode(token); - req.body.teamId = teamId; - await inviteRoleValidation.validateAsync({ roles: role }); - await inviteBodyValidation.validateAsync(req.body); - - const inviteToken = await this.db.requestInviteToken({ ...req.body }); + async (req, res) => { + const invite = req.body; + const teamId = req?.user?.teamId; + invite.teamId = teamId; + await inviteBodyValidation.validateAsync(invite); + const inviteToken = await this.inviteService.getInviteToken({ invite, teamId }); return res.success({ msg: this.stringService.inviteIssued, data: inviteToken, @@ -45,27 +50,26 @@ class InviteController { "getInviteToken" ); + /** + * Sends an invitation email to a user + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing invite details + * @param {Object} req.user - Authenticated user object + * @param {string} req.user.teamId - Team ID of the authenticated user + * @param {string} req.user.firstName - First name of the authenticated user + * @param {Object} res - Express response object + * @returns {Promise} Response with invite token data + */ sendInviteEmail = asyncHandler( - async (req, res, next) => { - // Only admins can invite - const token = getTokenFromHeaders(req.headers); - const { role, firstname, teamId } = jwt.decode(token); - req.body.teamId = teamId; - await inviteRoleValidation.validateAsync({ roles: role }); - await inviteBodyValidation.validateAsync(req.body); + async (req, res) => { + const inviteRequest = req.body; + inviteRequest.teamId = req?.user?.teamId; + await inviteBodyValidation.validateAsync(inviteRequest); - const inviteToken = await this.db.requestInviteToken({ ...req.body }); - const { clientHost } = this.settingsService.getSettings(); - - const html = await this.emailService.buildEmail("employeeActivationTemplate", { - name: firstname, - link: `${clientHost}/register/${inviteToken.token}`, + const inviteToken = await this.inviteService.sendInviteEmail({ + inviteRequest, + firstName: req?.user?.firstName, }); - const result = await this.emailService.sendEmail(req.body.email, "Welcome to Uptime Monitor", html); - if (!result) { - throw createServerError("Failed to send invite e-mail... Please verify your settings."); - } - return res.success({ msg: this.stringService.inviteIssued, data: inviteToken, @@ -75,17 +79,25 @@ class InviteController { "sendInviteEmail" ); - inviteVerifyController = asyncHandler( - async (req, res, next) => { + /** + * Verifies an invite token and returns invite details + * @param {Object} req - Express request object + * @param {Object} req.body - Request body containing the invite token + * @param {string} req.body.token - The invite token to verify + * @param {Object} res - Express response object + * @returns {Promise} Response with verified invite data + */ + verifyInviteToken = asyncHandler( + async (req, res) => { await inviteVerificationBodyValidation.validateAsync(req.body); - const invite = await this.db.getInviteToken(req.body.token); + const invite = await this.inviteService.verifyInviteToken({ inviteToken: req?.body?.token }); return res.success({ msg: this.stringService.inviteVerified, data: invite, }); }, SERVICE_NAME, - "inviteVerifyController" + "verifyInviteToken" ); } diff --git a/server/index.js b/server/index.js index c3c23995e..2a8b5259b 100755 --- a/server/index.js +++ b/server/index.js @@ -61,6 +61,7 @@ import SuperSimpleQueueHelper from "./service/infrastructure/SuperSimpleQueue/Su import UserService from "./service/business/userService.js"; import CheckService from "./service/business/checkService.js"; import DiagnosticService from "./service/business/diagnosticService.js"; +import InviteService from "./service/business/inviteService.js"; //Network service and dependencies import NetworkService from "./service/infrastructure/networkService.js"; @@ -198,7 +199,12 @@ const startApp = async () => { stringService, }); const diagnosticService = new DiagnosticService(); - + const inviteService = new InviteService({ + db, + settingsService, + emailService, + stringService, + }); // const jobQueueHelper = new JobQueueHelper({ // redisService, // Queue, @@ -304,12 +310,13 @@ const startApp = async () => { checkService: ServiceRegistry.get(CheckService.SERVICE_NAME), }); - const inviteController = new InviteController( - ServiceRegistry.get(MongoDB.SERVICE_NAME), - ServiceRegistry.get(SettingsService.SERVICE_NAME), - ServiceRegistry.get(EmailService.SERVICE_NAME), - ServiceRegistry.get(StringService.SERVICE_NAME) - ); + const inviteController = new InviteController({ + db: ServiceRegistry.get(MongoDB.SERVICE_NAME), + settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), + emailService: ServiceRegistry.get(EmailService.SERVICE_NAME), + stringService: ServiceRegistry.get(StringService.SERVICE_NAME), + inviteService, + }); const maintenanceWindowController = new MaintenanceWindowController( ServiceRegistry.get(MongoDB.SERVICE_NAME), diff --git a/server/routes/inviteRoute.js b/server/routes/inviteRoute.js index c50eae9e3..d495c5476 100755 --- a/server/routes/inviteRoute.js +++ b/server/routes/inviteRoute.js @@ -10,8 +10,8 @@ class InviteRoutes { } initRoutes() { - this.router.post("/send", this.inviteController.sendInviteEmail); - this.router.post("/verify", this.inviteController.inviteVerifyController); + this.router.post("/send", verifyJWT, isAllowed(["admin", "superadmin"]), this.inviteController.sendInviteEmail); + this.router.post("/verify", this.inviteController.verifyInviteToken); this.router.post("/", verifyJWT, isAllowed(["admin", "superadmin"]), this.inviteController.getInviteToken); } diff --git a/server/service/business/inviteService.js b/server/service/business/inviteService.js new file mode 100644 index 000000000..b70c151d4 --- /dev/null +++ b/server/service/business/inviteService.js @@ -0,0 +1,40 @@ +const SERVICE_NAME = "inviteService"; +import { createServerError } from "../../utils/errorUtils.js"; + +class InviteService { + static SERVICE_NAME = SERVICE_NAME; + + constructor({ db, settingsService, emailService, stringService }) { + this.db = db; + this.settingsService = settingsService; + this.emailService = emailService; + this.stringService = stringService; + } + + getInviteToken = async ({ invite, teamId }) => { + invite.teamId = teamId; + const inviteToken = await this.db.requestInviteToken(invite); + return inviteToken; + }; + + sendInviteEmail = async ({ inviteRequest, firstName }) => { + const inviteToken = await this.db.requestInviteToken({ ...inviteRequest }); + const { clientHost } = this.settingsService.getSettings(); + + const html = await this.emailService.buildEmail("employeeActivationTemplate", { + name: firstName, + link: `${clientHost}/register/${inviteToken.token}`, + }); + const result = await this.emailService.sendEmail(inviteRequest.email, "Welcome to Uptime Monitor", html); + if (!result) { + throw createServerError("Failed to send invite e-mail... Please verify your settings."); + } + }; + + verifyInviteToken = async ({ inviteToken }) => { + const invite = await this.db.getInviteToken(inviteToken); + return invite; + }; +} + +export default InviteService; diff --git a/server/validation/joi.js b/server/validation/joi.js index 3436195f4..744b0ec74 100755 --- a/server/validation/joi.js +++ b/server/validation/joi.js @@ -87,10 +87,6 @@ const deleteUserParamValidation = joi.object({ email: joi.string().email().required(), }); -const inviteRoleValidation = joi.object({ - roles: joi.custom(roleValidatior(["admin", "superadmin"])).required(), -}); - const inviteBodyValidation = joi.object({ email: joi.string().trim().email().required().messages({ "string.empty": "Email is required", @@ -652,7 +648,6 @@ export { recoveryValidation, recoveryTokenBodyValidation, newPasswordValidation, - inviteRoleValidation, inviteBodyValidation, inviteVerificationBodyValidation, createMonitorBodyValidation, From b3f8d7723810ad39ca38aceb8b3ff66f3706c632 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 15:14:47 -0700 Subject: [PATCH 070/226] add maintenance window service --- server/controllers/inviteController.js | 8 +-- .../maintenanceWindowController.js | 57 +++++------------ server/index.js | 22 ++++--- .../business/maintenanceWindowService.js | 64 +++++++++++++++++++ 4 files changed, 95 insertions(+), 56 deletions(-) create mode 100644 server/service/business/maintenanceWindowService.js diff --git a/server/controllers/inviteController.js b/server/controllers/inviteController.js index 06e38a2e2..75e98cb68 100755 --- a/server/controllers/inviteController.js +++ b/server/controllers/inviteController.js @@ -11,16 +11,10 @@ class InviteController { /** * Creates a new InviteController instance * @param {Object} dependencies - Dependencies injected into the controller - * @param {Object} dependencies.db - Database connection instance - * @param {Object} dependencies.settingsService - Service for managing application settings - * @param {Object} dependencies.emailService - Service for sending emails * @param {Object} dependencies.stringService - Service for internationalized strings * @param {Object} dependencies.inviteService - Service for invite-related operations */ - constructor({ db, settingsService, emailService, stringService, inviteService }) { - this.db = db; - this.settingsService = settingsService; - this.emailService = emailService; + constructor({ stringService, inviteService }) { this.stringService = stringService; this.inviteService = inviteService; } diff --git a/server/controllers/maintenanceWindowController.js b/server/controllers/maintenanceWindowController.js index 9807392cc..95349fa66 100755 --- a/server/controllers/maintenanceWindowController.js +++ b/server/controllers/maintenanceWindowController.js @@ -12,14 +12,15 @@ import { asyncHandler } from "../utils/errorUtils.js"; const SERVICE_NAME = "maintenanceWindowController"; class MaintenanceWindowController { - constructor(db, settingsService, stringService) { + constructor({ db, settingsService, stringService, maintenanceWindowService }) { this.db = db; this.settingsService = settingsService; this.stringService = stringService; + this.maintenanceWindowService = maintenanceWindowService; } createMaintenanceWindows = asyncHandler( - async (req, res, next) => { + async (req, res) => { await createMaintenanceWindowBodyValidation.validateAsync(req.body); const teamId = req?.user?.teamId; @@ -27,32 +28,7 @@ class MaintenanceWindowController { throw new Error("Team ID is required"); } - const monitorIds = req.body.monitors; - - const monitors = await this.db.getMonitorsByIds(monitorIds); - - const unauthorizedMonitors = monitors.filter((monitor) => !monitor.teamId.equals(teamId)); - - if (unauthorizedMonitors.length > 0) { - const error = new Error("Unauthorized access to one or more monitors"); - error.status = 403; - error.service = SERVICE_NAME; - error.method = "createMaintenanceWindows"; - throw error; - } - - const dbTransactions = monitorIds.map((monitorId) => { - return this.db.createMaintenanceWindow({ - teamId, - monitorId, - name: req.body.name, - active: req.body.active ? req.body.active : true, - repeat: req.body.repeat, - start: req.body.start, - end: req.body.end, - }); - }); - await Promise.all(dbTransactions); + await this.maintenanceWindowService.createMaintenanceWindow({ teamId, body: req.body }); return res.success({ msg: this.stringService.maintenanceWindowCreate, @@ -63,7 +39,7 @@ class MaintenanceWindowController { ); getMaintenanceWindowById = asyncHandler( - async (req, res, next) => { + async (req, res) => { await getMaintenanceWindowByIdParamValidation.validateAsync(req.params); const teamId = req.user.teamId; @@ -71,7 +47,8 @@ class MaintenanceWindowController { throw new Error("Team ID is required"); } - const maintenanceWindow = await this.db.getMaintenanceWindowById({ id: req.params.id, teamId }); + const maintenanceWindow = await this.maintenanceWindowService.getMaintenanceWindowById({ id: req.params.id, teamId }); + return res.success({ msg: this.stringService.maintenanceWindowGetById, data: maintenanceWindow, @@ -82,7 +59,7 @@ class MaintenanceWindowController { ); getMaintenanceWindowsByTeamId = asyncHandler( - async (req, res, next) => { + async (req, res) => { await getMaintenanceWindowsByTeamIdQueryValidation.validateAsync(req.query); const teamId = req?.user?.teamId; @@ -91,7 +68,7 @@ class MaintenanceWindowController { throw new Error("Team ID is required"); } - const maintenanceWindows = await this.db.getMaintenanceWindowsByTeamId(teamId, req.query); + const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByTeamId({ teamId, query: req.query }); return res.success({ msg: this.stringService.maintenanceWindowGetByTeam, @@ -103,17 +80,15 @@ class MaintenanceWindowController { ); getMaintenanceWindowsByMonitorId = asyncHandler( - async (req, res, next) => { + async (req, res) => { await getMaintenanceWindowsByMonitorIdParamValidation.validateAsync(req.params); - const monitorId = req.params.monitorId; - const teamId = req?.user?.teamId; if (!teamId) { throw new Error("Team ID is required"); } - const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId({ monitorId, teamId }); + const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByMonitorId({ monitorId: req.params.monitorId, teamId }); return res.success({ msg: this.stringService.maintenanceWindowGetByUser, @@ -125,7 +100,7 @@ class MaintenanceWindowController { ); deleteMaintenanceWindow = asyncHandler( - async (req, res, next) => { + async (req, res) => { await deleteMaintenanceWindowByIdParamValidation.validateAsync(req.params); const teamId = req?.user?.teamId; @@ -133,7 +108,8 @@ class MaintenanceWindowController { throw new Error("Team ID is required"); } - await this.db.deleteMaintenanceWindowById({ id: req.params.id, teamId }); + await this.maintenanceWindowService.deleteMaintenanceWindow({ id: req.params.id, teamId }); + return res.success({ msg: this.stringService.maintenanceWindowDelete, }); @@ -143,7 +119,7 @@ class MaintenanceWindowController { ); editMaintenanceWindow = asyncHandler( - async (req, res, next) => { + async (req, res) => { await editMaintenanceWindowByIdParamValidation.validateAsync(req.params); await editMaintenanceByIdWindowBodyValidation.validateAsync(req.body); @@ -152,8 +128,7 @@ class MaintenanceWindowController { throw new Error("Team ID is required"); } - const editedMaintenanceWindow = await this.db.editMaintenanceWindowById({ id: req.params.id, body: req.body, teamId }); - + const editedMaintenanceWindow = await this.maintenanceWindowService.editMaintenanceWindow({ id: req.params.id, body: req.body, teamId }); return res.success({ msg: this.stringService.maintenanceWindowEdit, data: editedMaintenanceWindow, diff --git a/server/index.js b/server/index.js index 2a8b5259b..9c7778a99 100755 --- a/server/index.js +++ b/server/index.js @@ -62,6 +62,7 @@ import UserService from "./service/business/userService.js"; import CheckService from "./service/business/checkService.js"; import DiagnosticService from "./service/business/diagnosticService.js"; import InviteService from "./service/business/inviteService.js"; +import MaintenanceWindowService from "./service/business/maintenanceWindowService.js"; //Network service and dependencies import NetworkService from "./service/infrastructure/networkService.js"; @@ -205,6 +206,11 @@ const startApp = async () => { emailService, stringService, }); + const maintenanceWindowService = new MaintenanceWindowService({ + db, + settingsService, + stringService, + }); // const jobQueueHelper = new JobQueueHelper({ // redisService, // Queue, @@ -265,6 +271,8 @@ const startApp = async () => { ServiceRegistry.register(RedisService.SERVICE_NAME, redisService); ServiceRegistry.register(UserService.SERVICE_NAME, userService); ServiceRegistry.register(CheckService.SERVICE_NAME, checkService); + ServiceRegistry.register(InviteService.SERVICE_NAME, inviteService); + ServiceRegistry.register(MaintenanceWindowService.SERVICE_NAME, maintenanceWindowService); await translationService.initialize(); @@ -311,18 +319,16 @@ const startApp = async () => { }); const inviteController = new InviteController({ - db: ServiceRegistry.get(MongoDB.SERVICE_NAME), - settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), - emailService: ServiceRegistry.get(EmailService.SERVICE_NAME), stringService: ServiceRegistry.get(StringService.SERVICE_NAME), inviteService, }); - const maintenanceWindowController = new MaintenanceWindowController( - ServiceRegistry.get(MongoDB.SERVICE_NAME), - ServiceRegistry.get(SettingsService.SERVICE_NAME), - ServiceRegistry.get(StringService.SERVICE_NAME) - ); + const maintenanceWindowController = new MaintenanceWindowController({ + db: ServiceRegistry.get(MongoDB.SERVICE_NAME), + settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), + stringService: ServiceRegistry.get(StringService.SERVICE_NAME), + maintenanceWindowService: ServiceRegistry.get(MaintenanceWindowService.SERVICE_NAME), + }); const queueController = new QueueController(ServiceRegistry.get(JobQueue.SERVICE_NAME), ServiceRegistry.get(StringService.SERVICE_NAME)); diff --git a/server/service/business/maintenanceWindowService.js b/server/service/business/maintenanceWindowService.js new file mode 100644 index 000000000..6140a5682 --- /dev/null +++ b/server/service/business/maintenanceWindowService.js @@ -0,0 +1,64 @@ +const SERVICE_NAME = "maintenanceWindowService"; + +class MaintenanceWindowService { + static SERVICE_NAME = SERVICE_NAME; + constructor({ db, settingsService, stringService }) { + this.db = db; + this.settingsService = settingsService; + this.stringService = stringService; + } + + createMaintenanceWindow = async ({ teamId, body }) => { + const monitorIds = body.monitors; + const monitors = await this.db.getMonitorsByIds(monitorIds); + + const unauthorizedMonitors = monitors.filter((monitor) => !monitor.teamId.equals(teamId)); + + if (unauthorizedMonitors.length > 0) { + const error = new Error("Unauthorized access to one or more monitors"); + error.status = 403; + error.service = SERVICE_NAME; + error.method = "createMaintenanceWindows"; + throw error; + } + + const dbTransactions = monitorIds.map((monitorId) => { + return this.db.createMaintenanceWindow({ + teamId, + monitorId, + name: body.name, + active: body.active ? body.active : true, + repeat: body.repeat, + start: body.start, + end: body.end, + }); + }); + await Promise.all(dbTransactions); + }; + + getMaintenanceWindowById = async ({ id, teamId }) => { + const maintenanceWindow = await this.db.getMaintenanceWindowById({ id, teamId }); + return maintenanceWindow; + }; + + getMaintenanceWindowsByTeamId = async ({ teamId, query }) => { + const maintenanceWindows = await this.db.getMaintenanceWindowsByTeamId(teamId, query); + return maintenanceWindows; + }; + + getMaintenanceWindowsByMonitorId = async ({ monitorId, teamId }) => { + const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId({ monitorId, teamId }); + return maintenanceWindows; + }; + + deleteMaintenanceWindow = async ({ id, teamId }) => { + await this.db.deleteMaintenanceWindowById({ id, teamId }); + }; + + editMaintenanceWindow = async ({ id, teamId, body }) => { + const editedMaintenanceWindow = await this.db.editMaintenanceWindowById({ id, body, teamId }); + return editedMaintenanceWindow; + }; +} + +export default MaintenanceWindowService; From 6d5034e2a515d13a30c32ab3bd2a53a577e17169 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 23 Jul 2025 15:31:26 -0700 Subject: [PATCH 071/226] reimplement clone monitor --- client/src/Pages/Uptime/Create/index.jsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/Uptime/Create/index.jsx b/client/src/Pages/Uptime/Create/index.jsx index 3db73481a..761fd6346 100644 --- a/client/src/Pages/Uptime/Create/index.jsx +++ b/client/src/Pages/Uptime/Create/index.jsx @@ -17,12 +17,13 @@ import Checkbox from "../../../Components/Inputs/Checkbox"; // Utils import { useTheme } from "@emotion/react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { monitorValidation } from "../../../Validation/validation"; import { createToast } from "../../../Utils/toastUtils"; import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications"; -import { useCreateMonitor } from "../../../Hooks/monitorHooks"; +import { useCreateMonitor, useFetchMonitorById } from "../../../Hooks/monitorHooks"; +import { useParams } from "react-router-dom"; const CreateMonitor = () => { // Local state @@ -42,12 +43,23 @@ const CreateMonitor = () => { }); // Setup + const MS_PER_MINUTE = 60000; const theme = useTheme(); const { t } = useTranslation(); const [notifications, notificationsAreLoading, error] = useGetNotificationsByTeamId(); const [createMonitor, isCreating] = useCreateMonitor(); + const { monitorId } = useParams(); + + const formatAndSet = (monitor) => { + monitor.interval = monitor.interval / MS_PER_MINUTE; + setMonitor(monitor); + }; + const [isLoading] = useFetchMonitorById({ + monitorId, + setMonitor: formatAndSet, + updateTrigger: true, + }); - const MS_PER_MINUTE = 60000; const SELECT_VALUES = [ { _id: 1, name: "1 minute" }, { _id: 2, name: "2 minutes" }, From 56d8224fb965e994105798f8672e8ff107329d2d Mon Sep 17 00:00:00 2001 From: Jesulayomy Date: Wed, 23 Jul 2025 08:41:50 -0400 Subject: [PATCH 072/226] [Uptime]: Added skeleton file --- client/src/Pages/Uptime/Create/skeleton.jsx | 90 +++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 client/src/Pages/Uptime/Create/skeleton.jsx diff --git a/client/src/Pages/Uptime/Create/skeleton.jsx b/client/src/Pages/Uptime/Create/skeleton.jsx new file mode 100644 index 000000000..9c1014ed4 --- /dev/null +++ b/client/src/Pages/Uptime/Create/skeleton.jsx @@ -0,0 +1,90 @@ +import { Box, Skeleton, Stack } from "@mui/material"; +import { useTheme } from "@emotion/react"; + +/** + * Renders a skeleton layout. + * + * @returns {JSX.Element} + */ +const SkeletonLayout = () => { + const theme = useTheme(); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default SkeletonLayout; From a004b7b450a13daebcdbd2f86ad8fb72bcf2a705 Mon Sep 17 00:00:00 2001 From: Jesulayomy Date: Wed, 23 Jul 2025 09:03:51 -0400 Subject: [PATCH 073/226] [Uptime]: Merged Configure into Create Component --- client/src/Pages/Uptime/Configure/index.css | 11 - client/src/Pages/Uptime/Configure/index.jsx | 526 -------- .../src/Pages/Uptime/Configure/skeleton.jsx | 90 -- client/src/Pages/Uptime/Create/index.jsx | 1179 ++++++++++------- 4 files changed, 702 insertions(+), 1104 deletions(-) delete mode 100644 client/src/Pages/Uptime/Configure/index.css delete mode 100644 client/src/Pages/Uptime/Configure/index.jsx delete mode 100644 client/src/Pages/Uptime/Configure/skeleton.jsx diff --git a/client/src/Pages/Uptime/Configure/index.css b/client/src/Pages/Uptime/Configure/index.css deleted file mode 100644 index 3f24bd011..000000000 --- a/client/src/Pages/Uptime/Configure/index.css +++ /dev/null @@ -1,11 +0,0 @@ -.configure-monitor button.MuiButtonBase-root { - height: var(--env-var-height-2); -} - -.configure-monitor .MuiStack-root:has(span.MuiTypography-root.input-error) { - position: relative; -} -.configure-monitor span.MuiTypography-root.input-error { - position: absolute; - top: 100%; -} diff --git a/client/src/Pages/Uptime/Configure/index.jsx b/client/src/Pages/Uptime/Configure/index.jsx deleted file mode 100644 index 2dacc26ab..000000000 --- a/client/src/Pages/Uptime/Configure/index.jsx +++ /dev/null @@ -1,526 +0,0 @@ -// Components -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import Tooltip from "@mui/material/Tooltip"; -import Typography from "@mui/material/Typography"; -import Button from "@mui/material/Button"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import Switch from "@mui/material/Switch"; -import ConfigBox from "../../../Components/ConfigBox"; -import Breadcrumbs from "../../../Components/Breadcrumbs"; -import TextInput from "../../../Components/Inputs/TextInput"; -import { HttpAdornment } from "../../../Components/Inputs/TextInput/Adornments"; -import Select from "../../../Components/Inputs/Select"; -import Dialog from "../../../Components/Dialog"; -import PulseDot from "../../../Components/Animated/PulseDot"; -import Checkbox from "../../../Components/Inputs/Checkbox"; - -// Utils -import { useParams } from "react-router"; -import { useTheme } from "@emotion/react"; -import { useState } from "react"; -import { monitorValidation } from "../../../Validation/validation"; -import { createToast } from "../../../Utils/toastUtils"; -import { useTranslation } from "react-i18next"; -import PauseOutlinedIcon from "@mui/icons-material/PauseOutlined"; -import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined"; -import { useMonitorUtils } from "../../../Hooks/useMonitorUtils"; -import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications"; -import { - useDeleteMonitor, - useUpdateMonitor, - usePauseMonitor, - useFetchMonitorById, -} from "../../../Hooks/monitorHooks"; -import NotificationsConfig from "../../../Components/NotificationConfig"; - -/** - * Parses a URL string and returns a URL object. - * - * @param {string} url - The URL string to parse. - * @returns {URL} - The parsed URL object if valid, otherwise an empty string. - */ -const parseUrl = (url) => { - try { - return new URL(url); - } catch (error) { - return null; - } -}; - -/** - * Configure page displays monitor configurations and allows for editing actions. - * @component - */ -const Configure = () => { - const { monitorId } = useParams(); - - // Local state - const [form, setForm] = useState({ - ignoreTlsErrors: false, - interval: 60000, - matchMethod: "equal", - expectedValue: "", - jsonPath: "", - notifications: [], - port: "", - type: "http", - }); - const [useAdvancedMatching, setUseAdvancedMatching] = useState(false); - const [updateTrigger, setUpdateTrigger] = useState(false); - const [isOpen, setIsOpen] = useState(false); - const [errors, setErrors] = useState({}); - - const triggerUpdate = () => { - setUpdateTrigger(!updateTrigger); - }; - - // Network - const [notifications, notificationsAreLoading, notificationsError] = - useGetNotificationsByTeamId(); - const [pauseMonitor, isPausing, pauseError] = usePauseMonitor({}); - const [deleteMonitor, isDeleting] = useDeleteMonitor(); - const [updateMonitor, isUpdating] = useUpdateMonitor(); - const [isLoading] = useFetchMonitorById({ - monitorId, - setMonitor: setForm, - updateTrigger, - }); - - const MS_PER_MINUTE = 60000; - const theme = useTheme(); - - const matchMethodOptions = [ - { _id: "equal", name: "Equal" }, - { _id: "include", name: "Include" }, - { _id: "regex", name: "Regex" }, - ]; - - const frequencies = [ - { _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 expectedValuePlaceholders = { - regex: "^(success|ok)$", - equal: "success", - include: "ok", - }; - - // Handlers - const handlePause = async () => { - const res = await pauseMonitor({ monitorId: form?._id, triggerUpdate }); - if (typeof res !== "undefined") { - triggerUpdate(); - } - }; - - const handleRemove = async (event) => { - event.preventDefault(); - await deleteMonitor({ monitor: form, redirect: "/uptime" }); - }; - - const onChange = (event) => { - let { name, value, checked } = event.target; - - if (name === "ignoreTlsErrors") { - value = checked; - } - - if (name === "useAdvancedMatching") { - setForm((prevForm) => { - return { - ...prevForm, - matchMethod: "equal", - }; - }); - setUseAdvancedMatching(!useAdvancedMatching); - return; - } - - if (name === "interval") { - value = value * MS_PER_MINUTE; - } - setForm({ ...form, [name]: value }); - - const validation = monitorValidation.validate( - { [name]: value }, - { abortEarly: false } - ); - - setErrors((prev) => { - const updatedErrors = { ...prev }; - if (validation.error) updatedErrors[name] = validation.error.details[0].message; - else delete updatedErrors[name]; - return updatedErrors; - }); - }; - - const onSubmit = async (e) => { - e.preventDefault(); - - const toSubmit = { - _id: form._id, - url: form.url, - name: form.name, - type: form.type, - matchMethod: form.matchMethod, - expectedValue: form.expectedValue, - jsonPath: form.jsonPath, - interval: form.interval, - teamId: form.teamId, - userId: form.userId, - port: form.port, - ignoreTlsErrors: form.ignoreTlsErrors, - }; - - if (!useAdvancedMatching) { - toSubmit.matchMethod = ""; - toSubmit.expectedValue = ""; - toSubmit.jsonPath = ""; - } - - const validation = monitorValidation.validate(toSubmit, { - abortEarly: false, - }); - - if (validation.error) { - const newErrors = {}; - validation.error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - setErrors(newErrors); - createToast({ body: "Please check the form for errors." }); - return; - } - - toSubmit.notifications = form.notifications; - await updateMonitor({ monitor: toSubmit, redirect: "/uptime" }); - }; - - // Parse the URL - const parsedUrl = parseUrl(form?.url); - const protocol = parsedUrl?.protocol?.replace(":", "") || ""; - - const { determineState, statusColor } = useMonitorUtils(); - - const { t } = useTranslation(); - - return ( - - - - - - - {form.name} - - - - - - - - - {form.url?.replace(/^https?:\/\//, "") || "..."} - - - {t("editing")} - - - - - - - - - - - - {t("settingsGeneralSettings")} - - {t("distributedUptimeCreateSelectURL")} - - - - } - id="monitor-url" - label={t("urlMonitor")} - placeholder="google.com" - value={parsedUrl?.host || form?.url || ""} - disabled={true} - /> - - - - - {t("notificationConfig.title")} - {t("notificationConfig.description")} - - - - - - - {t("ignoreTLSError")} - - {t("ignoreTLSErrorDescription")} - - - - } - label={t("tlsErrorIgnored")} - /> - - - - - - {t("distributedUptimeCreateAdvancedSettings")} - - - - - - - - {t("uptimeCreate")} - - - - - - {t("uptimeCreateJsonPath")}  - - jmespath.org - -  {t("uptimeCreateJsonPathQuery")} - - - - )} - - - - - - - setIsOpen(false)} - confirmationButtonLabel="Delete" - onConfirm={handleRemove} - isLoading={isLoading} - /> - - ); -}; - -export default Configure; diff --git a/client/src/Pages/Uptime/Configure/skeleton.jsx b/client/src/Pages/Uptime/Configure/skeleton.jsx deleted file mode 100644 index 9c1014ed4..000000000 --- a/client/src/Pages/Uptime/Configure/skeleton.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Box, Skeleton, Stack } from "@mui/material"; -import { useTheme } from "@emotion/react"; - -/** - * Renders a skeleton layout. - * - * @returns {JSX.Element} - */ -const SkeletonLayout = () => { - const theme = useTheme(); - - return ( - <> - - - - - - - - - - - - - - - - - - - - - ); -}; - -export default SkeletonLayout; diff --git a/client/src/Pages/Uptime/Create/index.jsx b/client/src/Pages/Uptime/Create/index.jsx index 761fd6346..c6caeacb1 100644 --- a/client/src/Pages/Uptime/Create/index.jsx +++ b/client/src/Pages/Uptime/Create/index.jsx @@ -1,4 +1,14 @@ //Components +import { + Box, + Button, + ButtonGroup, + FormControlLabel, + Stack, + Switch, + Tooltip, + Typography, +} from "@mui/material"; import Breadcrumbs from "../../../Components/Breadcrumbs"; import TextInput from "../../../Components/Inputs/TextInput"; import { HttpAdornment } from "../../../Components/Inputs/TextInput/Adornments"; @@ -6,494 +16,709 @@ import Radio from "../../../Components/Inputs/Radio"; import Select from "../../../Components/Inputs/Select"; import ConfigBox from "../../../Components/ConfigBox"; import NotificationsConfig from "../../../Components/NotificationConfig"; -import Button from "@mui/material/Button"; -import ButtonGroup from "@mui/material/ButtonGroup"; -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import Switch from "@mui/material/Switch"; -import FormControlLabel from "@mui/material/FormControlLabel"; import Checkbox from "../../../Components/Inputs/Checkbox"; +import Dialog from "../../../Components/Dialog"; +import PulseDot from "../../../Components/Animated/PulseDot"; +import SkeletonLayout from "./skeleton"; // Utils +import { useParams } from "react-router"; import { useTheme } from "@emotion/react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { monitorValidation } from "../../../Validation/validation"; import { createToast } from "../../../Utils/toastUtils"; +import { useTranslation } from "react-i18next"; +import { + PauseOutlined as PauseOutlinedIcon, + PlayArrowOutlined as PlayArrowOutlinedIcon, +} from "@mui/icons-material"; +import { useMonitorUtils } from "../../../Hooks/useMonitorUtils"; import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications"; import { useCreateMonitor, useFetchMonitorById } from "../../../Hooks/monitorHooks"; import { useParams } from "react-router-dom"; +import { + useDeleteMonitor, + useUpdateMonitor, + usePauseMonitor, + useFetchMonitorById, +} from "../../../Hooks/monitorHooks"; +import { useCreateMonitor } from "../../../Hooks/monitorHooks"; -const CreateMonitor = () => { - // Local state - const [errors, setErrors] = useState({}); - const [https, setHttps] = useState(true); - const [useAdvancedMatching, setUseAdvancedMatching] = useState(false); - const [monitor, setMonitor] = useState({ - url: "", - name: "", - type: "http", - matchMethod: "equal", - expectedValue: "", - jsonPath: "", - notifications: [], - interval: 1, - ignoreTlsErrors: false, - }); - - // Setup - const MS_PER_MINUTE = 60000; - const theme = useTheme(); - const { t } = useTranslation(); - const [notifications, notificationsAreLoading, error] = useGetNotificationsByTeamId(); - const [createMonitor, isCreating] = useCreateMonitor(); - const { monitorId } = useParams(); - - const formatAndSet = (monitor) => { - monitor.interval = monitor.interval / MS_PER_MINUTE; - setMonitor(monitor); - }; - const [isLoading] = useFetchMonitorById({ - monitorId, - setMonitor: formatAndSet, - updateTrigger: true, - }); - - 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 matchMethodOptions = [ - { _id: "equal", name: "Equal" }, - { _id: "include", name: "Include" }, - { _id: "regex", name: "Regex" }, - ]; - - const expectedValuePlaceholders = { - regex: "^(success|ok)$", - equal: "success", - include: "ok", - }; - - const monitorTypeMaps = { - http: { - label: "URL to monitor", - placeholder: "google.com", - namePlaceholder: "Google", - }, - ping: { - label: "IP address to monitor", - placeholder: "1.1.1.1", - namePlaceholder: "Google", - }, - docker: { - label: "Container ID", - placeholder: "abc123", - namePlaceholder: "My Container", - }, - port: { - label: "URL to monitor", - placeholder: "localhost", - namePlaceholder: "Localhost:5173", - }, - }; - - const BREADCRUMBS = [ - { name: "uptime", path: "/uptime" }, - { name: "create", path: `/uptime/create` }, - ]; - - // Handlers - - const onSubmit = async (event) => { - event.preventDefault(); - const { notifications, ...rest } = monitor; - - let form = { - ...rest, - url: - //prepending protocol for url - monitor.type === "http" - ? `http${https ? "s" : ""}://` + monitor.url - : monitor.url, - port: monitor.type === "port" ? monitor.port : undefined, - name: monitor.name || monitor.url.substring(0, 50), - type: monitor.type, - interval: monitor.interval * MS_PER_MINUTE, - }; - - // If not using advanced matching, remove advanced settings - if (!useAdvancedMatching) { - form.matchMethod = undefined; - form.expectedValue = undefined; - form.jsonPath = undefined; - } - - const { error } = monitorValidation.validate(form, { - 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; - } - - form = { - ...form, - description: monitor.name || monitor.url, - notifications: monitor.notifications, - }; - - await createMonitor({ monitor: form, redirect: "/uptime" }); - }; - - const onChange = (event) => { - const { name, value, checked } = event.target; - - let newValue = value; - if (name === "ignoreTlsErrors") { - newValue = checked; - } - - if (name === "useAdvancedMatching") { - setUseAdvancedMatching(checked); - return; - } - - const updatedMonitor = { - ...monitor, - [name]: newValue, - }; - - setMonitor(updatedMonitor); - - const { error } = monitorValidation.validate( - { type: monitor.type, [name]: newValue }, - { abortEarly: false } - ); - - setErrors((prev) => ({ - ...prev, - ...(error ? { [name]: error.details[0].message } : { [name]: undefined }), - })); - }; - - return ( - - - - - - {t("createYour")}{" "} - - - {t("monitor")} - - - - - - - {t("distributedUptimeCreateChecks")} - - - {t("distributedUptimeCreateChecksDescription")} - - - - - - {monitor.type === "http" ? ( - - - - - ) : ( - "" - )} - - - - - {errors["type"] ? ( - - - {errors["type"]} - - - ) : ( - "" - )} - - - - - - {t("settingsGeneralSettings")} - - - {t(`uptimeGeneralInstructions.${monitor.type}`)} - - - - : null - } - label={monitorTypeMaps[monitor.type].label || "URL to monitor"} - https={https} - placeholder={monitorTypeMaps[monitor.type].placeholder || ""} - value={monitor.url} - onChange={onChange} - error={errors["url"] ? true : false} - helperText={errors["url"]} - /> - - - - - {t("notificationConfig.title")} - {t("notificationConfig.description")} - - - - - - - {t("ignoreTLSError")} - - {t("ignoreTLSErrorDescription")} - - - - } - label={t("tlsErrorIgnored")} - /> - - - - - - {t("distributedUptimeCreateAdvancedSettings")} - - - - - - - - {t("uptimeCreate")} - - - - - - {t("uptimeCreateJsonPath")}  - - jmespath.org - -  {t("uptimeCreateJsonPathQuery")} - - - - )} - - - - - - - - ); +/** + * Parses a URL string and returns a URL object. + * + * @param {string} url - The URL string to parse. + * @returns {URL} - The parsed URL object if valid, otherwise an empty string. + */ +const parseUrl = (url) => { + try { + return new URL(url); + } catch (error) { + return null; + } }; -export default CreateMonitor; +/** + * Create page renders monitor creation or configuration views. + * @component + */ +const UptimeCreate = () => { + const { monitorId } = useParams(); + const isCreate = typeof monitorId === "undefined"; + + // States + const [monitor, setMonitor] = useState({ + type: "http", + matchMethod: "equal", + expectedValue: "", + jsonPath: "", + notifications: [], + interval: 60000, + ignoreTlsErrors: false, + ...(isCreate ? { url: "", name: "" } : { port: undefined }), + }); + const [errors, setErrors] = useState({}); + const [https, setHttps] = useState(true); + const [isOpen, setIsOpen] = useState(false); + const [useAdvancedMatching, setUseAdvancedMatching] = useState(false); + const [updateTrigger, setUpdateTrigger] = useState(false); + const triggerUpdate = () => { + setUpdateTrigger(!updateTrigger); + }; + + // Hooks + const [notifications, notificationsAreLoading, notificationsError] = + useGetNotificationsByTeamId(); + const { determineState, statusColor } = useMonitorUtils(); + // Network + const [isLoading] = useFetchMonitorById({ + monitorId, + setMonitor: formatAndSet, + updateTrigger: true, + }); + const [createMonitor, isCreating] = useCreateMonitor(); + const [pauseMonitor, isPausing] = usePauseMonitor({}); + const [deleteMonitor, isDeleting] = useDeleteMonitor(); + const [updateMonitor, isUpdating] = useUpdateMonitor(); + + // Setup + const theme = useTheme(); + const { t } = useTranslation(); + const formatAndSet = (monitor) => { + monitor.interval = monitor.interval / MS_PER_MINUTE; + setMonitor(monitor); + }; + + // Constants + const MS_PER_MINUTE = 60000; + const FREQUENCIES = [ + { _id: 1, name: t("time.oneMinute") }, + { _id: 2, name: t("time.twoMinutes") }, + { _id: 3, name: t("time.threeMinutes") }, + { _id: 4, name: t("time.fourMinutes") }, + { _id: 5, name: t("time.fiveMinutes") }, + ]; + const CRUMBS = [ + { name: "uptime", path: "/uptime" }, + ...(isCreate + ? [{ name: "create", path: `/uptime/create` }] + : [ + { name: "details", path: `/uptime/${monitorId}` }, + { name: "configure", path: `/uptime/configure/${monitorId}` }, + ]), + ]; + const matchMethodOptions = [ + { _id: "equal", name: t("matchMethodOptions.equal") }, + { _id: "include", name: t("matchMethodOptions.include") }, + { _id: "regex", name: t("matchMethodOptions.regex") }, + ]; + const expectedValuePlaceholders = { + regex: t("matchMethodOptions.regexPlaceholder"), + equal: t("matchMethodOptions.equalPlaceholder"), + include: t("matchMethodOptions.includePlaceholder"), + }; + const monitorTypeMaps = { + http: { + label: t("monitorType.http.label"), + placeholder: t("monitorType.http.placeholder"), + namePlaceholder: t("monitorType.http.namePlaceholder"), + }, + ping: { + label: t("monitorType.ping.label"), + placeholder: t("monitorType.ping.placeholder"), + namePlaceholder: t("monitorType.ping.namePlaceholder"), + }, + docker: { + label: t("monitorType.docker.label"), + placeholder: t("monitorType.docker.placeholder"), + namePlaceholder: t("monitorType.docker.namePlaceholder"), + }, + port: { + label: t("monitorType.port.label"), + placeholder: t("monitorType.port.placeholder"), + namePlaceholder: t("monitorType.port.namePlaceholder"), + }, + }; + + // Handlers + const onSubmit = async (event) => { + event.preventDefault(); + const { notifications, ...rest } = monitor; + + let form = {}; + if (isCreate) { + form = { + ...rest, + url: + monitor.type === "http" + ? `http${https ? "s" : ""}://` + monitor.url + : monitor.url, + port: monitor.type === "port" ? monitor.port : undefined, + name: monitor.name || monitor.url.substring(0, 50), + type: monitor.type, + interval: monitor.interval, + }; + } else { + form = { + _id: monitor._id, + url: monitor.url, + name: monitor.name || monitor.url.substring(0, 50), + type: monitor.type, + matchMethod: monitor.matchMethod, + expectedValue: monitor.expectedValue, + jsonPath: monitor.jsonPath, + interval: monitor.interval, + teamId: monitor.teamId, + userId: monitor.userId, + port: monitor.type === "port" ? monitor.port : undefined, + ignoreTlsErrors: monitor.ignoreTlsErrors, + }; + } + if (!useAdvancedMatching) { + form.matchMethod = isCreate ? undefined : ""; + form.expectedValue = isCreate ? undefined : ""; + form.jsonPath = isCreate ? undefined : ""; + } + + const { error } = monitorValidation.validate(form, { + abortEarly: false, + }); + + if (error) { + const newErrors = {}; + error.details.forEach((err) => { + newErrors[err.path[0]] = err.message; + }); + setErrors(newErrors); + createToast({ body: t("checkFormError") }); + return; + } + + form = { + ...form, + description: monitor.name || monitor.url, + notifications: monitor.notifications, + }; + + if (isCreate) { + await createMonitor({ monitor: form, redirect: "/uptime" }); + } else { + await updateMonitor({ monitor: form, redirect: "/uptime" }); + } + }; + + const onChange = (event) => { + let { name, value, checked } = event.target; + + if (name === "ignoreTlsErrors") { + value = checked; + } + + if (name === "useAdvancedMatching") { + setUseAdvancedMatching(checked); + return; + } + + if (name === "interval") { + value = value * MS_PER_MINUTE; + } + + setMonitor((prev) => ({ ...prev, [name]: value })); + + const { error } = monitorValidation.validate( + { type: monitor.type, [name]: value }, + { abortEarly: false } + ); + + setErrors((prev) => ({ + ...prev, + ...(error ? { [name]: error.details[0].message } : { [name]: undefined }), + })); + }; + + const handlePause = async () => { + await pauseMonitor({ monitorId, triggerUpdate }); + }; + + const handleRemove = async (event) => { + event.preventDefault(); + await deleteMonitor({ monitor, redirect: "/uptime" }); + }; + + const isBusy = isLoading || isCreating || isDeleting || isUpdating || isPausing; + const parsedUrl = parseUrl(monitor?.url); + const protocol = parsedUrl?.protocol?.replace(":", "") || ""; + + if (Object.keys(monitor).length === 0) { + return ; + } + useEffect(() => { + if (!isCreate) { + if (monitor.matchMethod) { + setUseAdvancedMatching(true); + } else { + setUseAdvancedMatching(false); + } + } + }, [monitor]); + + return ( + + + + + + + + + {!isCreate ? monitor.name : t("createYour") + " "} + + {isCreate && ( + + {t("monitor")} + + )} + + {!isCreate && ( + + + + + + + + {monitor.url?.replace(/^https?:\/\//, "") || "..."} + + + {t("editing")} + + + )} + + {!isCreate && ( + + + + + )} + + {isCreate && ( + + + + {t("distributedUptimeCreateChecks")} + + + {t("distributedUptimeCreateChecksDescription")} + + + + + + {monitor.type === "http" ? ( + + + + + ) : ( + "" + )} + + + + + {errors["type"] ? ( + + + {errors["type"]} + + + ) : ( + "" + )} + + + )} + + + + {t("settingsGeneralSettings")} + + + {isCreate + ? t(`uptimeGeneralInstructions.${monitor.type}`) + : t("distributedUptimeCreateSelectURL")} + + + + + ) + } + helperText={errors["url"]} + onChange={onChange} + disabled={!isCreate} + /> + + + + + {t("notificationConfig.title")} + {t("notificationConfig.description")} + + + + + + + {t("ignoreTLSError")} + + {t("ignoreTLSErrorDescription")} + + + + } + label={t("tlsErrorIgnored")} + /> + + + + + + {t("distributedUptimeCreateAdvancedSettings")} + + + + + + + + {t("uptimeCreate")} + + + + + + {t("uptimeCreateJsonPath") + " "} + + jmespath.org + + {" " + t("uptimeCreateJsonPathQuery")} + + + + )} + + + + + + + + {!isCreate && ( + setIsOpen(false)} + confirmationButtonLabel={t("delete")} + onConfirm={handleRemove} + isLoading={isLoading} + /> + )} + + ); +}; + +export default UptimeCreate; From 5f36543d44efd656c76cab7a128ab7f6e4e83568 Mon Sep 17 00:00:00 2001 From: Jesulayomy Date: Wed, 23 Jul 2025 09:05:33 -0400 Subject: [PATCH 074/226] [Uptime]: Removed UptimeConfigure component, added extra translations for Uptime and other monitor pages --- client/src/Routes/index.jsx | 5 ++--- client/src/locales/en.json | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 6de07a2de..5bb89db58 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -14,7 +14,6 @@ import AuthNewPasswordConfirmed from "../Pages/Auth/NewPasswordConfirmed"; import Uptime from "../Pages/Uptime/Monitors"; import UptimeDetails from "../Pages/Uptime/Details"; import UptimeCreate from "../Pages/Uptime/Create"; -import UptimeConfigure from "../Pages/Uptime/Configure"; // PageSpeed import PageSpeed from "../Pages/PageSpeed/Monitors"; @@ -81,7 +80,7 @@ const Routes = () => { /> } /> { /> } + element={} /> Date: Wed, 23 Jul 2025 19:59:45 -0400 Subject: [PATCH 075/226] [Locales]: Sorted translations, fixed a duplicate key from the original branch --- client/src/locales/en.json | 82 +++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 45 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 6d5ec2948..584894543 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -378,6 +378,7 @@ "dockerContainerMonitoringDescription": "Check whether your Docker container is running or not.", "duration": "Duration", "edit": "Edit", + "editing": "Editing...", "editMaintenance": "Edit maintenance", "editUserPage": { "form": { @@ -397,7 +398,6 @@ "validationErrors": "Validation errors" } }, - "editing": "Editing...", "emailSent": "Email sent successfully", "errorInvalidFieldId": "Invalid field ID provided", "errorInvalidTypeId": "Invalid notification type provided", @@ -453,8 +453,8 @@ "incidentsOptionsHeaderShow": "Show:", "incidentsOptionsHeaderTotalIncidents": "Total Incidents", "incidentsOptionsPlaceholderAllServers": "All servers", - "incidentsPageActionResolveMonitor": "Resolve monitor incidents", "incidentsPageActionResolveAll": "Resolve all incidents", + "incidentsPageActionResolveMonitor": "Resolve monitor incidents", "incidentsPageTitle": "Incidents", "incidentsTableActionResolve": "Resolve", "incidentsTableDateTime": "Date & Time", @@ -476,13 +476,13 @@ "infrastructureEditYour": "Edit your", "infrastructureMonitor": { "fallback": { + "actionButton": "Let's create your first infrastructure monitor!", "checks": [ "Track the performance of your servers", "Identify bottlenecks and optimize usage", "Ensure reliability with real-time monitoring" ], - "title": "An infrastructure monitor is used to:", - "actionButton": "Let's create your first infrastructure monitor!" + "title": "An infrastructure monitor is used to:" } }, "infrastructureMonitorCreated": "Infrastructure monitor created successfully!", @@ -530,13 +530,13 @@ "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", "maintenanceWindow": { "fallback": { + "actionButton": "Let's create your first maintenance window!", "checks": [ "Mark your maintenance periods", "Eliminate any misunderstandings", "Stop sending alerts in maintenance windows" ], - "title": "A maintenance window is used to:", - "actionButton": "Let's create your first maintenance window!" + "title": "A maintenance window is used to:" } }, "maintenanceWindowDescription": "Your pings won't be sent during this time frame", @@ -591,6 +591,7 @@ "failureAddDemoMonitors": "Failed to add demo monitors", "successAddDemoMonitors": "Successfully added demo monitors" }, + "monitors": "monitors", "monitorState": { "active": "Active", "paused": "Paused", @@ -605,28 +606,27 @@ }, "monitorStatusDown": "Monitor {name} ({url}) is DOWN and not responding", "monitorStatusUp": "Monitor {name} ({url}) is now UP and responding", - "monitors": "monitors", "monitorsToApply": "Monitors to apply maintenance window to", "monitorType": { "docker": { "label": "Container ID", - "placeholder": "abcd1234", - "namePlaceholder": "My Container" + "namePlaceholder": "My Container", + "placeholder": "abcd1234" }, "http": { "label": "URL to monitor", - "placeholder": "google.com", - "namePlaceholder": "Google" + "namePlaceholder": "Google", + "placeholder": "google.com" }, "ping": { "label": "IP address to monitor", - "placeholder": "1.1.1.1", - "namePlaceholder": "Google" + "namePlaceholder": "Google", + "placeholder": "1.1.1.1" }, "port": { "label": "URL to monitor", - "placeholder": "localhost", - "namePlaceholder": "Localhost:5173" + "namePlaceholder": "Localhost:5173", + "placeholder": "localhost" } }, "ms": "ms", @@ -662,13 +662,13 @@ }, "enableNotifications": "Enable {{platform}} notifications", "fallback": { + "actionButton": "Let's create your first notification channel!", "checks": [ "Alert teams about downtime or performance issues", "Let engineers know when incidents happen", "Keep administrators informed of system changes" ], - "title": "A notification channel is used to:", - "actionButton": "Let's create your first notification channel!" + "title": "A notification channel is used to:" }, "fetch": { "failed": "Failed to fetch notifications", @@ -715,13 +715,13 @@ "os": "OS", "pageSpeed": { "fallback": { + "actionButton": "Let's create your first PageSpeed monitor!", "checks": [ "Report on the user experience of a page", "Help analyze webpage speed", "Give suggestions on how the page can be improved" ], - "title": "A PageSpeed monitor is used to:", - "actionButton": "Let's create your first PageSpeed monitor!" + "title": "A PageSpeed monitor is used to:" } }, "pageSpeedAddApiKey": "to add your API key.", @@ -754,8 +754,8 @@ "queuePage": { "failedJobTable": { "failCountHeader": "Fail count", - "failReasonHeader": "Fail reason", "failedAtHeader": "Last failed at", + "failReasonHeader": "Fail reason", "monitorIdHeader": "Monitor ID", "monitorUrlHeader": "Monitor URL", "title": "Failed jobs" @@ -896,8 +896,8 @@ "settingsTestEmailUnknownError": "Unknown error", "showAdminLoginLink": "Show \"Administrator? Login Here\" link on the status page", "showCharts": "Show charts", - "showUptimePercentage": "Show uptime percentage", "shown": "Shown", + "showUptimePercentage": "Show uptime percentage", "starPromptDescription": "See the latest releases and help grow the community on GitHub", "starPromptTitle": "Star Checkmate", "startTime": "Start time", @@ -917,6 +917,15 @@ "createSuccess": "Status page created successfully", "deleteFailed": "Failed to delete status page", "deleteSuccess": "Status page deleted successfully", + "fallback": { + "actionButton": "Let's create your first status page!", + "checks": [ + "Monitor and display the health of your services in real time", + "Track multiple services and share their status", + "Keep users informed about outages and performance" + ], + "title": "A status page is used to:" + }, "generalSettings": "General settings", "updateSuccess": "Status page updated successfully" }, @@ -939,23 +948,6 @@ "statusPageStatusNoPage": "There's no status page here.", "statusPageStatusNotPublic": "This status page is not public.", "statusPageStatusServiceStatus": "Service status", - "statusPage": { - "deleteSuccess": "Status page deleted successfully", - "deleteFailed": "Failed to delete status page", - "createSuccess": "Status page created successfully", - "updateSuccess": "Status page updated successfully", - "generalSettings": "General settings", - "contents": "Contents", - "fallback": { - "checks": [ - "Monitor and display the health of your services in real time", - "Track multiple services and share their status", - "Keep users informed about outages and performance" - ], - "title": "A status page is used to:", - "actionButton": "Let's create your first status page!" - } - }, "submit": "Submit", "teamPanel": { "cancel": "Cancel", @@ -983,19 +975,19 @@ "testLocale": "testLocale", "testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button", "time": { - "oneMinute": "1 minute", - "twoMinutes": "2 minutes", - "fourMinutes": "4 minutes", "fiveMinutes": "5 minutes", + "fourMinutes": "4 minutes", "oneDay": "1 day", "oneHour": "1 hour", + "oneMinute": "1 minute", "oneWeek": "1 week", "tenMinutes": "10 minutes", "threeMinutes": "3 minutes", - "twentyMinutes": "20 minutes" + "twentyMinutes": "20 minutes", + "twoMinutes": "2 minutes" }, - "timeZoneInfo": "All dates and times are in GMT+0 time zone.", "timezone": "Timezone", + "timeZoneInfo": "All dates and times are in GMT+0 time zone.", "title": "Title", "tlsErrorIgnored": "TLS/SSL errors ignored", "total": "Total", @@ -1016,14 +1008,14 @@ }, "uptimeMonitor": { "fallback": { + "actionButton": "Let's create your first uptime monitor!", "checks": [ "Check if websites or servers are online & responsive", "Alert teams about downtime or performance issues", "Monitor HTTP endpoints, pings, containers & ports", "Track historical uptime and reliability trends" ], - "title": "An uptime monitor is used to:", - "actionButton": "Let's create your first uptime monitor!" + "title": "An uptime monitor is used to:" } }, "url": "URL", From d3493a0af678867610be397affb858912c6719a0 Mon Sep 17 00:00:00 2001 From: Jesulayomy Date: Wed, 23 Jul 2025 20:01:39 -0400 Subject: [PATCH 076/226] [Uptime]: Used latest formatting rules --- client/src/Pages/Uptime/Create/index.jsx | 1301 +++++++++++----------- 1 file changed, 650 insertions(+), 651 deletions(-) diff --git a/client/src/Pages/Uptime/Create/index.jsx b/client/src/Pages/Uptime/Create/index.jsx index c6caeacb1..fcf81f1c3 100644 --- a/client/src/Pages/Uptime/Create/index.jsx +++ b/client/src/Pages/Uptime/Create/index.jsx @@ -1,13 +1,13 @@ //Components import { - Box, - Button, - ButtonGroup, - FormControlLabel, - Stack, - Switch, - Tooltip, - Typography, + Box, + Button, + ButtonGroup, + FormControlLabel, + Stack, + Switch, + Tooltip, + Typography, } from "@mui/material"; import Breadcrumbs from "../../../Components/Breadcrumbs"; import TextInput from "../../../Components/Inputs/TextInput"; @@ -30,20 +30,19 @@ import { monitorValidation } from "../../../Validation/validation"; import { createToast } from "../../../Utils/toastUtils"; import { useTranslation } from "react-i18next"; import { - PauseOutlined as PauseOutlinedIcon, - PlayArrowOutlined as PlayArrowOutlinedIcon, + PauseOutlined as PauseOutlinedIcon, + PlayArrowOutlined as PlayArrowOutlinedIcon, } from "@mui/icons-material"; import { useMonitorUtils } from "../../../Hooks/useMonitorUtils"; import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications"; import { useCreateMonitor, useFetchMonitorById } from "../../../Hooks/monitorHooks"; import { useParams } from "react-router-dom"; import { - useDeleteMonitor, - useUpdateMonitor, - usePauseMonitor, - useFetchMonitorById, + useDeleteMonitor, + useUpdateMonitor, + usePauseMonitor, + useFetchMonitorById, } from "../../../Hooks/monitorHooks"; -import { useCreateMonitor } from "../../../Hooks/monitorHooks"; /** * Parses a URL string and returns a URL object. @@ -52,11 +51,11 @@ import { useCreateMonitor } from "../../../Hooks/monitorHooks"; * @returns {URL} - The parsed URL object if valid, otherwise an empty string. */ const parseUrl = (url) => { - try { - return new URL(url); - } catch (error) { - return null; - } + try { + return new URL(url); + } catch (error) { + return null; + } }; /** @@ -64,661 +63,661 @@ const parseUrl = (url) => { * @component */ const UptimeCreate = () => { - const { monitorId } = useParams(); - const isCreate = typeof monitorId === "undefined"; + const { monitorId } = useParams(); + const isCreate = typeof monitorId === "undefined"; - // States - const [monitor, setMonitor] = useState({ - type: "http", - matchMethod: "equal", - expectedValue: "", - jsonPath: "", - notifications: [], - interval: 60000, - ignoreTlsErrors: false, - ...(isCreate ? { url: "", name: "" } : { port: undefined }), - }); - const [errors, setErrors] = useState({}); - const [https, setHttps] = useState(true); - const [isOpen, setIsOpen] = useState(false); - const [useAdvancedMatching, setUseAdvancedMatching] = useState(false); - const [updateTrigger, setUpdateTrigger] = useState(false); - const triggerUpdate = () => { - setUpdateTrigger(!updateTrigger); - }; + // States + const [monitor, setMonitor] = useState({ + type: "http", + matchMethod: "equal", + expectedValue: "", + jsonPath: "", + notifications: [], + interval: 60000, + ignoreTlsErrors: false, + ...(isCreate ? { url: "", name: "" } : { port: undefined }), + }); + const [errors, setErrors] = useState({}); + const [https, setHttps] = useState(true); + const [isOpen, setIsOpen] = useState(false); + const [useAdvancedMatching, setUseAdvancedMatching] = useState(false); + const [updateTrigger, setUpdateTrigger] = useState(false); + const triggerUpdate = () => { + setUpdateTrigger(!updateTrigger); + }; - // Hooks - const [notifications, notificationsAreLoading, notificationsError] = - useGetNotificationsByTeamId(); - const { determineState, statusColor } = useMonitorUtils(); - // Network - const [isLoading] = useFetchMonitorById({ - monitorId, - setMonitor: formatAndSet, - updateTrigger: true, - }); - const [createMonitor, isCreating] = useCreateMonitor(); - const [pauseMonitor, isPausing] = usePauseMonitor({}); - const [deleteMonitor, isDeleting] = useDeleteMonitor(); - const [updateMonitor, isUpdating] = useUpdateMonitor(); + // Hooks + const [notifications, notificationsAreLoading, notificationsError] = + useGetNotificationsByTeamId(); + const { determineState, statusColor } = useMonitorUtils(); + // Network + const [isLoading] = useFetchMonitorById({ + monitorId, + setMonitor: formatAndSet, + updateTrigger: true, + }); + const [createMonitor, isCreating] = useCreateMonitor(); + const [pauseMonitor, isPausing] = usePauseMonitor({}); + const [deleteMonitor, isDeleting] = useDeleteMonitor(); + const [updateMonitor, isUpdating] = useUpdateMonitor(); - // Setup - const theme = useTheme(); - const { t } = useTranslation(); - const formatAndSet = (monitor) => { - monitor.interval = monitor.interval / MS_PER_MINUTE; - setMonitor(monitor); - }; + // Setup + const theme = useTheme(); + const { t } = useTranslation(); - // Constants - const MS_PER_MINUTE = 60000; - const FREQUENCIES = [ - { _id: 1, name: t("time.oneMinute") }, - { _id: 2, name: t("time.twoMinutes") }, - { _id: 3, name: t("time.threeMinutes") }, - { _id: 4, name: t("time.fourMinutes") }, - { _id: 5, name: t("time.fiveMinutes") }, - ]; - const CRUMBS = [ - { name: "uptime", path: "/uptime" }, - ...(isCreate - ? [{ name: "create", path: `/uptime/create` }] - : [ - { name: "details", path: `/uptime/${monitorId}` }, - { name: "configure", path: `/uptime/configure/${monitorId}` }, - ]), - ]; - const matchMethodOptions = [ - { _id: "equal", name: t("matchMethodOptions.equal") }, - { _id: "include", name: t("matchMethodOptions.include") }, - { _id: "regex", name: t("matchMethodOptions.regex") }, - ]; - const expectedValuePlaceholders = { - regex: t("matchMethodOptions.regexPlaceholder"), - equal: t("matchMethodOptions.equalPlaceholder"), - include: t("matchMethodOptions.includePlaceholder"), - }; - const monitorTypeMaps = { - http: { - label: t("monitorType.http.label"), - placeholder: t("monitorType.http.placeholder"), - namePlaceholder: t("monitorType.http.namePlaceholder"), - }, - ping: { - label: t("monitorType.ping.label"), - placeholder: t("monitorType.ping.placeholder"), - namePlaceholder: t("monitorType.ping.namePlaceholder"), - }, - docker: { - label: t("monitorType.docker.label"), - placeholder: t("monitorType.docker.placeholder"), - namePlaceholder: t("monitorType.docker.namePlaceholder"), - }, - port: { - label: t("monitorType.port.label"), - placeholder: t("monitorType.port.placeholder"), - namePlaceholder: t("monitorType.port.namePlaceholder"), - }, - }; + // Constants + const MS_PER_MINUTE = 60000; + const FREQUENCIES = [ + { _id: 1, name: t("time.oneMinute") }, + { _id: 2, name: t("time.twoMinutes") }, + { _id: 3, name: t("time.threeMinutes") }, + { _id: 4, name: t("time.fourMinutes") }, + { _id: 5, name: t("time.fiveMinutes") }, + ]; + const CRUMBS = [ + { name: "uptime", path: "/uptime" }, + ...(isCreate + ? [{ name: "create", path: `/uptime/create` }] + : [ + { name: "details", path: `/uptime/${monitorId}` }, + { name: "configure", path: `/uptime/configure/${monitorId}` }, + ]), + ]; + const matchMethodOptions = [ + { _id: "equal", name: t("matchMethodOptions.equal") }, + { _id: "include", name: t("matchMethodOptions.include") }, + { _id: "regex", name: t("matchMethodOptions.regex") }, + ]; + const expectedValuePlaceholders = { + regex: t("matchMethodOptions.regexPlaceholder"), + equal: t("matchMethodOptions.equalPlaceholder"), + include: t("matchMethodOptions.includePlaceholder"), + }; + const monitorTypeMaps = { + http: { + label: t("monitorType.http.label"), + placeholder: t("monitorType.http.placeholder"), + namePlaceholder: t("monitorType.http.namePlaceholder"), + }, + ping: { + label: t("monitorType.ping.label"), + placeholder: t("monitorType.ping.placeholder"), + namePlaceholder: t("monitorType.ping.namePlaceholder"), + }, + docker: { + label: t("monitorType.docker.label"), + placeholder: t("monitorType.docker.placeholder"), + namePlaceholder: t("monitorType.docker.namePlaceholder"), + }, + port: { + label: t("monitorType.port.label"), + placeholder: t("monitorType.port.placeholder"), + namePlaceholder: t("monitorType.port.namePlaceholder"), + }, + }; + const formatAndSet = (monitor) => { + monitor.interval = monitor.interval / MS_PER_MINUTE; + setMonitor(monitor); + }; - // Handlers - const onSubmit = async (event) => { - event.preventDefault(); - const { notifications, ...rest } = monitor; + // Handlers + const onSubmit = async (event) => { + event.preventDefault(); + const { notifications, ...rest } = monitor; - let form = {}; - if (isCreate) { - form = { - ...rest, - url: - monitor.type === "http" - ? `http${https ? "s" : ""}://` + monitor.url - : monitor.url, - port: monitor.type === "port" ? monitor.port : undefined, - name: monitor.name || monitor.url.substring(0, 50), - type: monitor.type, - interval: monitor.interval, - }; - } else { - form = { - _id: monitor._id, - url: monitor.url, - name: monitor.name || monitor.url.substring(0, 50), - type: monitor.type, - matchMethod: monitor.matchMethod, - expectedValue: monitor.expectedValue, - jsonPath: monitor.jsonPath, - interval: monitor.interval, - teamId: monitor.teamId, - userId: monitor.userId, - port: monitor.type === "port" ? monitor.port : undefined, - ignoreTlsErrors: monitor.ignoreTlsErrors, - }; - } - if (!useAdvancedMatching) { - form.matchMethod = isCreate ? undefined : ""; - form.expectedValue = isCreate ? undefined : ""; - form.jsonPath = isCreate ? undefined : ""; - } + let form = {}; + if (isCreate) { + form = { + ...rest, + url: + monitor.type === "http" + ? `http${https ? "s" : ""}://` + monitor.url + : monitor.url, + port: monitor.type === "port" ? monitor.port : undefined, + name: monitor.name || monitor.url.substring(0, 50), + type: monitor.type, + interval: monitor.interval, + }; + } else { + form = { + _id: monitor._id, + url: monitor.url, + name: monitor.name || monitor.url.substring(0, 50), + type: monitor.type, + matchMethod: monitor.matchMethod, + expectedValue: monitor.expectedValue, + jsonPath: monitor.jsonPath, + interval: monitor.interval, + teamId: monitor.teamId, + userId: monitor.userId, + port: monitor.type === "port" ? monitor.port : undefined, + ignoreTlsErrors: monitor.ignoreTlsErrors, + }; + } + if (!useAdvancedMatching) { + form.matchMethod = isCreate ? undefined : ""; + form.expectedValue = isCreate ? undefined : ""; + form.jsonPath = isCreate ? undefined : ""; + } - const { error } = monitorValidation.validate(form, { - abortEarly: false, - }); + const { error } = monitorValidation.validate(form, { + abortEarly: false, + }); - if (error) { - const newErrors = {}; - error.details.forEach((err) => { - newErrors[err.path[0]] = err.message; - }); - setErrors(newErrors); - createToast({ body: t("checkFormError") }); - return; - } + if (error) { + const newErrors = {}; + error.details.forEach((err) => { + newErrors[err.path[0]] = err.message; + }); + setErrors(newErrors); + createToast({ body: t("checkFormError") }); + return; + } - form = { - ...form, - description: monitor.name || monitor.url, - notifications: monitor.notifications, - }; + form = { + ...form, + description: monitor.name || monitor.url, + notifications: monitor.notifications, + }; - if (isCreate) { - await createMonitor({ monitor: form, redirect: "/uptime" }); - } else { - await updateMonitor({ monitor: form, redirect: "/uptime" }); - } - }; + if (isCreate) { + await createMonitor({ monitor: form, redirect: "/uptime" }); + } else { + await updateMonitor({ monitor: form, redirect: "/uptime" }); + } + }; - const onChange = (event) => { - let { name, value, checked } = event.target; + const onChange = (event) => { + let { name, value, checked } = event.target; - if (name === "ignoreTlsErrors") { - value = checked; - } + if (name === "ignoreTlsErrors") { + value = checked; + } - if (name === "useAdvancedMatching") { - setUseAdvancedMatching(checked); - return; - } + if (name === "useAdvancedMatching") { + setUseAdvancedMatching(checked); + return; + } - if (name === "interval") { - value = value * MS_PER_MINUTE; - } + if (name === "interval") { + value = value * MS_PER_MINUTE; + } - setMonitor((prev) => ({ ...prev, [name]: value })); + setMonitor((prev) => ({ ...prev, [name]: value })); - const { error } = monitorValidation.validate( - { type: monitor.type, [name]: value }, - { abortEarly: false } - ); + const { error } = monitorValidation.validate( + { type: monitor.type, [name]: value }, + { abortEarly: false } + ); - setErrors((prev) => ({ - ...prev, - ...(error ? { [name]: error.details[0].message } : { [name]: undefined }), - })); - }; + setErrors((prev) => ({ + ...prev, + ...(error ? { [name]: error.details[0].message } : { [name]: undefined }), + })); + }; - const handlePause = async () => { - await pauseMonitor({ monitorId, triggerUpdate }); - }; + const handlePause = async () => { + await pauseMonitor({ monitorId, triggerUpdate }); + }; - const handleRemove = async (event) => { - event.preventDefault(); - await deleteMonitor({ monitor, redirect: "/uptime" }); - }; + const handleRemove = async (event) => { + event.preventDefault(); + await deleteMonitor({ monitor, redirect: "/uptime" }); + }; - const isBusy = isLoading || isCreating || isDeleting || isUpdating || isPausing; - const parsedUrl = parseUrl(monitor?.url); - const protocol = parsedUrl?.protocol?.replace(":", "") || ""; + const isBusy = isLoading || isCreating || isDeleting || isUpdating || isPausing; + const parsedUrl = parseUrl(monitor?.url); + const protocol = parsedUrl?.protocol?.replace(":", "") || ""; - if (Object.keys(monitor).length === 0) { - return ; - } - useEffect(() => { - if (!isCreate) { - if (monitor.matchMethod) { - setUseAdvancedMatching(true); - } else { - setUseAdvancedMatching(false); - } - } - }, [monitor]); + if (Object.keys(monitor).length === 0) { + return ; + } + useEffect(() => { + if (!isCreate) { + if (monitor.matchMethod) { + setUseAdvancedMatching(true); + } else { + setUseAdvancedMatching(false); + } + } + }, [monitor]); - return ( - - + return ( + + - - - - - - {!isCreate ? monitor.name : t("createYour") + " "} - - {isCreate && ( - - {t("monitor")} - - )} - - {!isCreate && ( - - - - - - - - {monitor.url?.replace(/^https?:\/\//, "") || "..."} - - - {t("editing")} - - - )} - - {!isCreate && ( - - - - - )} - - {isCreate && ( - - - - {t("distributedUptimeCreateChecks")} - - - {t("distributedUptimeCreateChecksDescription")} - - - - - - {monitor.type === "http" ? ( - - - - - ) : ( - "" - )} - - - - - {errors["type"] ? ( - - - {errors["type"]} - - - ) : ( - "" - )} - - - )} - - - - {t("settingsGeneralSettings")} - - - {isCreate - ? t(`uptimeGeneralInstructions.${monitor.type}`) - : t("distributedUptimeCreateSelectURL")} - - - - - ) - } - helperText={errors["url"]} - onChange={onChange} - disabled={!isCreate} - /> - - - - - {t("notificationConfig.title")} - {t("notificationConfig.description")} - - - - - - - {t("ignoreTLSError")} - - {t("ignoreTLSErrorDescription")} - - - - } - label={t("tlsErrorIgnored")} - /> - - - - - - {t("distributedUptimeCreateAdvancedSettings")} - - - - - - - - {t("uptimeCreate")} - - - - - - {t("uptimeCreateJsonPath") + " "} - - jmespath.org - - {" " + t("uptimeCreateJsonPathQuery")} - - - - )} - - - - - - + + + + + + {!isCreate ? monitor.name : t("createYour") + " "} + + {isCreate && ( + + {t("monitor")} + + )} + + {!isCreate && ( + + + + + + + + {monitor.url?.replace(/^https?:\/\//, "") || "..."} + + + {t("editing")} + + + )} + + {!isCreate && ( + + + + + )} + + {isCreate && ( + + + + {t("distributedUptimeCreateChecks")} + + + {t("distributedUptimeCreateChecksDescription")} + + + + + + {monitor.type === "http" ? ( + + + + + ) : ( + "" + )} + + + + + {errors["type"] ? ( + + + {errors["type"]} + + + ) : ( + "" + )} + + + )} + + + + {t("settingsGeneralSettings")} + + + {isCreate + ? t(`uptimeGeneralInstructions.${monitor.type}`) + : t("distributedUptimeCreateSelectURL")} + + + + + ) + } + helperText={errors["url"]} + onChange={onChange} + disabled={!isCreate} + /> + + + + + {t("notificationConfig.title")} + {t("notificationConfig.description")} + + + + + + + {t("ignoreTLSError")} + + {t("ignoreTLSErrorDescription")} + + + + } + label={t("tlsErrorIgnored")} + /> + + + + + + {t("distributedUptimeCreateAdvancedSettings")} + + + + + + + + {t("uptimeCreate")} + + + + + + {t("uptimeCreateJsonPath") + " "} + + jmespath.org + + {" " + t("uptimeCreateJsonPathQuery")} + + + + )} + + + + + + - {!isCreate && ( - setIsOpen(false)} - confirmationButtonLabel={t("delete")} - onConfirm={handleRemove} - isLoading={isLoading} - /> - )} - - ); + {!isCreate && ( + setIsOpen(false)} + confirmationButtonLabel={t("delete")} + onConfirm={handleRemove} + isLoading={isLoading} + /> + )} + + ); }; export default UptimeCreate; From 89f5be2cc9aa3bf715691755735ace397f3ea360 Mon Sep 17 00:00:00 2001 From: Jesulayomy Date: Wed, 23 Jul 2025 21:13:19 -0400 Subject: [PATCH 077/226] [Uptime]: Moved hook, removed duplicate imports, teporarily disabled formatAndSet function due to rerenders --- client/src/Pages/Uptime/Create/index.jsx | 29 ++++++++++++------------ 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/client/src/Pages/Uptime/Create/index.jsx b/client/src/Pages/Uptime/Create/index.jsx index fcf81f1c3..f63a2cb9f 100644 --- a/client/src/Pages/Uptime/Create/index.jsx +++ b/client/src/Pages/Uptime/Create/index.jsx @@ -22,22 +22,20 @@ import PulseDot from "../../../Components/Animated/PulseDot"; import SkeletonLayout from "./skeleton"; // Utils -import { useParams } from "react-router"; import { useTheme } from "@emotion/react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { monitorValidation } from "../../../Validation/validation"; import { createToast } from "../../../Utils/toastUtils"; -import { useTranslation } from "react-i18next"; import { PauseOutlined as PauseOutlinedIcon, PlayArrowOutlined as PlayArrowOutlinedIcon, } from "@mui/icons-material"; import { useMonitorUtils } from "../../../Hooks/useMonitorUtils"; import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications"; -import { useCreateMonitor, useFetchMonitorById } from "../../../Hooks/monitorHooks"; import { useParams } from "react-router-dom"; import { + useCreateMonitor, useDeleteMonitor, useUpdateMonitor, usePauseMonitor, @@ -85,6 +83,10 @@ const UptimeCreate = () => { const triggerUpdate = () => { setUpdateTrigger(!updateTrigger); }; + // const formatAndSet = (monitor) => { + // monitor.interval = monitor.interval / MS_PER_MINUTE; + // setMonitor(monitor); + // }; // Hooks const [notifications, notificationsAreLoading, notificationsError] = @@ -93,7 +95,7 @@ const UptimeCreate = () => { // Network const [isLoading] = useFetchMonitorById({ monitorId, - setMonitor: formatAndSet, + setMonitor, // : formatAndSet, updateTrigger: true, }); const [createMonitor, isCreating] = useCreateMonitor(); @@ -155,10 +157,6 @@ const UptimeCreate = () => { namePlaceholder: t("monitorType.port.namePlaceholder"), }, }; - const formatAndSet = (monitor) => { - monitor.interval = monitor.interval / MS_PER_MINUTE; - setMonitor(monitor); - }; // Handlers const onSubmit = async (event) => { @@ -269,9 +267,6 @@ const UptimeCreate = () => { const parsedUrl = parseUrl(monitor?.url); const protocol = parsedUrl?.protocol?.replace(":", "") || ""; - if (Object.keys(monitor).length === 0) { - return ; - } useEffect(() => { if (!isCreate) { if (monitor.matchMethod) { @@ -280,7 +275,11 @@ const UptimeCreate = () => { setUseAdvancedMatching(false); } } - }, [monitor]); + }, [monitor, isCreate]); + + if (Object.keys(monitor).length === 0) { + return ; + } return ( @@ -365,12 +364,12 @@ const UptimeCreate = () => { "&:before": { position: "absolute", content: `""`, - width: 4, - height: 4, + width: theme.spacing(2), + height: theme.spacing(2), borderRadius: "50%", backgroundColor: theme.palette.primary.contrastTextTertiary, opacity: 0.8, - left: -10, + left: theme.spacing(-5), top: "50%", transform: "translateY(-50%)", }, From 47021fa55cbab3fc51e5df6896e4fbfcfe8be52e Mon Sep 17 00:00:00 2001 From: Jesulayomy Date: Thu, 24 Jul 2025 13:38:14 -0400 Subject: [PATCH 078/226] [Uptime]: removed formatAndSet, added displayInterval, moved UI logic out of component view --- client/src/Pages/Uptime/Create/index.jsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/client/src/Pages/Uptime/Create/index.jsx b/client/src/Pages/Uptime/Create/index.jsx index f63a2cb9f..fa8519b1f 100644 --- a/client/src/Pages/Uptime/Create/index.jsx +++ b/client/src/Pages/Uptime/Create/index.jsx @@ -35,7 +35,7 @@ import { useMonitorUtils } from "../../../Hooks/useMonitorUtils"; import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications"; import { useParams } from "react-router-dom"; import { - useCreateMonitor, + useCreateMonitor, useDeleteMonitor, useUpdateMonitor, usePauseMonitor, @@ -83,10 +83,6 @@ const UptimeCreate = () => { const triggerUpdate = () => { setUpdateTrigger(!updateTrigger); }; - // const formatAndSet = (monitor) => { - // monitor.interval = monitor.interval / MS_PER_MINUTE; - // setMonitor(monitor); - // }; // Hooks const [notifications, notificationsAreLoading, notificationsError] = @@ -95,7 +91,7 @@ const UptimeCreate = () => { // Network const [isLoading] = useFetchMonitorById({ monitorId, - setMonitor, // : formatAndSet, + setMonitor, updateTrigger: true, }); const [createMonitor, isCreating] = useCreateMonitor(); @@ -264,6 +260,7 @@ const UptimeCreate = () => { }; const isBusy = isLoading || isCreating || isDeleting || isUpdating || isPausing; + const displayInterval = monitor?.interval / MS_PER_MINUTE || 1; const parsedUrl = parseUrl(monitor?.url); const protocol = parsedUrl?.protocol?.replace(":", "") || ""; @@ -526,7 +523,7 @@ const UptimeCreate = () => { : monitorTypeMaps[monitor.type].label || t("urlMonitor") } placeholder={monitorTypeMaps[monitor.type].placeholder || ""} - value={parsedUrl?.host || monitor?.url || ""} + value={parsedUrl?.host + parsedUrl?.pathname || monitor?.url || ""} https={isCreate ? https : protocol === "https"} startAdornment={ monitor?.type === "http" && ( @@ -610,7 +607,7 @@ const UptimeCreate = () => { @@ -445,8 +445,8 @@ const Configure = () => { name="expectedValue" label={t("expectedValue")} isOptional={true} - placeholder={expectedValuePlaceholders[form.matchMethod || "equal"]} - value={form.expectedValue} + placeholder={expectedValuePlaceholders[form?.matchMethod || "equal"]} + value={form?.expectedValue} onChange={onChange} error={errors["expectedValue"] ? true : false} helperText={errors["expectedValue"]} @@ -466,7 +466,7 @@ const Configure = () => { label="JSON Path" isOptional={true} placeholder="data.status" - value={form.jsonPath} + value={form?.jsonPath} onChange={onChange} error={errors["jsonPath"] ? true : false} helperText={errors["jsonPath"]} diff --git a/client/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js index 3abd726da..59e2a0a21 100644 --- a/client/src/Utils/NetworkService.js +++ b/client/src/Utils/NetworkService.js @@ -111,30 +111,6 @@ class NetworkService { return this.axiosInstance.post(`/monitors`, monitor); } - /** - * - * ************************************ - * Check the endpoint resolution - * ************************************ - * - * @async - * @param {Object} config - The configuration object. - * @param {Object} config.monitorURL - The monitor url to be sent in the request body. - * @returns {Promise} The response from the axios POST request. - */ - async checkEndpointResolution(config) { - const { monitorURL } = config; - const params = new URLSearchParams(); - - if (monitorURL) params.append("monitorURL", monitorURL); - - return this.axiosInstance.get(`/monitors/resolution/url?${params.toString()}`, { - headers: { - "Content-Type": "application/json", - }, - }); - } - /** * ************************************ * Get all uptime monitors for a Team From ddf3e8251d7bdc44dd033cde214a6e3c39a46a37 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 24 Jul 2025 15:06:46 -0700 Subject: [PATCH 082/226] add an error service, base controller --- server/controllers/authController.js | 30 ++-- server/controllers/baseController.js | 8 + server/controllers/checkController.js | 25 +-- server/controllers/controllerUtils.js | 4 +- server/controllers/diagnosticController.js | 12 +- server/controllers/inviteController.js | 18 +- server/controllers/logController.js | 13 +- .../maintenanceWindowController.js | 33 ++-- server/controllers/monitorController.js | 87 +++++----- server/controllers/notificationController.js | 76 ++++----- server/controllers/queueController.js | 33 ++-- server/controllers/settingsController.js | 29 ++-- server/controllers/statusPageController.js | 36 ++-- server/index.js | 35 +++- server/middleware/handleErrors.js | 1 - server/service/business/inviteService.js | 6 +- server/service/business/monitorService.js | 18 +- server/service/business/userService.js | 8 +- server/service/infrastructure/errorService.js | 161 ++++++++++++++++++ server/utils/errorUtils.js | 80 --------- 20 files changed, 412 insertions(+), 301 deletions(-) create mode 100644 server/controllers/baseController.js create mode 100644 server/service/infrastructure/errorService.js diff --git a/server/controllers/authController.js b/server/controllers/authController.js index 12d2f77f4..8dac9f028 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -1,3 +1,4 @@ +import BaseController from "./baseController.js"; import { registrationBodyValidation, loginValidation, @@ -10,7 +11,6 @@ import { editUserByIdBodyValidation, editSuperadminUserByIdBodyValidation, } from "../validation/joi.js"; -import { asyncHandler, createError } from "../utils/errorUtils.js"; const SERVICE_NAME = "authController"; @@ -23,7 +23,7 @@ const SERVICE_NAME = "authController"; * @class AuthController * @description Manages user authentication and authorization operations */ -class AuthController { +class AuthController extends BaseController { /** * Creates an instance of AuthController. * @@ -36,7 +36,8 @@ class AuthController { * @param {Object} dependencies.logger - Logger service * @param {Object} dependencies.userService - User business logic service */ - constructor({ db, settingsService, emailService, jobQueue, stringService, logger, userService }) { + constructor({ db, settingsService, emailService, jobQueue, stringService, logger, userService, errorService }) { + super(); this.db = db; this.settingsService = settingsService; this.emailService = emailService; @@ -44,6 +45,7 @@ class AuthController { this.stringService = stringService; this.logger = logger; this.userService = userService; + this.errorService = errorService; } /** @@ -85,7 +87,7 @@ class AuthController { * "inviteToken": "abc123..." * } */ - registerUser = asyncHandler( + registerUser = this.asyncHandler( async (req, res) => { if (req.body?.email) { req.body.email = req.body.email?.toLowerCase(); @@ -121,7 +123,7 @@ class AuthController { * "password": "SecurePass123!" * } */ - loginUser = asyncHandler( + loginUser = this.asyncHandler( async (req, res) => { if (req.body?.email) { req.body.email = req.body.email?.toLowerCase(); @@ -173,7 +175,7 @@ class AuthController { * "newPassword": "NewPass123!" * } */ - editUser = asyncHandler( + editUser = this.asyncHandler( async (req, res) => { await editUserBodyValidation.validateAsync(req.body); @@ -200,7 +202,7 @@ class AuthController { * GET /auth/users/superadmin * // Response: { "data": true } or { "data": false } */ - checkSuperadminExists = asyncHandler( + checkSuperadminExists = this.asyncHandler( async (req, res) => { const superAdminExists = await this.userService.checkSuperadminExists(); return res.success({ @@ -230,7 +232,7 @@ class AuthController { * "email": "john@example.com" * } */ - requestRecovery = asyncHandler( + requestRecovery = this.asyncHandler( async (req, res) => { await recoveryValidation.validateAsync(req.body); const email = req?.body?.email; @@ -262,7 +264,7 @@ class AuthController { * "recoveryToken": "abc123..." * } */ - validateRecovery = asyncHandler( + validateRecovery = this.asyncHandler( async (req, res) => { await recoveryTokenBodyValidation.validateAsync(req.body); await this.userService.validateRecovery(req.body.recoveryToken); @@ -294,7 +296,7 @@ class AuthController { * "recoveryToken": "abc123..." * } */ - resetPassword = asyncHandler( + resetPassword = this.asyncHandler( async (req, res) => { await newPasswordValidation.validateAsync(req.body); const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken); @@ -326,7 +328,7 @@ class AuthController { * DELETE /auth/user * // Requires JWT authentication */ - deleteUser = asyncHandler( + deleteUser = this.asyncHandler( async (req, res) => { await this.userService.deleteUser(req.user); return res.success({ @@ -350,7 +352,7 @@ class AuthController { * GET /auth/users * // Requires JWT authentication with admin/superadmin role */ - getAllUsers = asyncHandler( + getAllUsers = this.asyncHandler( async (req, res) => { const allUsers = await this.userService.getAllUsers(); return res.success({ @@ -381,7 +383,7 @@ class AuthController { * GET /auth/users/507f1f77bcf86cd799439011 * // Requires JWT authentication with superadmin role */ - getUserById = asyncHandler( + getUserById = this.asyncHandler( async (req, res) => { await getUserByIdParamValidation.validateAsync(req.params); const userId = req?.params?.userId; @@ -431,7 +433,7 @@ class AuthController { * } * // Requires JWT authentication with superadmin role */ - editUserById = asyncHandler( + editUserById = this.asyncHandler( async (req, res) => { const roles = req?.user?.role; if (!roles.includes("superadmin")) { diff --git a/server/controllers/baseController.js b/server/controllers/baseController.js new file mode 100644 index 000000000..81df59deb --- /dev/null +++ b/server/controllers/baseController.js @@ -0,0 +1,8 @@ +import { asyncHandler } from "../service/infrastructure/errorService.js"; + +class BaseController { + constructor() { + this.asyncHandler = asyncHandler; + } +} +export default BaseController; diff --git a/server/controllers/checkController.js b/server/controllers/checkController.js index 304f89c6d..693f5a32d 100755 --- a/server/controllers/checkController.js +++ b/server/controllers/checkController.js @@ -1,3 +1,4 @@ +import BaseController from "./baseController.js"; import { getChecksParamValidation, getChecksQueryValidation, @@ -9,7 +10,6 @@ import { ackAllChecksParamValidation, ackAllChecksBodyValidation, } from "../validation/joi.js"; -import { asyncHandler } from "../utils/errorUtils.js"; const SERVICE_NAME = "checkController"; @@ -22,7 +22,7 @@ const SERVICE_NAME = "checkController"; * @class CheckController * @description Manages check operations and monitoring data */ -class CheckController { +class CheckController extends BaseController { /** * Creates an instance of CheckController. * @@ -31,12 +31,15 @@ class CheckController { * @param {Object} dependencies.settingsService - Service for application settings * @param {Object} dependencies.stringService - Service for string/localization * @param {Object} dependencies.checkService - Check business logic service + * @param {Object} dependencies.errorService - Error service for error handling */ - constructor({ db, settingsService, stringService, checkService }) { + constructor({ db, settingsService, stringService, checkService, errorService }) { + super(); this.db = db; this.settingsService = settingsService; this.stringService = stringService; this.checkService = checkService; + this.errorService = errorService; } /** @@ -67,7 +70,7 @@ class CheckController { * GET /checks/monitor/507f1f77bcf86cd799439011?page=1&rowsPerPage=10&status=down * // Requires JWT authentication */ - getChecksByMonitor = asyncHandler( + getChecksByMonitor = this.asyncHandler( async (req, res) => { await getChecksParamValidation.validateAsync(req.params); await getChecksQueryValidation.validateAsync(req.query); @@ -109,7 +112,7 @@ class CheckController { * GET /checks/team?page=1&rowsPerPage=20&status=down&ack=false * // Requires JWT authentication */ - getChecksByTeam = asyncHandler( + getChecksByTeam = this.asyncHandler( async (req, res) => { await getTeamChecksQueryValidation.validateAsync(req.query); const checkData = await this.checkService.getChecksByTeam({ @@ -140,7 +143,7 @@ class CheckController { * // Requires JWT authentication * // Response includes counts by status, time ranges, etc. */ - getChecksSummaryByTeamId = asyncHandler( + getChecksSummaryByTeamId = this.asyncHandler( async (req, res) => { const summary = await this.checkService.getChecksSummaryByTeamId({ teamId: req?.user?.teamId }); return res.success({ @@ -176,7 +179,7 @@ class CheckController { * } * // Requires JWT authentication */ - ackCheck = asyncHandler( + ackCheck = this.asyncHandler( async (req, res) => { await ackCheckBodyValidation.validateAsync(req.body); @@ -220,7 +223,7 @@ class CheckController { * } * // Requires JWT authentication */ - ackAllChecks = asyncHandler( + ackAllChecks = this.asyncHandler( async (req, res) => { await ackAllChecksParamValidation.validateAsync(req.params); await ackAllChecksBodyValidation.validateAsync(req.body); @@ -266,7 +269,7 @@ class CheckController { * // Requires JWT authentication * // Response: { "data": { "deletedCount": 150 } } */ - deleteChecks = asyncHandler( + deleteChecks = this.asyncHandler( async (req, res) => { await deleteChecksParamValidation.validateAsync(req.params); @@ -300,7 +303,7 @@ class CheckController { * // Requires JWT authentication * // Response: { "data": { "deletedCount": 1250 } } */ - deleteChecksByTeamId = asyncHandler( + deleteChecksByTeamId = this.asyncHandler( async (req, res) => { await deleteChecksByTeamIdParamValidation.validateAsync(req.params); @@ -336,7 +339,7 @@ class CheckController { * // Requires JWT authentication * // Sets check TTL to 30 days */ - updateChecksTTL = asyncHandler( + updateChecksTTL = this.asyncHandler( async (req, res) => { await updateChecksTTLBodyValidation.validateAsync(req.body); diff --git a/server/controllers/controllerUtils.js b/server/controllers/controllerUtils.js index 2b3a4198c..f21ba154b 100755 --- a/server/controllers/controllerUtils.js +++ b/server/controllers/controllerUtils.js @@ -1,12 +1,10 @@ -import { createServerError } from "../utils/errorUtils.js"; - const fetchMonitorCertificate = async (sslChecker, monitor) => { const monitorUrl = new URL(monitor.url); const hostname = monitorUrl.hostname; const cert = await sslChecker(hostname); // Throw an error if no cert or if cert.validTo is not present if (cert?.validTo === null || cert?.validTo === undefined) { - throw createServerError("Certificate not found"); + throw new Error("Certificate not found"); } return cert; }; diff --git a/server/controllers/diagnosticController.js b/server/controllers/diagnosticController.js index 585684211..308755109 100755 --- a/server/controllers/diagnosticController.js +++ b/server/controllers/diagnosticController.js @@ -1,6 +1,5 @@ -import { asyncHandler } from "../utils/errorUtils.js"; - const SERVICE_NAME = "diagnosticController"; +import BaseController from "./baseController.js"; /** * Diagnostic Controller * @@ -10,15 +9,18 @@ const SERVICE_NAME = "diagnosticController"; * @class DiagnosticController * @description Manages system diagnostics and performance monitoring */ -class DiagnosticController { +class DiagnosticController extends BaseController { /** * Creates an instance of DiagnosticController. * * @param {Object} dependencies - The dependencies required by the controller * @param {Object} dependencies.diagnosticService - Service for system diagnostics and monitoring + * @param {Object} dependencies.errorService - Service for error handling */ - constructor({ diagnosticService }) { + constructor({ diagnosticService, errorService }) { + super(); this.diagnosticService = diagnosticService; + this.errorService = errorService; } /** @@ -41,7 +43,7 @@ class DiagnosticController { * // - Database connection status * // - Active processes/connections */ - getSystemStats = asyncHandler( + getSystemStats = this.asyncHandler( async (req, res) => { const diagnostics = await this.diagnosticService.getSystemStats(); return res.success({ diff --git a/server/controllers/inviteController.js b/server/controllers/inviteController.js index 75e98cb68..4cf7ec8ed 100755 --- a/server/controllers/inviteController.js +++ b/server/controllers/inviteController.js @@ -1,22 +1,24 @@ import { inviteBodyValidation, inviteVerificationBodyValidation } from "../validation/joi.js"; -import { asyncHandler } from "../utils/errorUtils.js"; - +import BaseController from "./baseController.js"; const SERVICE_NAME = "inviteController"; /** * Controller for handling user invitation operations * Manages invite token generation, email sending, and token verification */ -class InviteController { +class InviteController extends BaseController { /** * Creates a new InviteController instance * @param {Object} dependencies - Dependencies injected into the controller * @param {Object} dependencies.stringService - Service for internationalized strings - * @param {Object} dependencies.inviteService - Service for invite-related operations + * @param {Object} dependencies.inviteServ ice - Service for invite-related operations + * @param {Object} dependencies.errorService - Service for error handling */ - constructor({ stringService, inviteService }) { + constructor({ stringService, inviteService, errorService }) { + super(); this.stringService = stringService; this.inviteService = inviteService; + this.errorService = errorService; } /** @@ -28,7 +30,7 @@ class InviteController { * @param {Object} res - Express response object * @returns {Promise} Response with invite token data */ - getInviteToken = asyncHandler( + getInviteToken = this.asyncHandler( async (req, res) => { const invite = req.body; const teamId = req?.user?.teamId; @@ -54,7 +56,7 @@ class InviteController { * @param {Object} res - Express response object * @returns {Promise} Response with invite token data */ - sendInviteEmail = asyncHandler( + sendInviteEmail = this.asyncHandler( async (req, res) => { const inviteRequest = req.body; inviteRequest.teamId = req?.user?.teamId; @@ -81,7 +83,7 @@ class InviteController { * @param {Object} res - Express response object * @returns {Promise} Response with verified invite data */ - verifyInviteToken = asyncHandler( + verifyInviteToken = this.asyncHandler( async (req, res) => { await inviteVerificationBodyValidation.validateAsync(req.body); const invite = await this.inviteService.verifyInviteToken({ inviteToken: req?.body?.token }); diff --git a/server/controllers/logController.js b/server/controllers/logController.js index f4887ce9a..14f7a6d58 100644 --- a/server/controllers/logController.js +++ b/server/controllers/logController.js @@ -1,14 +1,15 @@ -import { asyncHandler } from "../utils/errorUtils.js"; - +import BaseController from "./baseController.js"; const SERVICE_NAME = "LogController"; -class LogController { - constructor(logger) { +class LogController extends BaseController { + constructor({ logger, errorService }) { + super(); this.logger = logger; + this.errorService = errorService; } - getLogs = asyncHandler( - async (req, res, next) => { + getLogs = this.asyncHandler( + async (req, res) => { const logs = await this.logger.getLogs(); res.success({ msg: "Logs fetched successfully", diff --git a/server/controllers/maintenanceWindowController.js b/server/controllers/maintenanceWindowController.js index 95349fa66..59ea10140 100755 --- a/server/controllers/maintenanceWindowController.js +++ b/server/controllers/maintenanceWindowController.js @@ -7,25 +7,28 @@ import { getMaintenanceWindowsByTeamIdQueryValidation, deleteMaintenanceWindowByIdParamValidation, } from "../validation/joi.js"; -import { asyncHandler } from "../utils/errorUtils.js"; +import BaseController from "./baseController.js"; const SERVICE_NAME = "maintenanceWindowController"; -class MaintenanceWindowController { - constructor({ db, settingsService, stringService, maintenanceWindowService }) { +class MaintenanceWindowController extends BaseController { + constructor({ db, settingsService, stringService, maintenanceWindowService, errorService }) { + super(); this.db = db; this.settingsService = settingsService; this.stringService = stringService; this.maintenanceWindowService = maintenanceWindowService; + this.errorService = errorService; + this.asyncHandler = errorService.asyncHandler; } - createMaintenanceWindows = asyncHandler( + createMaintenanceWindows = this.asyncHandler( async (req, res) => { await createMaintenanceWindowBodyValidation.validateAsync(req.body); const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } await this.maintenanceWindowService.createMaintenanceWindow({ teamId, body: req.body }); @@ -38,13 +41,13 @@ class MaintenanceWindowController { "createMaintenanceWindows" ); - getMaintenanceWindowById = asyncHandler( + getMaintenanceWindowById = this.asyncHandler( async (req, res) => { await getMaintenanceWindowByIdParamValidation.validateAsync(req.params); const teamId = req.user.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const maintenanceWindow = await this.maintenanceWindowService.getMaintenanceWindowById({ id: req.params.id, teamId }); @@ -58,14 +61,14 @@ class MaintenanceWindowController { "getMaintenanceWindowById" ); - getMaintenanceWindowsByTeamId = asyncHandler( + getMaintenanceWindowsByTeamId = this.asyncHandler( async (req, res) => { await getMaintenanceWindowsByTeamIdQueryValidation.validateAsync(req.query); const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByTeamId({ teamId, query: req.query }); @@ -79,13 +82,13 @@ class MaintenanceWindowController { "getMaintenanceWindowsByTeamId" ); - getMaintenanceWindowsByMonitorId = asyncHandler( + getMaintenanceWindowsByMonitorId = this.asyncHandler( async (req, res) => { await getMaintenanceWindowsByMonitorIdParamValidation.validateAsync(req.params); const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByMonitorId({ monitorId: req.params.monitorId, teamId }); @@ -99,13 +102,13 @@ class MaintenanceWindowController { "getMaintenanceWindowsByMonitorId" ); - deleteMaintenanceWindow = asyncHandler( + deleteMaintenanceWindow = this.asyncHandler( async (req, res) => { await deleteMaintenanceWindowByIdParamValidation.validateAsync(req.params); const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } await this.maintenanceWindowService.deleteMaintenanceWindow({ id: req.params.id, teamId }); @@ -118,14 +121,14 @@ class MaintenanceWindowController { "deleteMaintenanceWindow" ); - editMaintenanceWindow = asyncHandler( + editMaintenanceWindow = this.asyncHandler( async (req, res) => { await editMaintenanceWindowByIdParamValidation.validateAsync(req.params); await editMaintenanceByIdWindowBodyValidation.validateAsync(req.body); const teamId = req.user.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const editedMaintenanceWindow = await this.maintenanceWindowService.editMaintenanceWindow({ id: req.params.id, body: req.body, teamId }); diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index 0f0dd3e76..4e2c021d2 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -13,30 +13,31 @@ import { getHardwareDetailsByIdQueryValidation, } from "../validation/joi.js"; import sslChecker from "ssl-checker"; -import { asyncHandler } from "../utils/errorUtils.js"; import { fetchMonitorCertificate } from "./controllerUtils.js"; +import BaseController from "./baseController.js"; const SERVICE_NAME = "monitorController"; -class MonitorController { - constructor({ db, settingsService, jobQueue, stringService, emailService, monitorService }) { +class MonitorController extends BaseController { + constructor({ db, settingsService, jobQueue, stringService, emailService, monitorService, errorService }) { + super(); this.db = db; this.settingsService = settingsService; this.jobQueue = jobQueue; this.stringService = stringService; this.emailService = emailService; this.monitorService = monitorService; + this.errorService = errorService; + this.asyncHandler = errorService.asyncHandler; } async verifyTeamAccess(teamId, monitorId) { const monitor = await this.db.getMonitorById(monitorId); if (!monitor.teamId.equals(teamId)) { - const error = new Error("Unauthorized"); - error.status = 403; - throw error; + throw this.errorService.createAuthorizationError(); } } - getAllMonitors = asyncHandler( + getAllMonitors = this.asyncHandler( async (req, res) => { const monitors = await this.monitorService.getAllMonitors(); return res.success({ @@ -48,7 +49,7 @@ class MonitorController { "getAllMonitors" ); - getUptimeDetailsById = asyncHandler( + getUptimeDetailsById = this.asyncHandler( async (req, res) => { const monitorId = req?.params?.monitorId; const dateRange = req?.query?.dateRange; @@ -57,7 +58,7 @@ class MonitorController { const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const data = await this.monitorService.getUptimeDetailsById({ @@ -75,7 +76,7 @@ class MonitorController { "getUptimeDetailsById" ); - getMonitorStatsById = asyncHandler( + getMonitorStatsById = this.asyncHandler( async (req, res) => { await getMonitorStatsByIdParamValidation.validateAsync(req.params); await getMonitorStatsByIdQueryValidation.validateAsync(req.query); @@ -85,7 +86,7 @@ class MonitorController { const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const monitorStats = await this.monitorService.getMonitorStatsById({ @@ -116,7 +117,7 @@ class MonitorController { * @returns {Promise} * @throws {Error} - Throws error if monitor not found or other database errors */ - getHardwareDetailsById = asyncHandler( + getHardwareDetailsById = this.asyncHandler( async (req, res) => { await getHardwareDetailsByIdParamValidation.validateAsync(req.params); await getHardwareDetailsByIdQueryValidation.validateAsync(req.query); @@ -125,7 +126,7 @@ class MonitorController { const dateRange = req?.query?.dateRange; const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const monitor = await this.monitorService.getHardwareDetailsById({ @@ -143,8 +144,8 @@ class MonitorController { "getHardwareDetailsById" ); - getMonitorCertificate = asyncHandler( - async (req, res, next) => { + getMonitorCertificate = this.asyncHandler( + async (req, res) => { await getCertificateParamValidation.validateAsync(req.params); const { monitorId } = req.params; @@ -162,14 +163,14 @@ class MonitorController { "getMonitorCertificate" ); - getMonitorById = asyncHandler( + getMonitorById = this.asyncHandler( async (req, res) => { await getMonitorByIdParamValidation.validateAsync(req.params); await getMonitorByIdQueryValidation.validateAsync(req.query); const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const monitor = await this.monitorService.getMonitorById({ teamId, monitorId: req?.params?.monitorId }); @@ -183,7 +184,7 @@ class MonitorController { "getMonitorById" ); - createMonitor = asyncHandler( + createMonitor = this.asyncHandler( async (req, res) => { await createMonitorBodyValidation.validateAsync(req.body); @@ -201,30 +202,30 @@ class MonitorController { "createMonitor" ); - createBulkMonitors = asyncHandler( + createBulkMonitors = this.asyncHandler( async (req, res) => { if (!req.file) { - throw new Error("No file uploaded"); + throw this.errorService.createBadRequestError("No file uploaded"); } if (!req.file.mimetype.includes("csv")) { - throw new Error("File is not a CSV"); + throw this.errorService.createBadRequestError("File is not a CSV"); } if (req.file.size === 0) { - throw new Error("File is empty"); + throw this.errorService.createBadRequestError("File is empty"); } const userId = req?.user?._id; const teamId = req?.user?.teamId; if (!userId || !teamId) { - throw new Error("Missing userId or teamId"); + throw this.errorService.createBadRequestError("Missing userId or teamId"); } const fileData = req?.file?.buffer?.toString("utf-8"); if (!fileData) { - throw new Error("Cannot get file from buffer"); + throw this.errorService.createBadRequestError("Cannot get file from buffer"); } const monitors = await this.monitorService.createBulkMonitors({ fileData, userId, teamId }); @@ -238,13 +239,13 @@ class MonitorController { "createBulkMonitors" ); - deleteMonitor = asyncHandler( + deleteMonitor = this.asyncHandler( async (req, res) => { await getMonitorByIdParamValidation.validateAsync(req.params); const monitorId = req.params.monitorId; const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const deletedMonitor = await this.monitorService.deleteMonitor({ teamId, monitorId }); @@ -255,11 +256,11 @@ class MonitorController { "deleteMonitor" ); - deleteAllMonitors = asyncHandler( + deleteAllMonitors = this.asyncHandler( async (req, res) => { const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const deletedCount = await this.monitorService.deleteAllMonitors({ teamId }); @@ -270,7 +271,7 @@ class MonitorController { "deleteAllMonitors" ); - editMonitor = asyncHandler( + editMonitor = this.asyncHandler( async (req, res) => { await getMonitorByIdParamValidation.validateAsync(req.params); await editMonitorBodyValidation.validateAsync(req.body); @@ -278,7 +279,7 @@ class MonitorController { const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const editedMonitor = await this.monitorService.editMonitor({ teamId, monitorId, body: req.body }); @@ -292,14 +293,14 @@ class MonitorController { "editMonitor" ); - pauseMonitor = asyncHandler( + pauseMonitor = this.asyncHandler( async (req, res) => { await pauseMonitorParamValidation.validateAsync(req.params); const monitorId = req.params.monitorId; const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const monitor = await this.monitorService.pauseMonitor({ teamId, monitorId }); @@ -313,7 +314,7 @@ class MonitorController { "pauseMonitor" ); - addDemoMonitors = asyncHandler( + addDemoMonitors = this.asyncHandler( async (req, res) => { const { _id, teamId } = req.user; const demoMonitors = await this.monitorService.addDemoMonitors({ userId: _id, teamId }); @@ -327,11 +328,11 @@ class MonitorController { "addDemoMonitors" ); - sendTestEmail = asyncHandler( + sendTestEmail = this.asyncHandler( async (req, res) => { const { to } = req.body; if (!to || typeof to !== "string") { - throw new Error(this.stringService.errorForValidEmailAddress); + throw this.errorService.createBadRequestError(this.stringService.errorForValidEmailAddress); } const messageId = await this.monitorService.sendTestEmail({ to }); @@ -344,8 +345,8 @@ class MonitorController { "sendTestEmail" ); - getMonitorsByTeamId = asyncHandler( - async (req, res, next) => { + getMonitorsByTeamId = this.asyncHandler( + async (req, res) => { await getMonitorsByTeamIdParamValidation.validateAsync(req.params); await getMonitorsByTeamIdQueryValidation.validateAsync(req.query); @@ -363,7 +364,7 @@ class MonitorController { "getMonitorsByTeamId" ); - getMonitorsAndSummaryByTeamId = asyncHandler( + getMonitorsAndSummaryByTeamId = this.asyncHandler( async (req, res) => { await getMonitorsByTeamIdParamValidation.validateAsync(req.params); await getMonitorsByTeamIdQueryValidation.validateAsync(req.query); @@ -372,7 +373,7 @@ class MonitorController { const type = req?.query?.type; const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const result = await this.monitorService.getMonitorsAndSummaryByTeamId({ teamId, type, explain }); @@ -386,7 +387,7 @@ class MonitorController { "getMonitorsAndSummaryByTeamId" ); - getMonitorsWithChecksByTeamId = asyncHandler( + getMonitorsWithChecksByTeamId = this.asyncHandler( async (req, res) => { await getMonitorsByTeamIdParamValidation.validateAsync(req.params); await getMonitorsByTeamIdQueryValidation.validateAsync(req.query); @@ -395,7 +396,7 @@ class MonitorController { let { limit, type, page, rowsPerPage, filter, field, order } = req.query; const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const monitors = await this.monitorService.getMonitorsWithChecksByTeamId({ @@ -419,11 +420,11 @@ class MonitorController { "getMonitorsWithChecksByTeamId" ); - exportMonitorsToCSV = asyncHandler( + exportMonitorsToCSV = this.asyncHandler( async (req, res) => { const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("Team ID is required"); + throw this.errorService.createBadRequestError("Team ID is required"); } const csv = await this.monitorService.exportMonitorsToCSV({ teamId }); diff --git a/server/controllers/notificationController.js b/server/controllers/notificationController.js index 41af21ece..32ae22aae 100755 --- a/server/controllers/notificationController.js +++ b/server/controllers/notificationController.js @@ -1,27 +1,27 @@ import { createNotificationBodyValidation } from "../validation/joi.js"; -import { asyncHandler } from "../utils/errorUtils.js"; +import BaseController from "./baseController.js"; const SERVICE_NAME = "NotificationController"; -class NotificationController { - constructor({ notificationService, stringService, statusService, db }) { +class NotificationController extends BaseController { + constructor({ notificationService, stringService, statusService, db, errorService }) { + super(); this.notificationService = notificationService; this.stringService = stringService; this.statusService = statusService; this.db = db; + this.errorService = errorService; + this.asyncHandler = errorService.asyncHandler; } - testNotification = asyncHandler( - async (req, res, next) => { + testNotification = this.asyncHandler( + async (req, res) => { const notification = req.body; const success = await this.notificationService.sendTestNotification(notification); if (!success) { - return res.error({ - msg: "Sending notification failed", - status: 400, - }); + throw this.errorService.createServerError("Sending notification failed"); } return res.success({ @@ -32,8 +32,8 @@ class NotificationController { "testNotification" ); - createNotification = asyncHandler( - async (req, res, next) => { + createNotification = this.asyncHandler( + async (req, res) => { await createNotificationBodyValidation.validateAsync(req.body, { abortEarly: false, }); @@ -42,12 +42,12 @@ class NotificationController { const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("No team ID in request"); + throw this.errorService.createBadRequestError("Team ID is required"); } const userId = req?.user?._id; if (!userId) { - throw new Error("No user ID in request"); + throw this.errorService.createBadRequestError("User ID is required"); } body.userId = userId; body.teamId = teamId; @@ -62,11 +62,11 @@ class NotificationController { "createNotification" ); - getNotificationsByTeamId = asyncHandler( - async (req, res, next) => { + getNotificationsByTeamId = this.asyncHandler( + async (req, res) => { const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("No team ID in request"); + throw this.errorService.createBadRequestError("Team ID is required"); } const notifications = await this.db.getNotificationsByTeamId(teamId); @@ -80,18 +80,16 @@ class NotificationController { "getNotificationsByTeamId" ); - deleteNotification = asyncHandler( - async (req, res, next) => { + deleteNotification = this.asyncHandler( + async (req, res) => { const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("No team ID in request"); + throw this.errorService.createBadRequestError("Team ID is required"); } const notification = await this.db.getNotificationById(req.params.id); if (!notification.teamId.equals(teamId)) { - const error = new Error("Unauthorized"); - error.status = 403; - throw error; + throw this.errorService.createAuthorizationError(); } await this.db.deleteNotificationById(req.params.id); @@ -103,19 +101,17 @@ class NotificationController { "deleteNotification" ); - getNotificationById = asyncHandler( - async (req, res, next) => { + getNotificationById = this.asyncHandler( + async (req, res) => { const notification = await this.db.getNotificationById(req.params.id); const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("No team ID in request"); + throw this.errorService.createBadRequestError("Team ID is required"); } if (!notification.teamId.equals(teamId)) { - const error = new Error("Unauthorized"); - error.status = 403; - throw error; + throw this.errorService.createAuthorizationError(); } return res.success({ msg: "Notification fetched successfully", @@ -126,23 +122,21 @@ class NotificationController { "getNotificationById" ); - editNotification = asyncHandler( - async (req, res, next) => { + editNotification = this.asyncHandler( + async (req, res) => { await createNotificationBodyValidation.validateAsync(req.body, { abortEarly: false, }); const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("No team ID in request"); + throw this.errorService.createBadRequestError("Team ID is required"); } const notification = await this.db.getNotificationById(req.params.id); if (!notification.teamId.equals(teamId)) { - const error = new Error("Unauthorized"); - error.status = 403; - throw error; + throw this.errorService.createAuthorizationError(); } const editedNotification = await this.db.editNotification(req.params.id, req.body); @@ -155,26 +149,24 @@ class NotificationController { "editNotification" ); - testAllNotifications = asyncHandler( - async (req, res, next) => { + testAllNotifications = this.asyncHandler( + async (req, res) => { const monitorId = req.body.monitorId; const teamId = req?.user?.teamId; if (!teamId) { - throw new Error("No team ID in request"); + throw this.errorService.createBadRequestError("Team ID is required"); } const monitor = await this.db.getMonitorById(monitorId); if (!monitor.teamId.equals(teamId)) { - const error = new Error("Unauthorized"); - error.status = 403; - throw error; + throw this.errorService.createAuthorizationError(); } const notifications = monitor.notifications; - if (notifications.length === 0) throw new Error("No notifications"); + if (notifications.length === 0) throw this.errorService.createBadRequestError("No notifications"); const result = await this.notificationService.testAllNotifications(notifications); - if (!result) throw new Error("Failed to send all notifications"); + if (!result) throw this.errorService.createServerError("Failed to send all notifications"); return res.success({ msg: "All notifications sent successfully", }); diff --git a/server/controllers/queueController.js b/server/controllers/queueController.js index faa0febd7..72864da38 100755 --- a/server/controllers/queueController.js +++ b/server/controllers/queueController.js @@ -1,15 +1,16 @@ -import { asyncHandler } from "../utils/errorUtils.js"; - +import BaseController from "./baseController.js"; const SERVICE_NAME = "JobQueueController"; -class JobQueueController { - constructor(jobQueue, stringService) { +class JobQueueController extends BaseController { + constructor({ jobQueue, stringService, errorService }) { + super(); this.jobQueue = jobQueue; this.stringService = stringService; + this.errorService = errorService; } - getMetrics = asyncHandler( - async (req, res, next) => { + getMetrics = this.asyncHandler( + async (req, res) => { const metrics = await this.jobQueue.getMetrics(); res.success({ msg: this.stringService.queueGetMetrics, @@ -20,8 +21,8 @@ class JobQueueController { "getMetrics" ); - getJobs = asyncHandler( - async (req, res, next) => { + getJobs = this.asyncHandler( + async (req, res) => { const jobs = await this.jobQueue.getJobs(); return res.success({ msg: this.stringService.queueGetJobs, @@ -32,8 +33,8 @@ class JobQueueController { "getJobs" ); - getAllMetrics = asyncHandler( - async (req, res, next) => { + getAllMetrics = this.asyncHandler( + async (req, res) => { const jobs = await this.jobQueue.getJobs(); const metrics = await this.jobQueue.getMetrics(); return res.success({ @@ -45,8 +46,8 @@ class JobQueueController { "getAllMetrics" ); - addJob = asyncHandler( - async (req, res, next) => { + addJob = this.asyncHandler( + async (req, res) => { await this.jobQueue.addJob(Math.random().toString(36).substring(7)); return res.success({ msg: this.stringService.queueAddJob, @@ -56,8 +57,8 @@ class JobQueueController { "addJob" ); - flushQueue = asyncHandler( - async (req, res, next) => { + flushQueue = this.asyncHandler( + async (req, res) => { const result = await this.jobQueue.flushQueues(); return res.success({ msg: this.stringService.jobQueueFlush, @@ -68,8 +69,8 @@ class JobQueueController { "flushQueue" ); - checkQueueHealth = asyncHandler( - async (req, res, next) => { + checkQueueHealth = this.asyncHandler( + async (req, res) => { const stuckQueues = await this.jobQueue.checkQueueHealth(); return res.success({ msg: this.stringService.queueHealthCheck, diff --git a/server/controllers/settingsController.js b/server/controllers/settingsController.js index 798fbdd5e..c54ba9e7e 100755 --- a/server/controllers/settingsController.js +++ b/server/controllers/settingsController.js @@ -1,15 +1,17 @@ import { updateAppSettingsBodyValidation } from "../validation/joi.js"; import { sendTestEmailBodyValidation } from "../validation/joi.js"; -import { asyncHandler, createServerError } from "../utils/errorUtils.js"; +import BaseController from "./baseController.js"; const SERVICE_NAME = "SettingsController"; -class SettingsController { - constructor({ db, settingsService, stringService, emailService }) { +class SettingsController extends BaseController { + constructor({ db, settingsService, stringService, emailService, errorService }) { + super(); this.db = db; this.settingsService = settingsService; this.stringService = stringService; this.emailService = emailService; + this.errorService = errorService; } buildAppSettings = (dbSettings) => { @@ -33,8 +35,8 @@ class SettingsController { return returnSettings; }; - getAppSettings = asyncHandler( - async (req, res, next) => { + getAppSettings = this.asyncHandler( + async (req, res) => { const dbSettings = await this.settingsService.getDBSettings(); const returnSettings = this.buildAppSettings(dbSettings); @@ -47,8 +49,8 @@ class SettingsController { "getAppSettings" ); - updateAppSettings = asyncHandler( - async (req, res, next) => { + updateAppSettings = this.asyncHandler( + async (req, res) => { await updateAppSettingsBodyValidation.validateAsync(req.body); const updatedSettings = await this.db.updateAppSettings(req.body); @@ -62,14 +64,9 @@ class SettingsController { "updateAppSettings" ); - sendTestEmail = asyncHandler( - async (req, res, next) => { - try { - await sendTestEmailBodyValidation.validateAsync(req.body); - } catch (error) { - next(handleValidationError(error, SERVICE_NAME)); - return; - } + sendTestEmail = this.asyncHandler( + async (req, res) => { + await sendTestEmailBodyValidation.validateAsync(req.body); const { to, @@ -107,7 +104,7 @@ class SettingsController { }); if (!messageId) { - throw createServerError("Failed to send test email."); + throw this.errorService.createServerError("Failed to send test email."); } return res.success({ diff --git a/server/controllers/statusPageController.js b/server/controllers/statusPageController.js index 8333e8949..79ebb50ab 100755 --- a/server/controllers/statusPageController.js +++ b/server/controllers/statusPageController.js @@ -1,16 +1,18 @@ import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation, imageValidation } from "../validation/joi.js"; -import { asyncHandler } from "../utils/errorUtils.js"; +import BaseController from "./baseController.js"; const SERVICE_NAME = "statusPageController"; -class StatusPageController { - constructor(db, stringService) { +class StatusPageController extends BaseController { + constructor({ db, stringService, errorService }) { + super(); this.db = db; this.stringService = stringService; + this.errorService = errorService; } - createStatusPage = asyncHandler( - async (req, res, next) => { + createStatusPage = this.asyncHandler( + async (req, res) => { await createStatusPageBodyValidation.validateAsync(req.body); await imageValidation.validateAsync(req.file); @@ -30,16 +32,14 @@ class StatusPageController { "createStatusPage" ); - updateStatusPage = asyncHandler( - async (req, res, next) => { + updateStatusPage = this.asyncHandler( + async (req, res) => { await createStatusPageBodyValidation.validateAsync(req.body); await imageValidation.validateAsync(req.file); const statusPage = await this.db.updateStatusPage(req.body, req.file); if (statusPage === null) { - const error = new Error(this.stringService.statusPageNotFound); - error.status = 404; - throw error; + throw this.errorService.createNotFoundError(this.stringService.statusPageNotFound); } return res.success({ msg: this.stringService.statusPageUpdate, @@ -50,8 +50,8 @@ class StatusPageController { "updateStatusPage" ); - getStatusPage = asyncHandler( - async (req, res, next) => { + getStatusPage = this.asyncHandler( + async (req, res) => { const statusPage = await this.db.getStatusPage(); return res.success({ msg: this.stringService.statusPageByUrl, @@ -62,8 +62,8 @@ class StatusPageController { "getStatusPage" ); - getStatusPageByUrl = asyncHandler( - async (req, res, next) => { + getStatusPageByUrl = this.asyncHandler( + async (req, res) => { await getStatusPageParamValidation.validateAsync(req.params); await getStatusPageQueryValidation.validateAsync(req.query); @@ -77,8 +77,8 @@ class StatusPageController { "getStatusPageByUrl" ); - getStatusPagesByTeamId = asyncHandler( - async (req, res, next) => { + getStatusPagesByTeamId = this.asyncHandler( + async (req, res) => { const teamId = req.user.teamId; const statusPages = await this.db.getStatusPagesByTeamId(teamId); @@ -91,8 +91,8 @@ class StatusPageController { "getStatusPagesByTeamId" ); - deleteStatusPage = asyncHandler( - async (req, res, next) => { + deleteStatusPage = this.asyncHandler( + async (req, res) => { await this.db.deleteStatusPage(req.params.url); return res.success({ msg: this.stringService.statusPageDelete, diff --git a/server/index.js b/server/index.js index 541f9ae14..7953d160b 100755 --- a/server/index.js +++ b/server/index.js @@ -3,7 +3,6 @@ import fs from "fs"; import swaggerUi from "swagger-ui-express"; import jwt from "jsonwebtoken"; import papaparse from "papaparse"; - import express from "express"; import helmet from "helmet"; import cors from "cors"; @@ -58,6 +57,8 @@ import PulseQueueHelper from "./service/infrastructure/PulseQueue/PulseQueueHelp import SuperSimpleQueue from "./service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; import SuperSimpleQueueHelper from "./service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import ErrorService from "./service/infrastructure/errorService.js"; + // Business services import UserService from "./service/business/userService.js"; import CheckService from "./service/business/checkService.js"; @@ -186,6 +187,7 @@ const startApp = async () => { }); const redisService = new RedisService({ Redis: IORedis, logger }); + const errorService = new ErrorService(); // const jobQueueHelper = new JobQueueHelper({ // redisService, @@ -240,6 +242,7 @@ const startApp = async () => { logger, stringService, jwt, + errorService, }); const checkService = new CheckService({ db, @@ -252,6 +255,7 @@ const startApp = async () => { settingsService, emailService, stringService, + errorService, }); const maintenanceWindowService = new MaintenanceWindowService({ db, @@ -266,11 +270,13 @@ const startApp = async () => { emailService, papaparse, logger, + errorService, }); // Register services // ServiceRegistry.register(JobQueue.SERVICE_NAME, jobQueue); // ServiceRegistry.register(JobQueue.SERVICE_NAME, pulseQueue); ServiceRegistry.register(JobQueue.SERVICE_NAME, superSimpleQueue); + ServiceRegistry.register(ErrorService.SERVICE_NAME, errorService); ServiceRegistry.register(MongoDB.SERVICE_NAME, db); ServiceRegistry.register(SettingsService.SERVICE_NAME, settingsService); ServiceRegistry.register(EmailService.SERVICE_NAME, emailService); @@ -285,6 +291,7 @@ const startApp = async () => { ServiceRegistry.register(InviteService.SERVICE_NAME, inviteService); ServiceRegistry.register(MaintenanceWindowService.SERVICE_NAME, maintenanceWindowService); ServiceRegistry.register(MonitorService.SERVICE_NAME, monitorService); + ServiceRegistry.register(DiagnosticService.SERVICE_NAME, diagnosticService); await translationService.initialize(); @@ -306,6 +313,7 @@ const startApp = async () => { stringService: ServiceRegistry.get(StringService.SERVICE_NAME), logger: logger, userService: ServiceRegistry.get(UserService.SERVICE_NAME), + errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); const monitorController = new MonitorController({ @@ -315,6 +323,7 @@ const startApp = async () => { stringService: ServiceRegistry.get(StringService.SERVICE_NAME), emailService: ServiceRegistry.get(EmailService.SERVICE_NAME), monitorService: ServiceRegistry.get(MonitorService.SERVICE_NAME), + errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); const settingsController = new SettingsController({ @@ -322,6 +331,7 @@ const startApp = async () => { settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), stringService: ServiceRegistry.get(StringService.SERVICE_NAME), emailService: ServiceRegistry.get(EmailService.SERVICE_NAME), + errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); const checkController = new CheckController({ @@ -329,11 +339,13 @@ const startApp = async () => { settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), stringService: ServiceRegistry.get(StringService.SERVICE_NAME), checkService: ServiceRegistry.get(CheckService.SERVICE_NAME), + errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); const inviteController = new InviteController({ stringService: ServiceRegistry.get(StringService.SERVICE_NAME), - inviteService, + inviteService: ServiceRegistry.get(InviteService.SERVICE_NAME), + errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); const maintenanceWindowController = new MaintenanceWindowController({ @@ -341,23 +353,34 @@ const startApp = async () => { settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), stringService: ServiceRegistry.get(StringService.SERVICE_NAME), maintenanceWindowService: ServiceRegistry.get(MaintenanceWindowService.SERVICE_NAME), + errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); - const queueController = new QueueController(ServiceRegistry.get(JobQueue.SERVICE_NAME), ServiceRegistry.get(StringService.SERVICE_NAME)); + const queueController = new QueueController({ + jobQueue: ServiceRegistry.get(JobQueue.SERVICE_NAME), + stringService: ServiceRegistry.get(StringService.SERVICE_NAME), + errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), + }); - const logController = new LogController(logger); + const logController = new LogController({ logger, errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME) }); - const statusPageController = new StatusPageController(ServiceRegistry.get(MongoDB.SERVICE_NAME), ServiceRegistry.get(StringService.SERVICE_NAME)); + const statusPageController = new StatusPageController({ + db: ServiceRegistry.get(MongoDB.SERVICE_NAME), + stringService: ServiceRegistry.get(StringService.SERVICE_NAME), + errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), + }); const notificationController = new NotificationController({ notificationService: ServiceRegistry.get(NotificationService.SERVICE_NAME), stringService: ServiceRegistry.get(StringService.SERVICE_NAME), statusService: ServiceRegistry.get(StatusService.SERVICE_NAME), db: ServiceRegistry.get(MongoDB.SERVICE_NAME), + errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); const diagnosticController = new DiagnosticController({ - diagnosticService, + diagnosticService: ServiceRegistry.get(DiagnosticService.SERVICE_NAME), + errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); //Create routes diff --git a/server/middleware/handleErrors.js b/server/middleware/handleErrors.js index 1efce6f67..782d37ad9 100755 --- a/server/middleware/handleErrors.js +++ b/server/middleware/handleErrors.js @@ -3,7 +3,6 @@ import ServiceRegistry from "../service/system/serviceRegistry.js"; import StringService from "../service/system/stringService.js"; const handleErrors = (error, req, res, next) => { - console.log("ERROR", error); const status = error.status || 500; const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); const message = error.message || stringService.authIncorrectPassword; diff --git a/server/service/business/inviteService.js b/server/service/business/inviteService.js index b70c151d4..d23d725e4 100644 --- a/server/service/business/inviteService.js +++ b/server/service/business/inviteService.js @@ -1,14 +1,14 @@ const SERVICE_NAME = "inviteService"; -import { createServerError } from "../../utils/errorUtils.js"; class InviteService { static SERVICE_NAME = SERVICE_NAME; - constructor({ db, settingsService, emailService, stringService }) { + constructor({ db, settingsService, emailService, stringService, errorService }) { this.db = db; this.settingsService = settingsService; this.emailService = emailService; this.stringService = stringService; + this.errorService = errorService; } getInviteToken = async ({ invite, teamId }) => { @@ -27,7 +27,7 @@ class InviteService { }); const result = await this.emailService.sendEmail(inviteRequest.email, "Welcome to Uptime Monitor", html); if (!result) { - throw createServerError("Failed to send invite e-mail... Please verify your settings."); + throw this.errorService.createServerError("Failed to send invite e-mail... Please verify your settings."); } }; diff --git a/server/service/business/monitorService.js b/server/service/business/monitorService.js index d0e8f92ca..babc3122f 100644 --- a/server/service/business/monitorService.js +++ b/server/service/business/monitorService.js @@ -1,12 +1,10 @@ import { createMonitorsBodyValidation } from "../../validation/joi.js"; -import { createServerError } from "../../utils/errorUtils.js"; -import logger from "../../utils/logger.js"; const SERVICE_NAME = "MonitorService"; class MonitorService { SERVICE_NAME = SERVICE_NAME; - constructor({ db, settingsService, jobQueue, stringService, emailService, papaparse }) { + constructor({ db, settingsService, jobQueue, stringService, emailService, papaparse, logger, errorService }) { this.db = db; this.settingsService = settingsService; this.jobQueue = jobQueue; @@ -14,14 +12,14 @@ class MonitorService { this.emailService = emailService; this.papaparse = papaparse; this.logger = logger; + this.errorService = errorService; + this.asyncHandler = errorService.asyncHandler; } verifyTeamAccess = async ({ teamId, monitorId }) => { const monitor = await this.db.getMonitorById(monitorId); if (!monitor?.teamId?.equals(teamId)) { - const error = new Error("Unauthorized"); - error.status = 403; - throw error; + throw this.errorService.createAuthorizationError(); } }; @@ -103,11 +101,11 @@ class MonitorService { complete: async ({ data, errors }) => { try { if (errors.length > 0) { - throw createServerError("Error parsing CSV"); + throw this.errorService.createServerError("Error parsing CSV"); } if (!data || data.length === 0) { - throw createServerError("CSV file contains no data rows"); + throw this.errorService.createServerError("CSV file contains no data rows"); } const enrichedData = data.map((monitor) => ({ @@ -156,7 +154,7 @@ class MonitorService { await this.db.deletePageSpeedChecksByMonitorId(monitor._id); await this.db.deleteNotificationsByMonitorId(monitor._id); } catch (error) { - logger.warn({ + this.logger.warn({ message: `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`, service: SERVICE_NAME, method: "deleteAllMonitors", @@ -195,7 +193,7 @@ class MonitorService { const messageId = await this.emailService.sendEmail(to, subject, html); if (!messageId) { - throw createServerError("Failed to send test email."); + throw this.errorService.createServerError("Failed to send test email."); } return messageId; diff --git a/server/service/business/userService.js b/server/service/business/userService.js index 90906552d..bd1ea3fa6 100644 --- a/server/service/business/userService.js +++ b/server/service/business/userService.js @@ -1,15 +1,15 @@ const SERVICE_NAME = "userService"; -import { createAuthError, createError } from "../../utils/errorUtils.js"; class UserService { static SERVICE_NAME = SERVICE_NAME; - constructor({ db, emailService, settingsService, logger, stringService, jwt }) { + constructor({ db, emailService, settingsService, logger, stringService, jwt, errorService }) { this.db = db; this.emailService = emailService; this.settingsService = settingsService; this.logger = logger; this.stringService = stringService; this.jwt = jwt; + this.errorService = errorService; } issueToken = (payload, appSettings) => { @@ -80,7 +80,7 @@ class UserService { // Compare password const match = await user.comparePassword(password); if (match !== true) { - throw createAuthError(this.stringService.authIncorrectPassword); + throw this.errorService.createAuthenticationError(this.stringService.authIncorrectPassword); } // Remove password from user object. Should this be abstracted to DB layer? @@ -109,7 +109,7 @@ class UserService { // If not a match, throw a 403 // 403 instead of 401 to avoid triggering axios interceptor if (!match) { - throw createError(this.stringService.authIncorrectPassword, 403); + throw this.errorService.createAuthorizationError(this.stringService.authIncorrectPassword); } // If a match, update the password updates.password = updates.newPassword; diff --git a/server/service/infrastructure/errorService.js b/server/service/infrastructure/errorService.js new file mode 100644 index 000000000..098c5b4ac --- /dev/null +++ b/server/service/infrastructure/errorService.js @@ -0,0 +1,161 @@ +class AppError extends Error { + constructor(message, status = 500, service = null, method = null, details = null) { + super(message); + this.status = status; + this.service = service; + this.method = method; + this.details = details; + + Error.captureStackTrace(this, this.constructor); + } +} + +class ValidationError extends AppError { + constructor(message, details = null, service = null, method = null) { + super(message, 422, service, method, details); + } +} + +class AuthenticationError extends AppError { + constructor(message, details = null, service = null, method = null) { + super(message, 401, service, method, details); + } +} + +class AuthorizationError extends AppError { + constructor(message, details = null, service = null, method = null) { + super(message, 403, service, method, details); + } +} + +class NotFoundError extends AppError { + constructor(message, details = null, service = null, method = null) { + super(message, 404, service, method, details); + } +} + +class ConflictError extends AppError { + constructor(message, details = null, service = null, method = null) { + super(message, 409, service, method, details); + } +} + +class DatabaseError extends AppError { + constructor(message, details = null, service = null, method = null) { + super(message, 500, service, method, details); + } +} + +class BadRequestError extends AppError { + constructor(message, details = null, service = null, method = null) { + super(message, 400, service, method, details); + } +} + +const SERVICE_NAME = "ErrorService"; +class ErrorService { + static SERVICE_NAME = SERVICE_NAME; + + constructor() {} + + createError = (message, status = 500, service = null, method = null, details = null) => { + return new AppError(message, status, service, method, details); + }; + + createValidationError = (message, details = null, service = null, method = null) => { + return new ValidationError(message, details, service, method); + }; + + createAuthenticationError = (message = "Unauthorized", details = null, service = null, method = null) => { + return new AuthenticationError(message, details, service, method); + }; + + createAuthorizationError = (message, details = null, service = null, method = null) => { + return new AuthorizationError(message, details, service, method); + }; + + createNotFoundError = (message, details = null, service = null, method = null) => { + return new NotFoundError(message, details, service, method); + }; + + createConflictError = (message, details = null, service = null, method = null) => { + return new ConflictError(message, details, service, method); + }; + + createDatabaseError = (message, details = null, service = null, method = null) => { + return new DatabaseError(message, details, service, method); + }; + + createServerError = (message, details = null, service = null, method = null) => { + return this.createError(message, 500, service, method, details); + }; + + createBadRequestError = (message = "BadRequest", details = null, service = null, method = null) => { + return new BadRequestError(message, details, service, method); + }; +} + +export default ErrorService; + +export const asyncHandler = (fn, serviceName, methodName) => { + return async (req, res, next) => { + try { + await fn(req, res, next); + } catch (error) { + // Handle validation errors + if (error.isJoi) { + const validationError = this.createValidationError(error.message, error.details, serviceName, methodName); + return next(validationError); + } + + if (error.name === "ValidationError") { + const validationError = this.createValidationError("Database validation failed", error.errors, serviceName, methodName); + return next(validationError); + } + + if (error.name === "CastError") { + const notFoundError = this.createNotFoundError( + "Invalid resource identifier", + { field: error.path, value: error.value }, + serviceName, + methodName + ); + return next(notFoundError); + } + + if (error.code === "11000") { + const conflictError = this.createConflictError("Resource already exists", { + originalError: error.message, + code: error.code, + }); + conflictError.service = serviceName; + conflictError.method = methodName; + return next(conflictError); + } + + if (error instanceof AppError) { + error.service = error.service || serviceName; + error.method = error.method || methodName; + return next(error); + } + + if (error.status) { + const appError = this.createError(error.message, error.status, serviceName, methodName, { + originalError: error.message, + stack: error.stack, + }); + return next(appError); + } + + // For unknown errors, create a server error + const appError = this.createServerError(error.message || "An unexpected error occurred", { + originalError: error.message, + stack: error.stack, + }); + appError.service = serviceName; + appError.method = methodName; + appError.stack = error.stack; // Preserve original stack + return next(appError); + } + }; +}; diff --git a/server/utils/errorUtils.js b/server/utils/errorUtils.js index 7d9a2959d..8b1378917 100644 --- a/server/utils/errorUtils.js +++ b/server/utils/errorUtils.js @@ -1,81 +1 @@ -class AppError extends Error { - constructor(message, status = 500, service = null, method = null, details = null) { - super(message); - this.status = status; - this.service = service; - this.method = method; - this.details = details; - Error.captureStackTrace(this, this.constructor); - } -} - -export const createError = (message, status = 500, service = null, method = null, details = null) => { - return new AppError(message, status, service, method, details); -}; - -export const createValidationError = (message, details = null, service = null, method = null) => { - return createError(message, 422, service, method, details); -}; - -export const createAuthError = (message, details = null, service = null, method = null) => { - return createError(message, 401, service, method, details); -}; - -export const createForbiddenError = (message, details = null, service = null, method = null) => { - return createError(message, 403, service, method, details); -}; - -export const createNotFoundError = (message, details = null, service = null, method = null) => { - return createError(message, 404, service, method, details); -}; - -export const createConflictError = (message, details = null, service = null, method = null) => { - return createError(message, 409, service, method, details); -}; - -export const createServerError = (message, details = null, service = null, method = null) => { - return createError(message, 500, service, method, details); -}; - -export const asyncHandler = (fn, serviceName, methodName) => { - return async (req, res, next) => { - try { - await fn(req, res, next); - } catch (error) { - // Handle validation errors - if (error.isJoi || error.name === "ValidationError") { - const validationError = createValidationError(error.message, error.details, serviceName, methodName); - return next(validationError); - } - - if (error instanceof AppError) { - error.service = error.service || serviceName; - error.method = error.method || methodName; - return next(error); - } - - if (error.code === "23505") { - const appError = createConflictError("Resource already exists", { - originalError: error.message, - code: error.code, - }); - appError.service = serviceName; - appError.method = methodName; - return next(appError); - } - - if (error.status) { - const appError = createError(error.message, error.status, serviceName, methodName, { originalError: error.message, stack: error.stack }); - return next(appError); - } - - // For unknown errors, create a server error - const appError = createServerError(error.message || "An unexpected error occurred", { originalError: error.message, stack: error.stack }); - appError.service = serviceName; - appError.method = methodName; - appError.stack = error.stack; // Preserve original stack - return next(appError); - } - }; -}; From 6cfa77a335aebc6482930be35e81c1d7baf7b0f9 Mon Sep 17 00:00:00 2001 From: Jesulayomy Date: Fri, 25 Jul 2025 11:11:39 -0400 Subject: [PATCH 083/226] [Uptime]: Add clone Route and restore cloning logic --- client/src/Pages/Uptime/Create/index.jsx | 16 ++++++++++------ client/src/Routes/index.jsx | 4 ++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/client/src/Pages/Uptime/Create/index.jsx b/client/src/Pages/Uptime/Create/index.jsx index fa8519b1f..9a847b251 100644 --- a/client/src/Pages/Uptime/Create/index.jsx +++ b/client/src/Pages/Uptime/Create/index.jsx @@ -60,9 +60,10 @@ const parseUrl = (url) => { * Create page renders monitor creation or configuration views. * @component */ -const UptimeCreate = () => { +const UptimeCreate = ({ clone }) => { const { monitorId } = useParams(); - const isCreate = typeof monitorId === "undefined"; + const isCreate = typeof monitorId === "undefined" || clone === true; + const isClone = clone === true; // States const [monitor, setMonitor] = useState({ @@ -162,15 +163,18 @@ const UptimeCreate = () => { let form = {}; if (isCreate) { form = { - ...rest, url: - monitor.type === "http" + monitor.type === "http" && !isClone ? `http${https ? "s" : ""}://` + monitor.url : monitor.url, - port: monitor.type === "port" ? monitor.port : undefined, name: monitor.name || monitor.url.substring(0, 50), type: monitor.type, + port: monitor.type === "port" ? monitor.port : undefined, interval: monitor.interval, + matchMethod: monitor.matchMethod, + expectedValue: monitor.expectedValue, + jsonPath: monitor.jsonPath, + ignoreTlsErrors: monitor.ignoreTlsErrors, }; } else { form = { @@ -265,7 +269,7 @@ const UptimeCreate = () => { const protocol = parsedUrl?.protocol?.replace(":", "") || ""; useEffect(() => { - if (!isCreate) { + if (!isCreate || isClone) { if (monitor.matchMethod) { setUseAdvancedMatching(true); } else { diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 5bb89db58..04d6f9b0b 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -83,6 +83,10 @@ const Routes = () => { path="/uptime/create" element={} /> + } + /> } From dc321580c3cf110e65e3b28251eafd17732e00dc Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 25 Jul 2025 10:16:29 -0700 Subject: [PATCH 084/226] refactor out common dependenceis --- server/controllers/announcementsController.js | 8 +- server/controllers/authController.js | 12 +-- server/controllers/baseController.js | 81 ++++++++++++++++++- server/controllers/checkController.js | 11 +-- server/controllers/diagnosticController.js | 8 +- server/controllers/inviteController.js | 12 +-- server/controllers/logController.js | 6 +- .../maintenanceWindowController.js | 8 +- server/controllers/monitorController.js | 8 +- server/controllers/notificationController.js | 8 +- server/controllers/queueController.js | 6 +- server/controllers/settingsController.js | 7 +- server/controllers/statusPageController.js | 7 +- server/index.js | 80 ++++++++---------- server/service/business/checkService.js | 55 +++++-------- .../business/maintenanceWindowService.js | 9 +-- server/service/business/monitorService.js | 1 - server/service/business/userService.js | 8 +- server/service/infrastructure/errorService.js | 65 +-------------- server/utils/logger.js | 3 + 20 files changed, 170 insertions(+), 233 deletions(-) diff --git a/server/controllers/announcementsController.js b/server/controllers/announcementsController.js index b17b8eb91..aeb7a3b9d 100755 --- a/server/controllers/announcementsController.js +++ b/server/controllers/announcementsController.js @@ -1,5 +1,4 @@ import { createAnnouncementValidation } from "../validation/joi.js"; -import { asyncHandler } from "../utils/errorUtils.js"; const SERVICE_NAME = "announcementController"; @@ -10,10 +9,9 @@ const SERVICE_NAME = "announcementController"; * @class AnnouncementController */ -class AnnouncementController { - constructor(db, stringService) { - this.db = db; - this.stringService = stringService; +class AnnouncementController extends BaseController { + constructor(commonDependencies) { + super(commonDependencies); this.createAnnouncement = this.createAnnouncement.bind(this); this.getAnnouncement = this.getAnnouncement.bind(this); } diff --git a/server/controllers/authController.js b/server/controllers/authController.js index 8dac9f028..00fc2caf5 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -27,25 +27,19 @@ class AuthController extends BaseController { /** * Creates an instance of AuthController. * + * @param {Object} commonDependencies - Common dependencies injected into the controller * @param {Object} dependencies - The dependencies required by the controller - * @param {Object} dependencies.db - Database service for data operations * @param {Object} dependencies.settingsService - Service for application settings * @param {Object} dependencies.emailService - Service for email operations * @param {Object} dependencies.jobQueue - Service for job queue operations - * @param {Object} dependencies.stringService - Service for string/localization - * @param {Object} dependencies.logger - Logger service * @param {Object} dependencies.userService - User business logic service */ - constructor({ db, settingsService, emailService, jobQueue, stringService, logger, userService, errorService }) { - super(); - this.db = db; + constructor(commonDependencies, { settingsService, emailService, jobQueue, userService }) { + super(commonDependencies); this.settingsService = settingsService; this.emailService = emailService; this.jobQueue = jobQueue; - this.stringService = stringService; - this.logger = logger; this.userService = userService; - this.errorService = errorService; } /** diff --git a/server/controllers/baseController.js b/server/controllers/baseController.js index 81df59deb..a84806a24 100644 --- a/server/controllers/baseController.js +++ b/server/controllers/baseController.js @@ -1,8 +1,83 @@ -import { asyncHandler } from "../service/infrastructure/errorService.js"; +import { AppError } from "../service/infrastructure/errorService.js"; + +export const createCommonDependencies = (serviceRegistry, dbServiceName, loggerServiceName, errorServiceName, stringServiceName) => { + return { + db: serviceRegistry.get(dbServiceName), + errorService: serviceRegistry.get(errorServiceName), + logger: serviceRegistry.get(loggerServiceName), + stringService: serviceRegistry.get(stringServiceName), + }; +}; class BaseController { - constructor() { - this.asyncHandler = asyncHandler; + constructor({ db, logger, errorService, ...additionalDependencies }) { + this.db = db; + this.logger = logger; + this.errorService = errorService; + Object.assign(this, additionalDependencies); + + this.asyncHandler = (fn, serviceName, methodName) => { + return async (req, res, next) => { + try { + await fn(req, res, next); + } catch (error) { + // Handle validation errors + if (error.isJoi) { + const validationError = this.errorService.createValidationError(error.message, error.details, serviceName, methodName); + return next(validationError); + } + + if (error.name === "ValidationError") { + const validationError = this.errorService.createValidationError("Database validation failed", error.errors, serviceName, methodName); + return next(validationError); + } + + if (error.name === "CastError") { + const notFoundError = this.errorService.createNotFoundError( + "Invalid resource identifier", + { field: error.path, value: error.value }, + serviceName, + methodName + ); + return next(notFoundError); + } + + if (error.code === "11000") { + const conflictError = this.errorService.createConflictError("Resource already exists", { + originalError: error.message, + code: error.code, + }); + conflictError.service = serviceName; + conflictError.method = methodName; + return next(conflictError); + } + + if (error instanceof AppError) { + error.service = error.service || serviceName; + error.method = error.method || methodName; + return next(error); + } + + if (error.status) { + const appError = this.errorService.createError(error.message, error.status, serviceName, methodName, { + originalError: error.message, + stack: error.stack, + }); + return next(appError); + } + + // For unknown errors, create a server error + const appError = this.errorService.createServerError(error.message || "An unexpected error occurred", { + originalError: error.message, + stack: error.stack, + }); + appError.service = serviceName; + appError.method = methodName; + appError.stack = error.stack; // Preserve original stack + return next(appError); + } + }; + }; } } export default BaseController; diff --git a/server/controllers/checkController.js b/server/controllers/checkController.js index 693f5a32d..08b6c2de9 100755 --- a/server/controllers/checkController.js +++ b/server/controllers/checkController.js @@ -26,20 +26,15 @@ class CheckController extends BaseController { /** * Creates an instance of CheckController. * + * @param {Object} commonDependencies - Common dependencies injected into the controller * @param {Object} dependencies - The dependencies required by the controller - * @param {Object} dependencies.db - Database service for data operations * @param {Object} dependencies.settingsService - Service for application settings - * @param {Object} dependencies.stringService - Service for string/localization * @param {Object} dependencies.checkService - Check business logic service - * @param {Object} dependencies.errorService - Error service for error handling */ - constructor({ db, settingsService, stringService, checkService, errorService }) { - super(); - this.db = db; + constructor(commonDependencies, { settingsService, checkService }) { + super(commonDependencies); this.settingsService = settingsService; - this.stringService = stringService; this.checkService = checkService; - this.errorService = errorService; } /** diff --git a/server/controllers/diagnosticController.js b/server/controllers/diagnosticController.js index 308755109..91b90f96b 100755 --- a/server/controllers/diagnosticController.js +++ b/server/controllers/diagnosticController.js @@ -12,15 +12,13 @@ import BaseController from "./baseController.js"; class DiagnosticController extends BaseController { /** * Creates an instance of DiagnosticController. - * + * @param {Object} commonDependencies - Common dependencies injected into the controller * @param {Object} dependencies - The dependencies required by the controller * @param {Object} dependencies.diagnosticService - Service for system diagnostics and monitoring - * @param {Object} dependencies.errorService - Service for error handling */ - constructor({ diagnosticService, errorService }) { - super(); + constructor(commonDependencies, { diagnosticService }) { + super(commonDependencies); this.diagnosticService = diagnosticService; - this.errorService = errorService; } /** diff --git a/server/controllers/inviteController.js b/server/controllers/inviteController.js index 4cf7ec8ed..3f237c6ec 100755 --- a/server/controllers/inviteController.js +++ b/server/controllers/inviteController.js @@ -9,16 +9,12 @@ const SERVICE_NAME = "inviteController"; class InviteController extends BaseController { /** * Creates a new InviteController instance - * @param {Object} dependencies - Dependencies injected into the controller - * @param {Object} dependencies.stringService - Service for internationalized strings - * @param {Object} dependencies.inviteServ ice - Service for invite-related operations - * @param {Object} dependencies.errorService - Service for error handling + * @param {Object} commonDependencies - Common dependencies injected into the controller + * @param {Object} dependencies.inviteService - Service for invite-related operations */ - constructor({ stringService, inviteService, errorService }) { - super(); - this.stringService = stringService; + constructor(commonDependencies, { inviteService }) { + super(commonDependencies); this.inviteService = inviteService; - this.errorService = errorService; } /** diff --git a/server/controllers/logController.js b/server/controllers/logController.js index 14f7a6d58..aa2780eb6 100644 --- a/server/controllers/logController.js +++ b/server/controllers/logController.js @@ -2,10 +2,8 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "LogController"; class LogController extends BaseController { - constructor({ logger, errorService }) { - super(); - this.logger = logger; - this.errorService = errorService; + constructor(commonDependencies) { + super(commonDependencies); } getLogs = this.asyncHandler( diff --git a/server/controllers/maintenanceWindowController.js b/server/controllers/maintenanceWindowController.js index 59ea10140..e54726ea3 100755 --- a/server/controllers/maintenanceWindowController.js +++ b/server/controllers/maintenanceWindowController.js @@ -12,14 +12,10 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "maintenanceWindowController"; class MaintenanceWindowController extends BaseController { - constructor({ db, settingsService, stringService, maintenanceWindowService, errorService }) { - super(); - this.db = db; + constructor(commonDependencies, { settingsService, maintenanceWindowService }) { + super(commonDependencies); this.settingsService = settingsService; - this.stringService = stringService; this.maintenanceWindowService = maintenanceWindowService; - this.errorService = errorService; - this.asyncHandler = errorService.asyncHandler; } createMaintenanceWindows = this.asyncHandler( diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index 4e2c021d2..2187df5d0 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -18,16 +18,12 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "monitorController"; class MonitorController extends BaseController { - constructor({ db, settingsService, jobQueue, stringService, emailService, monitorService, errorService }) { - super(); - this.db = db; + constructor(commonDependencies, { settingsService, jobQueue, emailService, monitorService }) { + super(commonDependencies); this.settingsService = settingsService; this.jobQueue = jobQueue; - this.stringService = stringService; this.emailService = emailService; this.monitorService = monitorService; - this.errorService = errorService; - this.asyncHandler = errorService.asyncHandler; } async verifyTeamAccess(teamId, monitorId) { diff --git a/server/controllers/notificationController.js b/server/controllers/notificationController.js index 32ae22aae..6e85b97b8 100755 --- a/server/controllers/notificationController.js +++ b/server/controllers/notificationController.js @@ -4,14 +4,10 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "NotificationController"; class NotificationController extends BaseController { - constructor({ notificationService, stringService, statusService, db, errorService }) { - super(); + constructor(commonDependencies, { notificationService, statusService }) { + super(commonDependencies); this.notificationService = notificationService; - this.stringService = stringService; this.statusService = statusService; - this.db = db; - this.errorService = errorService; - this.asyncHandler = errorService.asyncHandler; } testNotification = this.asyncHandler( diff --git a/server/controllers/queueController.js b/server/controllers/queueController.js index 72864da38..757350652 100755 --- a/server/controllers/queueController.js +++ b/server/controllers/queueController.js @@ -2,11 +2,9 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "JobQueueController"; class JobQueueController extends BaseController { - constructor({ jobQueue, stringService, errorService }) { - super(); + constructor(commonDependencies, { jobQueue }) { + super(commonDependencies); this.jobQueue = jobQueue; - this.stringService = stringService; - this.errorService = errorService; } getMetrics = this.asyncHandler( diff --git a/server/controllers/settingsController.js b/server/controllers/settingsController.js index c54ba9e7e..d97c19e88 100755 --- a/server/controllers/settingsController.js +++ b/server/controllers/settingsController.js @@ -5,13 +5,10 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "SettingsController"; class SettingsController extends BaseController { - constructor({ db, settingsService, stringService, emailService, errorService }) { - super(); - this.db = db; + constructor(commonDependencies, { settingsService, emailService }) { + super(commonDependencies); this.settingsService = settingsService; - this.stringService = stringService; this.emailService = emailService; - this.errorService = errorService; } buildAppSettings = (dbSettings) => { diff --git a/server/controllers/statusPageController.js b/server/controllers/statusPageController.js index 79ebb50ab..09d1d8b5f 100755 --- a/server/controllers/statusPageController.js +++ b/server/controllers/statusPageController.js @@ -4,11 +4,8 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "statusPageController"; class StatusPageController extends BaseController { - constructor({ db, stringService, errorService }) { - super(); - this.db = db; - this.stringService = stringService; - this.errorService = errorService; + constructor(commonDependencies) { + super(commonDependencies); } createStatusPage = this.asyncHandler( diff --git a/server/index.js b/server/index.js index 7953d160b..b6f1dd3fe 100755 --- a/server/index.js +++ b/server/index.js @@ -7,11 +7,13 @@ import express from "express"; import helmet from "helmet"; import cors from "cors"; import compression from "compression"; +import { Logger } from "./utils/logger.js"; import logger from "./utils/logger.js"; import { verifyJWT } from "./middleware/verifyJWT.js"; import { handleErrors } from "./middleware/handleErrors.js"; import { responseHandler } from "./middleware/responseHandler.js"; import { fileURLToPath } from "url"; +import { createCommonDependencies } from "./controllers/baseController.js"; import AuthRoutes from "./routes/authRoute.js"; import AuthController from "./controllers/authController.js"; @@ -248,6 +250,7 @@ const startApp = async () => { db, settingsService, stringService, + errorService, }); const diagnosticService = new DiagnosticService(); const inviteService = new InviteService({ @@ -261,6 +264,7 @@ const startApp = async () => { db, settingsService, stringService, + errorService, }); const monitorService = new MonitorService({ db, @@ -275,6 +279,7 @@ const startApp = async () => { // Register services // ServiceRegistry.register(JobQueue.SERVICE_NAME, jobQueue); // ServiceRegistry.register(JobQueue.SERVICE_NAME, pulseQueue); + ServiceRegistry.register(Logger.SERVICE_NAME, logger); ServiceRegistry.register(JobQueue.SERVICE_NAME, superSimpleQueue); ServiceRegistry.register(ErrorService.SERVICE_NAME, errorService); ServiceRegistry.register(MongoDB.SERVICE_NAME, db); @@ -305,82 +310,63 @@ const startApp = async () => { process.on("SIGTERM", shutdown); //Create controllers - const authController = new AuthController({ - db: ServiceRegistry.get(MongoDB.SERVICE_NAME), + + const commonDependencies = createCommonDependencies( + ServiceRegistry, + MongoDB.SERVICE_NAME, + Logger.SERVICE_NAME, + ErrorService.SERVICE_NAME, + StringService.SERVICE_NAME + ); + + const authController = new AuthController(commonDependencies, { settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), emailService: ServiceRegistry.get(EmailService.SERVICE_NAME), jobQueue: ServiceRegistry.get(JobQueue.SERVICE_NAME), - stringService: ServiceRegistry.get(StringService.SERVICE_NAME), - logger: logger, userService: ServiceRegistry.get(UserService.SERVICE_NAME), - errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); - const monitorController = new MonitorController({ - db: ServiceRegistry.get(MongoDB.SERVICE_NAME), + const monitorController = new MonitorController(commonDependencies, { settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), jobQueue: ServiceRegistry.get(JobQueue.SERVICE_NAME), - stringService: ServiceRegistry.get(StringService.SERVICE_NAME), emailService: ServiceRegistry.get(EmailService.SERVICE_NAME), monitorService: ServiceRegistry.get(MonitorService.SERVICE_NAME), - errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); - const settingsController = new SettingsController({ - db: ServiceRegistry.get(MongoDB.SERVICE_NAME), + const settingsController = new SettingsController(commonDependencies, { settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), - stringService: ServiceRegistry.get(StringService.SERVICE_NAME), emailService: ServiceRegistry.get(EmailService.SERVICE_NAME), - errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); - const checkController = new CheckController({ - db: ServiceRegistry.get(MongoDB.SERVICE_NAME), + const checkController = new CheckController(commonDependencies, { settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), - stringService: ServiceRegistry.get(StringService.SERVICE_NAME), checkService: ServiceRegistry.get(CheckService.SERVICE_NAME), - errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); - const inviteController = new InviteController({ - stringService: ServiceRegistry.get(StringService.SERVICE_NAME), + const inviteController = new InviteController(commonDependencies, { inviteService: ServiceRegistry.get(InviteService.SERVICE_NAME), - errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); - const maintenanceWindowController = new MaintenanceWindowController({ - db: ServiceRegistry.get(MongoDB.SERVICE_NAME), + const maintenanceWindowController = new MaintenanceWindowController(commonDependencies, { settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), - stringService: ServiceRegistry.get(StringService.SERVICE_NAME), maintenanceWindowService: ServiceRegistry.get(MaintenanceWindowService.SERVICE_NAME), - errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); - const queueController = new QueueController({ + const queueController = new QueueController(commonDependencies, { jobQueue: ServiceRegistry.get(JobQueue.SERVICE_NAME), - stringService: ServiceRegistry.get(StringService.SERVICE_NAME), - errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); - const logController = new LogController({ logger, errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME) }); + const logController = new LogController(commonDependencies); - const statusPageController = new StatusPageController({ - db: ServiceRegistry.get(MongoDB.SERVICE_NAME), - stringService: ServiceRegistry.get(StringService.SERVICE_NAME), - errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), - }); + const statusPageController = new StatusPageController(commonDependencies); - const notificationController = new NotificationController({ + const notificationController = new NotificationController(commonDependencies, { notificationService: ServiceRegistry.get(NotificationService.SERVICE_NAME), - stringService: ServiceRegistry.get(StringService.SERVICE_NAME), statusService: ServiceRegistry.get(StatusService.SERVICE_NAME), - db: ServiceRegistry.get(MongoDB.SERVICE_NAME), - errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); - const diagnosticController = new DiagnosticController({ + const diagnosticController = new DiagnosticController(commonDependencies, { diagnosticService: ServiceRegistry.get(DiagnosticService.SERVICE_NAME), - errorService: ServiceRegistry.get(ErrorService.SERVICE_NAME), }); //Create routes @@ -393,9 +379,9 @@ const startApp = async () => { const queueRoutes = new QueueRoutes(queueController); const logRoutes = new LogRoutes(logController); const statusPageRoutes = new StatusPageRoutes(statusPageController); - const notificationRoutes = new NotificationRoutes(notificationController); const diagnosticRoutes = new DiagnosticRoutes(diagnosticController); + // Middleware app.use(express.static(frontendPath)); app.use(responseHandler); @@ -438,16 +424,16 @@ const startApp = async () => { //routes app.use("/api/v1/auth", authRoutes.getRouter()); - app.use("/api/v1/checks", verifyJWT, checkRoutes.getRouter()); - app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter()); - app.use("/api/v1/invite", inviteRoutes.getRouter()); - app.use("/api/v1/logs", verifyJWT, logRoutes.getRouter()); - app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRoutes.getRouter()); app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter()); - app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); - app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter()); app.use("/api/v1/settings", verifyJWT, settingsRoutes.getRouter()); + app.use("/api/v1/checks", verifyJWT, checkRoutes.getRouter()); + app.use("/api/v1/invite", inviteRoutes.getRouter()); + app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRoutes.getRouter()); + app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter()); + app.use("/api/v1/logs", verifyJWT, logRoutes.getRouter()); app.use("/api/v1/status-page", statusPageRoutes.getRouter()); + app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); + app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter()); app.use("/api/v1/health", (req, res) => { res.json({ diff --git a/server/service/business/checkService.js b/server/service/business/checkService.js index e52a4919a..3d8206644 100644 --- a/server/service/business/checkService.js +++ b/server/service/business/checkService.js @@ -3,37 +3,30 @@ const SERVICE_NAME = "checkService"; class CheckService { static SERVICE_NAME = SERVICE_NAME; - constructor({ db, settingsService, stringService }) { + constructor({ db, settingsService, stringService, errorService }) { this.db = db; this.settingsService = settingsService; this.stringService = stringService; + this.errorService = errorService; } getChecksByMonitor = async ({ monitorId, query, teamId }) => { if (!monitorId) { - throw new Error("No monitor ID in request"); + throw this.errorService.createBadRequestError("No monitor ID in request"); } if (!teamId) { - throw new Error("No team ID in request"); + throw this.errorService.createBadRequestError("No team ID in request"); } const monitor = await this.db.getMonitorById(monitorId); if (!monitor) { - const err = new Error("Monitor not found"); - err.status = 404; - err.service = SERVICE_NAME; - err.method = "getChecksByMonitor"; - throw err; + throw this.errorService.createNotFoundError("Monitor not found"); } if (!monitor.teamId.equals(teamId)) { - const err = new Error("Unauthorized"); - err.status = 403; - err.service = SERVICE_NAME; - err.method = "getChecksByMonitor"; - throw err; + throw this.errorService.createAuthorizationError(); } let { type, sortOrder, dateRange, filter, ack, page, rowsPerPage, status } = query; @@ -55,7 +48,7 @@ class CheckService { let { sortOrder, dateRange, filter, ack, page, rowsPerPage } = query; if (!teamId) { - throw new Error("No team ID in request"); + throw this.errorService.createBadRequestError("No team ID in request"); } const checkData = await this.db.getChecksByTeam({ @@ -72,7 +65,7 @@ class CheckService { getChecksSummaryByTeamId = async ({ teamId }) => { if (!teamId) { - throw new Error("No team ID in request"); + throw this.errorService.createBadRequestError("No team ID in request"); } const summary = await this.db.getChecksSummaryByTeamId({ teamId }); @@ -81,11 +74,11 @@ class CheckService { ackCheck = async ({ checkId, teamId, ack }) => { if (!checkId) { - throw new Error("No check ID in request"); + throw this.errorService.createBadRequestError("No check ID in request"); } if (!teamId) { - throw new Error("No team ID in request"); + throw this.errorService.createBadRequestError("No team ID in request"); } const updatedCheck = await this.db.ackCheck(checkId, teamId, ack); @@ -95,20 +88,16 @@ class CheckService { ackAllChecks = async ({ monitorId, path, teamId, ack }) => { if (path === "monitor") { if (!monitorId) { - throw new Error("No monitor ID in request"); + throw this.errorService.createBadRequestError("No monitor ID in request"); } const monitor = await this.db.getMonitorById(monitorId); if (!monitor) { - throw new Error("Monitor not found"); + throw this.errorService.createNotFoundError("Monitor not found"); } if (!monitor.teamId.equals(teamId)) { - const err = new Error("Unauthorized"); - err.status = 403; - err.service = SERVICE_NAME; - err.method = "ackAllChecks"; - throw err; + throw this.errorService.createAuthorizationError(); } } @@ -118,29 +107,21 @@ class CheckService { deleteChecks = async ({ monitorId, teamId }) => { if (!monitorId) { - throw new Error("No monitor ID in request"); + throw this.errorService.createBadRequestError("No monitor ID in request"); } if (!teamId) { - throw new Error("No team ID in request"); + throw this.errorService.createBadRequestError("No team ID in request"); } const monitor = await this.db.getMonitorById(monitorId); if (!monitor) { - const err = new Error("Monitor not found"); - err.status = 404; - err.service = SERVICE_NAME; - err.method = "deleteChecks"; - throw err; + throw this.errorService.createNotFoundError("Monitor not found"); } if (!monitor.teamId.equals(teamId)) { - const err = new Error("Unauthorized"); - err.status = 403; - err.service = SERVICE_NAME; - err.method = "deleteChecks"; - throw err; + throw this.errorService.createAuthorizationError(); } const deletedCount = await this.db.deleteChecks(monitorId); @@ -148,7 +129,7 @@ class CheckService { }; deleteChecksByTeamId = async ({ teamId }) => { if (!teamId) { - throw new Error("No team ID in request"); + throw this.errorService.createBadRequestError("No team ID in request"); } const deletedCount = await this.db.deleteChecksByTeamId(teamId); diff --git a/server/service/business/maintenanceWindowService.js b/server/service/business/maintenanceWindowService.js index 6140a5682..0f58aca85 100644 --- a/server/service/business/maintenanceWindowService.js +++ b/server/service/business/maintenanceWindowService.js @@ -2,10 +2,11 @@ const SERVICE_NAME = "maintenanceWindowService"; class MaintenanceWindowService { static SERVICE_NAME = SERVICE_NAME; - constructor({ db, settingsService, stringService }) { + constructor({ db, settingsService, stringService, errorService }) { this.db = db; this.settingsService = settingsService; this.stringService = stringService; + this.errorService = errorService; } createMaintenanceWindow = async ({ teamId, body }) => { @@ -15,11 +16,7 @@ class MaintenanceWindowService { const unauthorizedMonitors = monitors.filter((monitor) => !monitor.teamId.equals(teamId)); if (unauthorizedMonitors.length > 0) { - const error = new Error("Unauthorized access to one or more monitors"); - error.status = 403; - error.service = SERVICE_NAME; - error.method = "createMaintenanceWindows"; - throw error; + throw this.errorService.createAuthorizationError(); } const dbTransactions = monitorIds.map((monitorId) => { diff --git a/server/service/business/monitorService.js b/server/service/business/monitorService.js index babc3122f..a2bf06dfd 100644 --- a/server/service/business/monitorService.js +++ b/server/service/business/monitorService.js @@ -13,7 +13,6 @@ class MonitorService { this.papaparse = papaparse; this.logger = logger; this.errorService = errorService; - this.asyncHandler = errorService.asyncHandler; } verifyTeamAccess = async ({ teamId, monitorId }) => { diff --git a/server/service/business/userService.js b/server/service/business/userService.js index bd1ea3fa6..12dd4207f 100644 --- a/server/service/business/userService.js +++ b/server/service/business/userService.js @@ -154,23 +154,23 @@ class UserService { deleteUser = async (user) => { const email = user?.email; if (!email) { - throw new Error("No email in request"); + throw this.errorService.createBadRequestError("No email in request"); } const teamId = user?.teamId; const userId = user?._id; if (!teamId) { - throw new Error("No team ID in request"); + throw this.errorService.createBadRequestError("No team ID in request"); } if (!userId) { - throw new Error("No user ID in request"); + throw this.errorService.createBadRequestError("No user ID in request"); } const roles = user?.role; if (roles.includes("demo")) { - throw new Error("Demo user cannot be deleted"); + throw this.errorService.createBadRequestError("Demo user cannot be deleted"); } // 1. Find all the monitors associated with the team ID if superadmin diff --git a/server/service/infrastructure/errorService.js b/server/service/infrastructure/errorService.js index 098c5b4ac..552816b73 100644 --- a/server/service/infrastructure/errorService.js +++ b/server/service/infrastructure/errorService.js @@ -1,4 +1,4 @@ -class AppError extends Error { +export class AppError extends Error { constructor(message, status = 500, service = null, method = null, details = null) { super(message); this.status = status; @@ -96,66 +96,3 @@ class ErrorService { } export default ErrorService; - -export const asyncHandler = (fn, serviceName, methodName) => { - return async (req, res, next) => { - try { - await fn(req, res, next); - } catch (error) { - // Handle validation errors - if (error.isJoi) { - const validationError = this.createValidationError(error.message, error.details, serviceName, methodName); - return next(validationError); - } - - if (error.name === "ValidationError") { - const validationError = this.createValidationError("Database validation failed", error.errors, serviceName, methodName); - return next(validationError); - } - - if (error.name === "CastError") { - const notFoundError = this.createNotFoundError( - "Invalid resource identifier", - { field: error.path, value: error.value }, - serviceName, - methodName - ); - return next(notFoundError); - } - - if (error.code === "11000") { - const conflictError = this.createConflictError("Resource already exists", { - originalError: error.message, - code: error.code, - }); - conflictError.service = serviceName; - conflictError.method = methodName; - return next(conflictError); - } - - if (error instanceof AppError) { - error.service = error.service || serviceName; - error.method = error.method || methodName; - return next(error); - } - - if (error.status) { - const appError = this.createError(error.message, error.status, serviceName, methodName, { - originalError: error.message, - stack: error.stack, - }); - return next(appError); - } - - // For unknown errors, create a server error - const appError = this.createServerError(error.message || "An unexpected error occurred", { - originalError: error.message, - stack: error.stack, - }); - appError.service = serviceName; - appError.method = methodName; - appError.stack = error.stack; // Preserve original stack - return next(appError); - } - }; -}; diff --git a/server/utils/logger.js b/server/utils/logger.js index 6fc240e4c..8cb57f9a7 100755 --- a/server/utils/logger.js +++ b/server/utils/logger.js @@ -2,7 +2,10 @@ import { createLogger, format, transports } from "winston"; import dotenv from "dotenv"; dotenv.config(); +const SERVICE_NAME = "Logger"; + class Logger { + static SERVICE_NAME = SERVICE_NAME; constructor() { this.logCache = []; this.maxCacheSize = 1000; From 3059e730e2495039635e742b82cfe74cf600e9b2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 25 Jul 2025 10:19:22 -0700 Subject: [PATCH 085/226] type errors --- server/service/business/monitorService.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/service/business/monitorService.js b/server/service/business/monitorService.js index a2bf06dfd..2a2b27aa3 100644 --- a/server/service/business/monitorService.js +++ b/server/service/business/monitorService.js @@ -90,7 +90,7 @@ class MonitorService { if (["port", "interval"].includes(header)) { const num = parseInt(value, 10); if (isNaN(num)) { - throw new Error(`${header} should be a valid number, got: ${value}`); + throw this.errorService.createBadRequestError(`${header} should be a valid number, got: ${value}`); } return num; } @@ -237,10 +237,10 @@ class MonitorService { }; exportMonitorsToCSV = async ({ teamId }) => { - const monitors = await this.monitorService.getMonitorsByTeamId({ teamId }); + const monitors = await this.db.getMonitorsByTeamId({ teamId }); if (!monitors || monitors.length === 0) { - throw new Error("No monitors to export"); + throw this.errorService.createNotFoundError("No monitors to export"); } const csvData = monitors?.filteredMonitors?.map((monitor) => ({ From bb2ab8e08a35514518bb05434e55c58fc788cdd9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 25 Jul 2025 10:29:04 -0700 Subject: [PATCH 086/226] format --- server/utils/errorUtils.js | 1 - 1 file changed, 1 deletion(-) diff --git a/server/utils/errorUtils.js b/server/utils/errorUtils.js index 8b1378917..e69de29bb 100644 --- a/server/utils/errorUtils.js +++ b/server/utils/errorUtils.js @@ -1 +0,0 @@ - From cafd9f9899394ae409449faca9e01bda7bcae4fc Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 25 Jul 2025 10:29:40 -0700 Subject: [PATCH 087/226] remove empty file --- server/utils/errorUtils.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 server/utils/errorUtils.js diff --git a/server/utils/errorUtils.js b/server/utils/errorUtils.js deleted file mode 100644 index e69de29bb..000000000 From 1aedf21566281a8ac7f10614d31a02a9f6208cce Mon Sep 17 00:00:00 2001 From: Jesulayomy Date: Fri, 25 Jul 2025 13:37:17 -0400 Subject: [PATCH 088/226] [Uptime]: Added PropType validation, passed isClone directly as prop --- client/src/Pages/Uptime/Create/index.jsx | 10 +++++++--- client/src/Routes/index.jsx | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/client/src/Pages/Uptime/Create/index.jsx b/client/src/Pages/Uptime/Create/index.jsx index 9a847b251..e0cf7c372 100644 --- a/client/src/Pages/Uptime/Create/index.jsx +++ b/client/src/Pages/Uptime/Create/index.jsx @@ -22,6 +22,7 @@ import PulseDot from "../../../Components/Animated/PulseDot"; import SkeletonLayout from "./skeleton"; // Utils +import PropTypes from "prop-types"; import { useTheme } from "@emotion/react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -60,10 +61,9 @@ const parseUrl = (url) => { * Create page renders monitor creation or configuration views. * @component */ -const UptimeCreate = ({ clone }) => { +const UptimeCreate = ({ isClone = false }) => { const { monitorId } = useParams(); - const isCreate = typeof monitorId === "undefined" || clone === true; - const isClone = clone === true; + const isCreate = typeof monitorId === "undefined" || isClone; // States const [monitor, setMonitor] = useState({ @@ -720,4 +720,8 @@ const UptimeCreate = ({ clone }) => { ); }; +UptimeCreate.propTypes = { + isClone: PropTypes.bool, +}; + export default UptimeCreate; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 04d6f9b0b..350d2f82d 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -85,7 +85,7 @@ const Routes = () => { /> } + element={} /> Date: Fri, 25 Jul 2025 15:15:14 -0700 Subject: [PATCH 089/226] refactor server --- server/app.js | 73 +++ server/config/controllers.js | 66 +++ server/config/database.js | 0 server/config/routes.js | 39 ++ server/config/services.js | 152 ++++++ server/controllers/announcementsController.js | 6 + server/controllers/authController.js | 5 + server/controllers/baseController.js | 10 +- server/controllers/checkController.js | 5 + server/controllers/diagnosticController.js | 4 + server/controllers/inviteController.js | 5 + server/controllers/logController.js | 5 + .../maintenanceWindowController.js | 5 + server/controllers/monitorController.js | 5 + server/controllers/notificationController.js | 5 + server/controllers/queueController.js | 5 + server/controllers/settingsController.js | 5 + server/controllers/statusPageController.js | 5 + server/db/mongo/MongoDB.js | 5 +- server/index.js | 447 +----------------- server/server.js | 1 + server/service/business/checkService.js | 4 + server/service/business/diagnosticService.js | 5 + server/service/business/inviteService.js | 4 + .../business/maintenanceWindowService.js | 5 + server/service/business/monitorService.js | 6 +- server/service/business/userService.js | 5 + server/service/data/redisService.js | 5 + .../infrastructure/JobQueue/JobQueue.js | 4 + .../infrastructure/JobQueue/JobQueueHelper.js | 6 + .../infrastructure/PulseQueue/PulseQueue.js | 6 +- .../PulseQueue/PulseQueueHelper.js | 6 + .../SuperSimpleQueue/SuperSimpleQueue.js | 6 +- .../SuperSimpleQueueHelper.js | 7 +- .../service/infrastructure/bufferService.js | 6 +- server/service/infrastructure/emailService.js | 6 +- server/service/infrastructure/errorService.js | 5 +- .../service/infrastructure/networkService.js | 4 + .../infrastructure/notificationService.js | 4 + .../infrastructure/notificationUtils.js | 8 + .../service/infrastructure/statusService.js | 6 +- server/service/system/serviceRegistry.js | 4 + server/service/system/settingsService.js | 9 +- server/service/system/stringService.js | 8 +- server/service/system/translationService.js | 5 +- server/service/userService.js | 208 -------- server/shutdown.js | 34 ++ server/utils/logger.js | 5 + 48 files changed, 584 insertions(+), 650 deletions(-) create mode 100644 server/app.js create mode 100644 server/config/controllers.js create mode 100644 server/config/database.js create mode 100644 server/config/routes.js create mode 100644 server/config/services.js create mode 100644 server/server.js delete mode 100644 server/service/userService.js create mode 100644 server/shutdown.js diff --git a/server/app.js b/server/app.js new file mode 100644 index 000000000..af53dbf56 --- /dev/null +++ b/server/app.js @@ -0,0 +1,73 @@ +import express from "express"; +import path from "path"; +import { responseHandler } from "./middleware/responseHandler.js"; +import cors from "cors"; +import helmet from "helmet"; +import compression from "compression"; +import languageMiddleware from "./middleware/languageMiddleware.js"; +import swaggerUi from "swagger-ui-express"; +import { handleErrors } from "./middleware/handleErrors.js"; +import { setupRoutes } from "./config/routes.js"; +export const createApp = ({ services, controllers, appSettings, frontendPath, openApiSpec }) => { + const allowedOrigin = appSettings.clientHost; + + const app = express(); + + // Static files + app.use(express.static(frontendPath)); + + // Response handler + app.use(responseHandler); + + app.use( + cors({ + origin: allowedOrigin, + methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", + allowedHeaders: "*", + credentials: true, + }) + ); + app.use(express.json()); + app.use( + helmet({ + hsts: false, + contentSecurityPolicy: { + useDefaults: true, + directives: { + upgradeInsecureRequests: null, + }, + }, + }) + ); + app.use( + compression({ + level: 6, + threshold: 1024, + filter: (req, res) => { + if (req.headers["x-no-compression"]) { + return false; + } + return compression.filter(req, res); + }, + }) + ); + app.use(languageMiddleware(services.stringService, services.translationService, services.settingsService)); + // Swagger UI + app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openApiSpec)); + + app.use("/api/v1/health", (req, res) => { + res.json({ + status: "OK", + }); + }); + + // Main app routes + setupRoutes(app, controllers); + + // FE routes + app.get("*", (req, res) => { + res.sendFile(path.join(frontendPath, "index.html")); + }); + app.use(handleErrors); + return app; +}; diff --git a/server/config/controllers.js b/server/config/controllers.js new file mode 100644 index 000000000..2185f9ffd --- /dev/null +++ b/server/config/controllers.js @@ -0,0 +1,66 @@ +import { createCommonDependencies } from "../controllers/baseController.js"; + +// Services + +// Controllers +import MonitorController from "../controllers/monitorController.js"; +import AuthController from "../controllers/authController.js"; +import SettingsController from "../controllers/settingsController.js"; +import CheckController from "../controllers/checkController.js"; +import InviteController from "../controllers/inviteController.js"; +import MaintenanceWindowController from "../controllers/maintenanceWindowController.js"; +import QueueController from "../controllers/queueController.js"; +import LogController from "../controllers/logController.js"; +import StatusPageController from "../controllers/statusPageController.js"; +import NotificationController from "../controllers/notificationController.js"; +import DiagnosticController from "../controllers/diagnosticController.js"; + +export const initializeControllers = (services) => { + const controllers = {}; + const commonDependencies = createCommonDependencies(services.db, services.logger, services.errorService, services.stringService); + + controllers.authController = new AuthController(commonDependencies, { + settingsService: services.settingsService, + emailService: services.emailService, + jobQueue: services.jobQueue, + userService: services.userService, + }); + + controllers.monitorController = new MonitorController(commonDependencies, { + settingsService: services.settingsService, + jobQueue: services.jobQueue, + emailService: services.emailService, + monitorService: services.monitorService, + }); + + controllers.settingsController = new SettingsController(commonDependencies, { + settingsService: services.settingsService, + emailService: services.emailService, + }); + controllers.checkController = new CheckController(commonDependencies, { + settingsService: services.settingsService, + checkService: services.checkService, + }); + controllers.inviteController = new InviteController(commonDependencies, { + inviteService: services.inviteService, + }); + + controllers.maintenanceWindowController = new MaintenanceWindowController(commonDependencies, { + settingsService: services.settingsService, + maintenanceWindowService: services.maintenanceWindowService, + }); + controllers.queueController = new QueueController(commonDependencies, { + jobQueue: services.jobQueue, + }); + controllers.logController = new LogController(commonDependencies); + controllers.statusPageController = new StatusPageController(commonDependencies); + controllers.notificationController = new NotificationController(commonDependencies, { + notificationService: services.notificationService, + statusService: services.statusService, + }); + controllers.diagnosticController = new DiagnosticController(commonDependencies, { + diagnosticService: services.diagnosticService, + }); + + return controllers; +}; diff --git a/server/config/database.js b/server/config/database.js new file mode 100644 index 000000000..e69de29bb diff --git a/server/config/routes.js b/server/config/routes.js new file mode 100644 index 000000000..64b1df474 --- /dev/null +++ b/server/config/routes.js @@ -0,0 +1,39 @@ +import { verifyJWT } from "../middleware/verifyJWT.js"; + +import AuthRoutes from "../routes/authRoute.js"; +import InviteRoutes from "../routes/inviteRoute.js"; +import MonitorRoutes from "../routes/monitorRoute.js"; +import CheckRoutes from "../routes/checkRoute.js"; +import SettingsRoutes from "../routes/settingsRoute.js"; +import MaintenanceWindowRoutes from "../routes/maintenanceWindowRoute.js"; +import StatusPageRoutes from "../routes/statusPageRoute.js"; +import QueueRoutes from "../routes/queueRoute.js"; +import LogRoutes from "../routes/logRoutes.js"; +import DiagnosticRoutes from "../routes/diagnosticRoute.js"; +import NotificationRoutes from "../routes/notificationRoute.js"; + +export const setupRoutes = (app, controllers) => { + const authRoutes = new AuthRoutes(controllers.authController); + const monitorRoutes = new MonitorRoutes(controllers.monitorController); + const settingsRoutes = new SettingsRoutes(controllers.settingsController); + const checkRoutes = new CheckRoutes(controllers.checkController); + const inviteRoutes = new InviteRoutes(controllers.inviteController); + const maintenanceWindowRoutes = new MaintenanceWindowRoutes(controllers.maintenanceWindowController); + const queueRoutes = new QueueRoutes(controllers.queueController); + const logRoutes = new LogRoutes(controllers.logController); + const statusPageRoutes = new StatusPageRoutes(controllers.statusPageController); + const notificationRoutes = new NotificationRoutes(controllers.notificationController); + const diagnosticRoutes = new DiagnosticRoutes(controllers.diagnosticController); + + app.use("/api/v1/auth", authRoutes.getRouter()); + app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter()); + app.use("/api/v1/settings", verifyJWT, settingsRoutes.getRouter()); + app.use("/api/v1/checks", verifyJWT, checkRoutes.getRouter()); + app.use("/api/v1/invite", inviteRoutes.getRouter()); + app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRoutes.getRouter()); + app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter()); + app.use("/api/v1/logs", verifyJWT, logRoutes.getRouter()); + app.use("/api/v1/status-page", statusPageRoutes.getRouter()); + app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); + app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter()); +}; diff --git a/server/config/services.js b/server/config/services.js new file mode 100644 index 000000000..490c252b9 --- /dev/null +++ b/server/config/services.js @@ -0,0 +1,152 @@ +import ServiceRegistry from "../service/system/serviceRegistry.js"; +import logger from "../utils/logger.js"; +import TranslationService from "../service/system/translationService.js"; +import StringService from "../service/system/stringService.js"; +import MongoDB from "../db/mongo/MongoDB.js"; +import NetworkService from "../service/infrastructure/networkService.js"; +import EmailService from "../service/infrastructure/emailService.js"; +import BufferService from "../service/infrastructure/bufferService.js"; +import StatusService from "../service/infrastructure/statusService.js"; +import NotificationUtils from "../service/infrastructure/notificationUtils.js"; +import NotificationService from "../service/infrastructure/notificationService.js"; +import RedisService from "../service/data/redisService.js"; +import ErrorService from "../service/infrastructure/errorService.js"; +import SuperSimpleQueueHelper from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import SuperSimpleQueue from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; +import UserService from "../service/business/userService.js"; +import CheckService from "../service/business/checkService.js"; +import DiagnosticService from "../service/business/diagnosticService.js"; +import InviteService from "../service/business/inviteService.js"; +import MaintenanceWindowService from "../service/business/maintenanceWindowService.js"; +import MonitorService from "../service/business/monitorService.js"; +import IORedis from "ioredis"; +import papaparse from "papaparse"; +import axios from "axios"; +import ping from "ping"; +import http from "http"; +import Docker from "dockerode"; +import net from "net"; +import fs from "fs"; +import path from "path"; +import nodemailer from "nodemailer"; +import pkg from "handlebars"; +const { compile } = pkg; +import mjml2html from "mjml"; +import jwt from "jsonwebtoken"; + +export const initializeServices = async (appSettings, settingsService) => { + const translationService = new TranslationService(logger); + await translationService.initialize(); + + const stringService = new StringService(translationService); + + // Create DB + const db = new MongoDB({ appSettings }); + await db.connect(); + + const networkService = new NetworkService(axios, ping, logger, http, Docker, net, stringService, settingsService); + const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); + const bufferService = new BufferService({ db, logger }); + const statusService = new StatusService({ db, logger, buffer: bufferService }); + + const notificationUtils = new NotificationUtils({ + stringService, + emailService, + }); + + const notificationService = new NotificationService({ + emailService, + db, + logger, + networkService, + stringService, + notificationUtils, + }); + + const redisService = new RedisService({ Redis: IORedis, logger }); + const errorService = new ErrorService(); + + const superSimpleQueueHelper = new SuperSimpleQueueHelper({ + db, + logger, + networkService, + statusService, + notificationService, + }); + + const superSimpleQueue = await SuperSimpleQueue.create({ + appSettings, + db, + logger, + helper: superSimpleQueueHelper, + }); + + // Business services + const userService = new UserService({ + db, + emailService, + settingsService, + logger, + stringService, + jwt, + errorService, + }); + const checkService = new CheckService({ + db, + settingsService, + stringService, + errorService, + }); + const diagnosticService = new DiagnosticService(); + const inviteService = new InviteService({ + db, + settingsService, + emailService, + stringService, + errorService, + }); + const maintenanceWindowService = new MaintenanceWindowService({ + db, + settingsService, + stringService, + errorService, + }); + const monitorService = new MonitorService({ + db, + settingsService, + jobQueue: superSimpleQueue, + stringService, + emailService, + papaparse, + logger, + errorService, + }); + + const services = { + settingsService, + translationService, + stringService, + db, + networkService, + emailService, + bufferService, + statusService, + notificationService, + redisService, + jobQueue: superSimpleQueue, + userService, + checkService, + diagnosticService, + inviteService, + maintenanceWindowService, + monitorService, + errorService, + logger, + }; + + Object.values(services).forEach((service) => { + ServiceRegistry.register(service.serviceName, service); + }); + + return services; +}; diff --git a/server/controllers/announcementsController.js b/server/controllers/announcementsController.js index aeb7a3b9d..430859791 100755 --- a/server/controllers/announcementsController.js +++ b/server/controllers/announcementsController.js @@ -1,4 +1,5 @@ import { createAnnouncementValidation } from "../validation/joi.js"; +import BaseController from "./baseController.js"; const SERVICE_NAME = "announcementController"; @@ -10,12 +11,17 @@ const SERVICE_NAME = "announcementController"; */ class AnnouncementController extends BaseController { + static SERVICE_NAME = SERVICE_NAME; constructor(commonDependencies) { super(commonDependencies); this.createAnnouncement = this.createAnnouncement.bind(this); this.getAnnouncement = this.getAnnouncement.bind(this); } + get serviceName() { + return AnnouncementController.SERVICE_NAME; + } + /** * Handles the creation of a new announcement. * diff --git a/server/controllers/authController.js b/server/controllers/authController.js index 00fc2caf5..c186e8423 100755 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -24,6 +24,7 @@ const SERVICE_NAME = "authController"; * @description Manages user authentication and authorization operations */ class AuthController extends BaseController { + static SERVICE_NAME = SERVICE_NAME; /** * Creates an instance of AuthController. * @@ -42,6 +43,10 @@ class AuthController extends BaseController { this.userService = userService; } + get serviceName() { + return AuthController.SERVICE_NAME; + } + /** * Registers a new user in the system. * diff --git a/server/controllers/baseController.js b/server/controllers/baseController.js index a84806a24..910cf57b4 100644 --- a/server/controllers/baseController.js +++ b/server/controllers/baseController.js @@ -1,11 +1,11 @@ import { AppError } from "../service/infrastructure/errorService.js"; -export const createCommonDependencies = (serviceRegistry, dbServiceName, loggerServiceName, errorServiceName, stringServiceName) => { +export const createCommonDependencies = (db, errorService, logger, stringService) => { return { - db: serviceRegistry.get(dbServiceName), - errorService: serviceRegistry.get(errorServiceName), - logger: serviceRegistry.get(loggerServiceName), - stringService: serviceRegistry.get(stringServiceName), + db, + errorService, + logger, + stringService, }; }; diff --git a/server/controllers/checkController.js b/server/controllers/checkController.js index 08b6c2de9..4069df3ff 100755 --- a/server/controllers/checkController.js +++ b/server/controllers/checkController.js @@ -23,6 +23,7 @@ const SERVICE_NAME = "checkController"; * @description Manages check operations and monitoring data */ class CheckController extends BaseController { + static SERVICE_NAME = SERVICE_NAME; /** * Creates an instance of CheckController. * @@ -37,6 +38,10 @@ class CheckController extends BaseController { this.checkService = checkService; } + get serviceName() { + return CheckController.SERVICE_NAME; + } + /** * Retrieves checks for a specific monitor with filtering and pagination. * diff --git a/server/controllers/diagnosticController.js b/server/controllers/diagnosticController.js index 91b90f96b..0723a9f6c 100755 --- a/server/controllers/diagnosticController.js +++ b/server/controllers/diagnosticController.js @@ -10,6 +10,7 @@ import BaseController from "./baseController.js"; * @description Manages system diagnostics and performance monitoring */ class DiagnosticController extends BaseController { + static SERVICE_NAME = SERVICE_NAME; /** * Creates an instance of DiagnosticController. * @param {Object} commonDependencies - Common dependencies injected into the controller @@ -21,6 +22,9 @@ class DiagnosticController extends BaseController { this.diagnosticService = diagnosticService; } + get serviceName() { + return DiagnosticController.SERVICE_NAME; + } /** * Retrieves comprehensive system statistics and performance metrics. * diff --git a/server/controllers/inviteController.js b/server/controllers/inviteController.js index 3f237c6ec..a377234c0 100755 --- a/server/controllers/inviteController.js +++ b/server/controllers/inviteController.js @@ -7,6 +7,7 @@ const SERVICE_NAME = "inviteController"; * Manages invite token generation, email sending, and token verification */ class InviteController extends BaseController { + static SERVICE_NAME = SERVICE_NAME; /** * Creates a new InviteController instance * @param {Object} commonDependencies - Common dependencies injected into the controller @@ -17,6 +18,10 @@ class InviteController extends BaseController { this.inviteService = inviteService; } + get serviceName() { + return InviteController.SERVICE_NAME; + } + /** * Generates an invite token for a user invitation * @param {Object} req - Express request object diff --git a/server/controllers/logController.js b/server/controllers/logController.js index aa2780eb6..3537246bb 100644 --- a/server/controllers/logController.js +++ b/server/controllers/logController.js @@ -2,10 +2,15 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "LogController"; class LogController extends BaseController { + static SERVICE_NAME = SERVICE_NAME; constructor(commonDependencies) { super(commonDependencies); } + get serviceName() { + return LogController.SERVICE_NAME; + } + getLogs = this.asyncHandler( async (req, res) => { const logs = await this.logger.getLogs(); diff --git a/server/controllers/maintenanceWindowController.js b/server/controllers/maintenanceWindowController.js index e54726ea3..2fdb1ee15 100755 --- a/server/controllers/maintenanceWindowController.js +++ b/server/controllers/maintenanceWindowController.js @@ -12,12 +12,17 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "maintenanceWindowController"; class MaintenanceWindowController extends BaseController { + static SERVICE_NAME = SERVICE_NAME; constructor(commonDependencies, { settingsService, maintenanceWindowService }) { super(commonDependencies); this.settingsService = settingsService; this.maintenanceWindowService = maintenanceWindowService; } + get serviceName() { + return MaintenanceWindowController.SERVICE_NAME; + } + createMaintenanceWindows = this.asyncHandler( async (req, res) => { await createMaintenanceWindowBodyValidation.validateAsync(req.body); diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index 2187df5d0..13890e5ea 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -18,6 +18,7 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "monitorController"; class MonitorController extends BaseController { + static SERVICE_NAME = SERVICE_NAME; constructor(commonDependencies, { settingsService, jobQueue, emailService, monitorService }) { super(commonDependencies); this.settingsService = settingsService; @@ -26,6 +27,10 @@ class MonitorController extends BaseController { this.monitorService = monitorService; } + get serviceName() { + return MonitorController.SERVICE_NAME; + } + async verifyTeamAccess(teamId, monitorId) { const monitor = await this.db.getMonitorById(monitorId); if (!monitor.teamId.equals(teamId)) { diff --git a/server/controllers/notificationController.js b/server/controllers/notificationController.js index 6e85b97b8..081968e3d 100755 --- a/server/controllers/notificationController.js +++ b/server/controllers/notificationController.js @@ -4,12 +4,17 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "NotificationController"; class NotificationController extends BaseController { + static SERVICE_NAME = SERVICE_NAME; constructor(commonDependencies, { notificationService, statusService }) { super(commonDependencies); this.notificationService = notificationService; this.statusService = statusService; } + get serviceName() { + return NotificationController.SERVICE_NAME; + } + testNotification = this.asyncHandler( async (req, res) => { const notification = req.body; diff --git a/server/controllers/queueController.js b/server/controllers/queueController.js index 757350652..d2c3fd06f 100755 --- a/server/controllers/queueController.js +++ b/server/controllers/queueController.js @@ -2,11 +2,16 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "JobQueueController"; class JobQueueController extends BaseController { + static SERVICE_NAME = SERVICE_NAME; constructor(commonDependencies, { jobQueue }) { super(commonDependencies); this.jobQueue = jobQueue; } + get serviceName() { + return JobQueueController.SERVICE_NAME; + } + getMetrics = this.asyncHandler( async (req, res) => { const metrics = await this.jobQueue.getMetrics(); diff --git a/server/controllers/settingsController.js b/server/controllers/settingsController.js index d97c19e88..c66024025 100755 --- a/server/controllers/settingsController.js +++ b/server/controllers/settingsController.js @@ -5,12 +5,17 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "SettingsController"; class SettingsController extends BaseController { + static SERVICE_NAME = SERVICE_NAME; constructor(commonDependencies, { settingsService, emailService }) { super(commonDependencies); this.settingsService = settingsService; this.emailService = emailService; } + get serviceName() { + return SettingsController.SERVICE_NAME; + } + buildAppSettings = (dbSettings) => { const sanitizedSettings = { ...dbSettings }; delete sanitizedSettings.version; diff --git a/server/controllers/statusPageController.js b/server/controllers/statusPageController.js index 09d1d8b5f..85211c7ff 100755 --- a/server/controllers/statusPageController.js +++ b/server/controllers/statusPageController.js @@ -4,10 +4,15 @@ import BaseController from "./baseController.js"; const SERVICE_NAME = "statusPageController"; class StatusPageController extends BaseController { + static SERVICE_NAME = SERVICE_NAME; constructor(commonDependencies) { super(commonDependencies); } + get serviceName() { + return StatusPageController.SERVICE_NAME; + } + createStatusPage = this.asyncHandler( async (req, res) => { await createStatusPageBodyValidation.validateAsync(req.body); diff --git a/server/db/mongo/MongoDB.js b/server/db/mongo/MongoDB.js index 6eb82384f..e063214fd 100755 --- a/server/db/mongo/MongoDB.js +++ b/server/db/mongo/MongoDB.js @@ -1,5 +1,4 @@ import mongoose from "mongoose"; -import UserModel from "../models/User.js"; import AppSettings from "../models/AppSettings.js"; import logger from "../../utils/logger.js"; @@ -87,6 +86,10 @@ class MongoDB { Object.assign(this, diagnosticModule); } + get serviceName() { + return MongoDB.SERVICE_NAME; + } + connect = async () => { try { const connectionString = this.appSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db"; diff --git a/server/index.js b/server/index.js index b6f1dd3fe..607847e8b 100755 --- a/server/index.js +++ b/server/index.js @@ -1,450 +1,47 @@ +import { initializeServices } from "./config/services.js"; +import { initializeControllers } from "./config/controllers.js"; +import { createApp } from "./app.js"; +import { initShutdownListener } from "./shutdown.js"; +import logger from "./utils/logger.js"; +import { fileURLToPath } from "url"; import path from "path"; import fs from "fs"; -import swaggerUi from "swagger-ui-express"; -import jwt from "jsonwebtoken"; -import papaparse from "papaparse"; -import express from "express"; -import helmet from "helmet"; -import cors from "cors"; -import compression from "compression"; -import { Logger } from "./utils/logger.js"; -import logger from "./utils/logger.js"; -import { verifyJWT } from "./middleware/verifyJWT.js"; -import { handleErrors } from "./middleware/handleErrors.js"; -import { responseHandler } from "./middleware/responseHandler.js"; -import { fileURLToPath } from "url"; -import { createCommonDependencies } from "./controllers/baseController.js"; -import AuthRoutes from "./routes/authRoute.js"; -import AuthController from "./controllers/authController.js"; - -import InviteRoutes from "./routes/inviteRoute.js"; -import InviteController from "./controllers/inviteController.js"; - -import MonitorRoutes from "./routes/monitorRoute.js"; -import MonitorController from "./controllers/monitorController.js"; - -import CheckRoutes from "./routes/checkRoute.js"; -import CheckController from "./controllers/checkController.js"; - -import MaintenanceWindowRoutes from "./routes/maintenanceWindowRoute.js"; -import MaintenanceWindowController from "./controllers/maintenanceWindowController.js"; - -import SettingsRoutes from "./routes/settingsRoute.js"; -import SettingsController from "./controllers/settingsController.js"; - -import StatusPageRoutes from "./routes/statusPageRoute.js"; -import StatusPageController from "./controllers/statusPageController.js"; - -import QueueRoutes from "./routes/queueRoute.js"; -import QueueController from "./controllers/queueController.js"; - -import LogRoutes from "./routes/logRoutes.js"; -import LogController from "./controllers/logController.js"; - -import NotificationRoutes from "./routes/notificationRoute.js"; -import NotificationController from "./controllers/notificationController.js"; - -import DiagnosticRoutes from "./routes/diagnosticRoute.js"; -import DiagnosticController from "./controllers/diagnosticController.js"; - -//JobQueue service and dependencies -import JobQueue from "./service/infrastructure/JobQueue/JobQueue.js"; -import JobQueueHelper from "./service/infrastructure/JobQueue/JobQueueHelper.js"; -import { Queue, Worker } from "bullmq"; - -import PulseQueue from "./service/infrastructure/PulseQueue/PulseQueue.js"; -import PulseQueueHelper from "./service/infrastructure/PulseQueue/PulseQueueHelper.js"; - -import SuperSimpleQueue from "./service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; -import SuperSimpleQueueHelper from "./service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; - -import ErrorService from "./service/infrastructure/errorService.js"; - -// Business services -import UserService from "./service/business/userService.js"; -import CheckService from "./service/business/checkService.js"; -import DiagnosticService from "./service/business/diagnosticService.js"; -import InviteService from "./service/business/inviteService.js"; -import MaintenanceWindowService from "./service/business/maintenanceWindowService.js"; -import MonitorService from "./service/business/monitorService.js"; - -//Network service and dependencies -import NetworkService from "./service/infrastructure/networkService.js"; -import axios from "axios"; -import ping from "ping"; -import http from "http"; -import Docker from "dockerode"; -import net from "net"; -// Email service and dependencies -import EmailService from "./service/infrastructure/emailService.js"; -import nodemailer from "nodemailer"; -import pkg from "handlebars"; -const { compile } = pkg; -import mjml2html from "mjml"; - -// Settings Service and dependencies import SettingsService from "./service/system/settingsService.js"; import AppSettings from "./db/models/AppSettings.js"; -// Status Service and dependencies -import StatusService from "./service/infrastructure/statusService.js"; - -// Notification Service and dependencies -import NotificationService from "./service/infrastructure/notificationService.js"; -import NotificationUtils from "./service/infrastructure/notificationUtils.js"; - -// Buffer Service and dependencies -import BufferService from "./service/infrastructure/bufferService.js"; - -// Service Registry -import ServiceRegistry from "./service/system/serviceRegistry.js"; - -import MongoDB from "./db/mongo/MongoDB.js"; - -// Redis Service and dependencies -import IORedis from "ioredis"; -import RedisService from "./service/data/redisService.js"; - -import TranslationService from "./service/system/translationService.js"; -import languageMiddleware from "./middleware/languageMiddleware.js"; -import StringService from "./service/system/stringService.js"; - const SERVICE_NAME = "Server"; -const SHUTDOWN_TIMEOUT = 1000; -let isShuttingDown = false; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "openapi.json"), "utf8")); -const frontendPath = path.join(__dirname, "public"); - -let server; - -const shutdown = async () => { - if (isShuttingDown) { - return; - } - isShuttingDown = true; - logger.info({ message: "Attempting graceful shutdown" }); - setTimeout(async () => { - logger.error({ - message: "Could not shut down in time, forcing shutdown", - service: SERVICE_NAME, - method: "shutdown", - }); - await ServiceRegistry.get(RedisService.SERVICE_NAME).flushRedis(); - process.exit(1); - }, SHUTDOWN_TIMEOUT); - try { - server.close(); - await ServiceRegistry.get(JobQueue.SERVICE_NAME).shutdown(); - await ServiceRegistry.get(MongoDB.SERVICE_NAME).disconnect(); - logger.info({ message: "Graceful shutdown complete" }); - process.exit(0); - } catch (error) { - logger.error({ - message: error.message, - service: SERVICE_NAME, - method: "shutdown", - stack: error.stack, - }); - } -}; -// Need to wrap server setup in a function to handle async nature of JobQueue const startApp = async () => { - const app = express(); - // Create and Register Primary services - const translationService = new TranslationService(logger); - const stringService = new StringService(translationService); - ServiceRegistry.register(StringService.SERVICE_NAME, stringService); - + // FE path + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "openapi.json"), "utf8")); + const frontendPath = path.join(__dirname, "public"); // Create services const settingsService = new SettingsService(AppSettings); const appSettings = settingsService.loadSettings(); - // Create DB - const db = new MongoDB({ appSettings }); - await db.connect(); + // Initialize services + const services = await initializeServices(appSettings, settingsService); - // Set allowed origin - const allowedOrigin = appSettings.clientHost; + // Initialize controllers + const controllers = initializeControllers(services); - const networkService = new NetworkService(axios, ping, logger, http, Docker, net, stringService, settingsService); - const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); - const bufferService = new BufferService({ db, logger }); - const statusService = new StatusService({ db, logger, buffer: bufferService }); - const notificationUtils = new NotificationUtils({ - stringService, - emailService, - }); - - const notificationService = new NotificationService({ - emailService, - db, - logger, - networkService, - stringService, - notificationUtils, - }); - - const redisService = new RedisService({ Redis: IORedis, logger }); - const errorService = new ErrorService(); - - // const jobQueueHelper = new JobQueueHelper({ - // redisService, - // Queue, - // Worker, - // logger, - // db, - // networkService, - // statusService, - // notificationService, - // }); - // const jobQueue = await JobQueue.create({ - // db, - // jobQueueHelper, - // logger, - // stringService, - // }); - - // const pulseQueueHelper = new PulseQueueHelper({ - // db, - // logger, - // networkService, - // statusService, - // notificationService, - // }); - // const pulseQueue = await PulseQueue.create({ - // appSettings, - // db, - // pulseQueueHelper, - // logger, - // }); - const superSimpleQueueHelper = new SuperSimpleQueueHelper({ - db, - logger, - networkService, - statusService, - notificationService, - }); - - const superSimpleQueue = await SuperSimpleQueue.create({ + const app = createApp({ + services, + controllers, appSettings, - db, - logger, - helper: superSimpleQueueHelper, + frontendPath, + openApiSpec, }); - // Business services - const userService = new UserService({ - db, - emailService, - settingsService, - logger, - stringService, - jwt, - errorService, - }); - const checkService = new CheckService({ - db, - settingsService, - stringService, - errorService, - }); - const diagnosticService = new DiagnosticService(); - const inviteService = new InviteService({ - db, - settingsService, - emailService, - stringService, - errorService, - }); - const maintenanceWindowService = new MaintenanceWindowService({ - db, - settingsService, - stringService, - errorService, - }); - const monitorService = new MonitorService({ - db, - settingsService, - jobQueue: superSimpleQueue, - stringService, - emailService, - papaparse, - logger, - errorService, - }); - // Register services - // ServiceRegistry.register(JobQueue.SERVICE_NAME, jobQueue); - // ServiceRegistry.register(JobQueue.SERVICE_NAME, pulseQueue); - ServiceRegistry.register(Logger.SERVICE_NAME, logger); - ServiceRegistry.register(JobQueue.SERVICE_NAME, superSimpleQueue); - ServiceRegistry.register(ErrorService.SERVICE_NAME, errorService); - ServiceRegistry.register(MongoDB.SERVICE_NAME, db); - ServiceRegistry.register(SettingsService.SERVICE_NAME, settingsService); - ServiceRegistry.register(EmailService.SERVICE_NAME, emailService); - ServiceRegistry.register(NetworkService.SERVICE_NAME, networkService); - ServiceRegistry.register(BufferService.SERVICE_NAME, bufferService); - ServiceRegistry.register(StatusService.SERVICE_NAME, statusService); - ServiceRegistry.register(NotificationService.SERVICE_NAME, notificationService); - ServiceRegistry.register(TranslationService.SERVICE_NAME, translationService); - ServiceRegistry.register(RedisService.SERVICE_NAME, redisService); - ServiceRegistry.register(UserService.SERVICE_NAME, userService); - ServiceRegistry.register(CheckService.SERVICE_NAME, checkService); - ServiceRegistry.register(InviteService.SERVICE_NAME, inviteService); - ServiceRegistry.register(MaintenanceWindowService.SERVICE_NAME, maintenanceWindowService); - ServiceRegistry.register(MonitorService.SERVICE_NAME, monitorService); - ServiceRegistry.register(DiagnosticService.SERVICE_NAME, diagnosticService); - - await translationService.initialize(); - const port = appSettings.port || 52345; - server = app.listen(port, () => { + const server = app.listen(port, () => { logger.info({ message: `Server started on port:${port}` }); }); - process.on("SIGUSR2", shutdown); - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - - //Create controllers - - const commonDependencies = createCommonDependencies( - ServiceRegistry, - MongoDB.SERVICE_NAME, - Logger.SERVICE_NAME, - ErrorService.SERVICE_NAME, - StringService.SERVICE_NAME - ); - - const authController = new AuthController(commonDependencies, { - settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), - emailService: ServiceRegistry.get(EmailService.SERVICE_NAME), - jobQueue: ServiceRegistry.get(JobQueue.SERVICE_NAME), - userService: ServiceRegistry.get(UserService.SERVICE_NAME), - }); - - const monitorController = new MonitorController(commonDependencies, { - settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), - jobQueue: ServiceRegistry.get(JobQueue.SERVICE_NAME), - emailService: ServiceRegistry.get(EmailService.SERVICE_NAME), - monitorService: ServiceRegistry.get(MonitorService.SERVICE_NAME), - }); - - const settingsController = new SettingsController(commonDependencies, { - settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), - emailService: ServiceRegistry.get(EmailService.SERVICE_NAME), - }); - - const checkController = new CheckController(commonDependencies, { - settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), - checkService: ServiceRegistry.get(CheckService.SERVICE_NAME), - }); - - const inviteController = new InviteController(commonDependencies, { - inviteService: ServiceRegistry.get(InviteService.SERVICE_NAME), - }); - - const maintenanceWindowController = new MaintenanceWindowController(commonDependencies, { - settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), - maintenanceWindowService: ServiceRegistry.get(MaintenanceWindowService.SERVICE_NAME), - }); - - const queueController = new QueueController(commonDependencies, { - jobQueue: ServiceRegistry.get(JobQueue.SERVICE_NAME), - }); - - const logController = new LogController(commonDependencies); - - const statusPageController = new StatusPageController(commonDependencies); - - const notificationController = new NotificationController(commonDependencies, { - notificationService: ServiceRegistry.get(NotificationService.SERVICE_NAME), - statusService: ServiceRegistry.get(StatusService.SERVICE_NAME), - }); - - const diagnosticController = new DiagnosticController(commonDependencies, { - diagnosticService: ServiceRegistry.get(DiagnosticService.SERVICE_NAME), - }); - - //Create routes - const authRoutes = new AuthRoutes(authController); - const monitorRoutes = new MonitorRoutes(monitorController); - const settingsRoutes = new SettingsRoutes(settingsController); - const checkRoutes = new CheckRoutes(checkController); - const inviteRoutes = new InviteRoutes(inviteController); - const maintenanceWindowRoutes = new MaintenanceWindowRoutes(maintenanceWindowController); - const queueRoutes = new QueueRoutes(queueController); - const logRoutes = new LogRoutes(logController); - const statusPageRoutes = new StatusPageRoutes(statusPageController); - const notificationRoutes = new NotificationRoutes(notificationController); - const diagnosticRoutes = new DiagnosticRoutes(diagnosticController); - - // Middleware - app.use(express.static(frontendPath)); - app.use(responseHandler); - app.use( - cors({ - origin: allowedOrigin, - methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", - allowedHeaders: "*", - credentials: true, - }) - ); - app.use(express.json()); - app.use( - helmet({ - hsts: false, - contentSecurityPolicy: { - useDefaults: true, - directives: { - upgradeInsecureRequests: null, - }, - }, - }) - ); - app.use( - compression({ - level: 6, - threshold: 1024, - filter: (req, res) => { - if (req.headers["x-no-compression"]) { - return false; - } - return compression.filter(req, res); - }, - }) - ); - - app.use(languageMiddleware(stringService, translationService, settingsService)); - // Swagger UI - app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openApiSpec)); - - //routes - app.use("/api/v1/auth", authRoutes.getRouter()); - app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter()); - app.use("/api/v1/settings", verifyJWT, settingsRoutes.getRouter()); - app.use("/api/v1/checks", verifyJWT, checkRoutes.getRouter()); - app.use("/api/v1/invite", inviteRoutes.getRouter()); - app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRoutes.getRouter()); - app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter()); - app.use("/api/v1/logs", verifyJWT, logRoutes.getRouter()); - app.use("/api/v1/status-page", statusPageRoutes.getRouter()); - app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); - app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter()); - - app.use("/api/v1/health", (req, res) => { - res.json({ - status: "OK", - }); - }); - // FE routes - app.get("*", (req, res) => { - res.sendFile(path.join(frontendPath, "index.html")); - }); - app.use(handleErrors); + initShutdownListener(server, services); }; startApp().catch((error) => { diff --git a/server/server.js b/server/server.js new file mode 100644 index 000000000..13a01fae6 --- /dev/null +++ b/server/server.js @@ -0,0 +1 @@ +export const startServer = () => {}; diff --git a/server/service/business/checkService.js b/server/service/business/checkService.js index 3d8206644..262d8e863 100644 --- a/server/service/business/checkService.js +++ b/server/service/business/checkService.js @@ -10,6 +10,10 @@ class CheckService { this.errorService = errorService; } + get serviceName() { + return CheckService.SERVICE_NAME; + } + getChecksByMonitor = async ({ monitorId, query, teamId }) => { if (!monitorId) { throw this.errorService.createBadRequestError("No monitor ID in request"); diff --git a/server/service/business/diagnosticService.js b/server/service/business/diagnosticService.js index 5c37cba14..353604932 100644 --- a/server/service/business/diagnosticService.js +++ b/server/service/business/diagnosticService.js @@ -5,6 +5,7 @@ const SERVICE_NAME = "diagnosticService"; class DiagnosticService { static SERVICE_NAME = SERVICE_NAME; + constructor() { /** * Performance Observer for monitoring system performance metrics. @@ -18,6 +19,10 @@ class DiagnosticService { obs.observe({ entryTypes: ["measure"] }); } + get serviceName() { + return DiagnosticService.SERVICE_NAME; + } + getCPUUsage = async () => { const startUsage = process.cpuUsage(); const timingPeriod = 1000; // measured in ms diff --git a/server/service/business/inviteService.js b/server/service/business/inviteService.js index d23d725e4..161045971 100644 --- a/server/service/business/inviteService.js +++ b/server/service/business/inviteService.js @@ -11,6 +11,10 @@ class InviteService { this.errorService = errorService; } + get serviceName() { + return InviteService.SERVICE_NAME; + } + getInviteToken = async ({ invite, teamId }) => { invite.teamId = teamId; const inviteToken = await this.db.requestInviteToken(invite); diff --git a/server/service/business/maintenanceWindowService.js b/server/service/business/maintenanceWindowService.js index 0f58aca85..ec0fa5345 100644 --- a/server/service/business/maintenanceWindowService.js +++ b/server/service/business/maintenanceWindowService.js @@ -2,6 +2,7 @@ const SERVICE_NAME = "maintenanceWindowService"; class MaintenanceWindowService { static SERVICE_NAME = SERVICE_NAME; + constructor({ db, settingsService, stringService, errorService }) { this.db = db; this.settingsService = settingsService; @@ -9,6 +10,10 @@ class MaintenanceWindowService { this.errorService = errorService; } + get serviceName() { + return MaintenanceWindowService.SERVICE_NAME; + } + createMaintenanceWindow = async ({ teamId, body }) => { const monitorIds = body.monitors; const monitors = await this.db.getMonitorsByIds(monitorIds); diff --git a/server/service/business/monitorService.js b/server/service/business/monitorService.js index 2a2b27aa3..d307e1837 100644 --- a/server/service/business/monitorService.js +++ b/server/service/business/monitorService.js @@ -2,7 +2,7 @@ import { createMonitorsBodyValidation } from "../../validation/joi.js"; const SERVICE_NAME = "MonitorService"; class MonitorService { - SERVICE_NAME = SERVICE_NAME; + static SERVICE_NAME = SERVICE_NAME; constructor({ db, settingsService, jobQueue, stringService, emailService, papaparse, logger, errorService }) { this.db = db; @@ -15,6 +15,10 @@ class MonitorService { this.errorService = errorService; } + get serviceName() { + return MonitorService.SERVICE_NAME; + } + verifyTeamAccess = async ({ teamId, monitorId }) => { const monitor = await this.db.getMonitorById(monitorId); if (!monitor?.teamId?.equals(teamId)) { diff --git a/server/service/business/userService.js b/server/service/business/userService.js index 12dd4207f..e4296a9c7 100644 --- a/server/service/business/userService.js +++ b/server/service/business/userService.js @@ -2,6 +2,7 @@ const SERVICE_NAME = "userService"; class UserService { static SERVICE_NAME = SERVICE_NAME; + constructor({ db, emailService, settingsService, logger, stringService, jwt, errorService }) { this.db = db; this.emailService = emailService; @@ -12,6 +13,10 @@ class UserService { this.errorService = errorService; } + get serviceName() { + return UserService.SERVICE_NAME; + } + issueToken = (payload, appSettings) => { const tokenTTL = appSettings?.jwtTTL ?? "2h"; const tokenSecret = appSettings?.jwtSecret; diff --git a/server/service/data/redisService.js b/server/service/data/redisService.js index a89c21fab..3bcfe73b8 100644 --- a/server/service/data/redisService.js +++ b/server/service/data/redisService.js @@ -2,12 +2,17 @@ const SERVICE_NAME = "RedisService"; class RedisService { static SERVICE_NAME = SERVICE_NAME; + constructor({ Redis, logger }) { this.Redis = Redis; this.connections = new Set(); this.logger = logger; } + get serviceName() { + return RedisService.SERVICE_NAME; + } + getNewConnection(options = {}) { const connection = new this.Redis(process.env.REDIS_URL, { retryStrategy: (times) => { diff --git a/server/service/infrastructure/JobQueue/JobQueue.js b/server/service/infrastructure/JobQueue/JobQueue.js index 6e316c616..88f0a3cac 100644 --- a/server/service/infrastructure/JobQueue/JobQueue.js +++ b/server/service/infrastructure/JobQueue/JobQueue.js @@ -23,6 +23,10 @@ class JobQueue { this.workers = []; } + get serviceName() { + return JobQueue.SERVICE_NAME; + } + static async create({ db, jobQueueHelper, logger, stringService }) { const instance = new JobQueue({ db, jobQueueHelper, logger, stringService }); await instance.init(); diff --git a/server/service/infrastructure/JobQueue/JobQueueHelper.js b/server/service/infrastructure/JobQueue/JobQueueHelper.js index 4107966e8..c746edae1 100644 --- a/server/service/infrastructure/JobQueue/JobQueueHelper.js +++ b/server/service/infrastructure/JobQueue/JobQueueHelper.js @@ -1,6 +1,8 @@ const SERVICE_NAME = "JobQueueHelper"; class JobQueueHelper { + static SERVICE_NAME = SERVICE_NAME; + constructor({ redisService, Queue, Worker, logger, db, networkService, statusService, notificationService }) { this.db = db; this.redisService = redisService; @@ -12,6 +14,10 @@ class JobQueueHelper { this.notificationService = notificationService; } + get serviceName() { + return JobQueueHelper.SERVICE_NAME; + } + createQueue(queueName) { const connection = this.redisService.getNewConnection(); const q = new this.Queue(queueName, { diff --git a/server/service/infrastructure/PulseQueue/PulseQueue.js b/server/service/infrastructure/PulseQueue/PulseQueue.js index 85d14ab07..d822b371d 100644 --- a/server/service/infrastructure/PulseQueue/PulseQueue.js +++ b/server/service/infrastructure/PulseQueue/PulseQueue.js @@ -1,6 +1,6 @@ import { Pulse } from "@pulsecron/pulse"; -const SERVICE_NAME = "PulseQueue"; +const SERVICE_NAME = "JobQueue"; class PulseQueue { static SERVICE_NAME = SERVICE_NAME; @@ -11,6 +11,10 @@ class PulseQueue { this.logger = logger; } + get serviceName() { + return PulseQueue.SERVICE_NAME; + } + static async create({ appSettings, db, pulseQueueHelper, logger }) { const instance = new PulseQueue({ appSettings, db, pulseQueueHelper, logger }); await instance.init(); diff --git a/server/service/infrastructure/PulseQueue/PulseQueueHelper.js b/server/service/infrastructure/PulseQueue/PulseQueueHelper.js index c3b8992d8..a5ec34d01 100644 --- a/server/service/infrastructure/PulseQueue/PulseQueueHelper.js +++ b/server/service/infrastructure/PulseQueue/PulseQueueHelper.js @@ -1,6 +1,8 @@ const SERVICE_NAME = "PulseQueueHelper"; class PulseQueueHelper { + static SERVICE_NAME = SERVICE_NAME; + constructor({ db, logger, networkService, statusService, notificationService }) { this.db = db; this.logger = logger; @@ -9,6 +11,10 @@ class PulseQueueHelper { this.notificationService = notificationService; } + get serviceName() { + return PulseQueueHelper.SERVICE_NAME; + } + getMonitorJob = () => { return async (job) => { try { diff --git a/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js b/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js index f0b59640e..e9021bf97 100644 --- a/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js +++ b/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js @@ -1,5 +1,5 @@ import Scheduler from "super-simple-scheduler"; -const SERVICE_NAME = "SuperSimpleQueue"; +const SERVICE_NAME = "JobQueue"; class SuperSimpleQueue { static SERVICE_NAME = SERVICE_NAME; @@ -11,6 +11,10 @@ class SuperSimpleQueue { this.helper = helper; } + get serviceName() { + return SuperSimpleQueue.SERVICE_NAME; + } + static async create({ appSettings, db, logger, helper }) { const instance = new SuperSimpleQueue({ appSettings, db, logger, helper }); await instance.init(); diff --git a/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js b/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js index fd41a5179..639e5183b 100644 --- a/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js +++ b/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js @@ -1,6 +1,7 @@ -const SERVICE_NAME = "SuperSimpleQueueHelper"; +const SERVICE_NAME = "JobQueueHelper"; class SuperSimpleQueueHelper { + static SERVICE_NAME = SERVICE_NAME; constructor({ db, logger, networkService, statusService, notificationService }) { this.db = db; this.logger = logger; @@ -9,6 +10,10 @@ class SuperSimpleQueueHelper { this.notificationService = notificationService; } + get serviceName() { + return SuperSimpleQueueHelper.SERVICE_NAME; + } + getMonitorJob = () => { return async (monitor) => { try { diff --git a/server/service/infrastructure/bufferService.js b/server/service/infrastructure/bufferService.js index 84f3efd30..439b251f6 100755 --- a/server/service/infrastructure/bufferService.js +++ b/server/service/infrastructure/bufferService.js @@ -10,6 +10,7 @@ const TYPE_MAP = { }; class BufferService { + static SERVICE_NAME = SERVICE_NAME; constructor({ db, logger }) { this.db = db; this.logger = logger; @@ -32,7 +33,10 @@ class BufferService { method: "constructor", }); } - static SERVICE_NAME = SERVICE_NAME; + + get serviceName() { + return BufferService.SERVICE_NAME; + } addToBuffer({ check, type }) { try { diff --git a/server/service/infrastructure/emailService.js b/server/service/infrastructure/emailService.js index b6b0eb4af..bc040d51a 100755 --- a/server/service/infrastructure/emailService.js +++ b/server/service/infrastructure/emailService.js @@ -33,6 +33,10 @@ class EmailService { this.init(); } + get serviceName() { + return EmailService.SERVICE_NAME; + } + init = async () => { /** * Loads an email template from the filesystem. @@ -42,7 +46,7 @@ class EmailService { */ this.loadTemplate = (templateName) => { try { - const templatePath = this.path.join(__dirname, `../templates/${templateName}.mjml`); + const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`); const templateContent = this.fs.readFileSync(templatePath, "utf8"); return this.compile(templateContent); } catch (error) { diff --git a/server/service/infrastructure/errorService.js b/server/service/infrastructure/errorService.js index 552816b73..680bb3765 100644 --- a/server/service/infrastructure/errorService.js +++ b/server/service/infrastructure/errorService.js @@ -55,9 +55,12 @@ class BadRequestError extends AppError { const SERVICE_NAME = "ErrorService"; class ErrorService { static SERVICE_NAME = SERVICE_NAME; - constructor() {} + get serviceName() { + return ErrorService.SERVICE_NAME; + } + createError = (message, status = 500, service = null, method = null, details = null) => { return new AppError(message, status, service, method, details); }; diff --git a/server/service/infrastructure/networkService.js b/server/service/infrastructure/networkService.js index 70d7cc932..6a2e3c8a7 100755 --- a/server/service/infrastructure/networkService.js +++ b/server/service/infrastructure/networkService.js @@ -36,6 +36,10 @@ class NetworkService { this.settingsService = settingsService; } + get serviceName() { + return NetworkService.SERVICE_NAME; + } + /** * Times the execution of an asynchronous operation. * diff --git a/server/service/infrastructure/notificationService.js b/server/service/infrastructure/notificationService.js index f253cf760..6af4fd20a 100644 --- a/server/service/infrastructure/notificationService.js +++ b/server/service/infrastructure/notificationService.js @@ -12,6 +12,10 @@ class NotificationService { this.notificationUtils = notificationUtils; } + get serviceName() { + return NotificationService.SERVICE_NAME; + } + sendNotification = async ({ notification, subject, content, html }) => { const { type, address } = notification; diff --git a/server/service/infrastructure/notificationUtils.js b/server/service/infrastructure/notificationUtils.js index d070a9995..22c7f4df7 100644 --- a/server/service/infrastructure/notificationUtils.js +++ b/server/service/infrastructure/notificationUtils.js @@ -1,9 +1,17 @@ +const SERVICE_NAME = "NotificationUtils"; + class NotificationUtils { + static SERVICE_NAME = SERVICE_NAME; + constructor({ stringService, emailService }) { this.stringService = stringService; this.emailService = emailService; } + get serviceName() { + return NotificationUtils.SERVICE_NAME; + } + buildTestEmail = async () => { const context = { testName: "Monitoring System" }; const html = await this.emailService.buildEmail("testEmailTemplate", context); diff --git a/server/service/infrastructure/statusService.js b/server/service/infrastructure/statusService.js index 1c91a407c..3e2330380 100755 --- a/server/service/infrastructure/statusService.js +++ b/server/service/infrastructure/statusService.js @@ -4,6 +4,7 @@ const SERVICE_NAME = "StatusService"; class StatusService { static SERVICE_NAME = SERVICE_NAME; + /** * Creates an instance of StatusService. * @@ -14,7 +15,10 @@ class StatusService { this.db = db; this.logger = logger; this.buffer = buffer; - this.SERVICE_NAME = SERVICE_NAME; + } + + get serviceName() { + return StatusService.SERVICE_NAME; } async updateRunningStats({ monitor, networkResponse }) { diff --git a/server/service/system/serviceRegistry.js b/server/service/system/serviceRegistry.js index 5f8b3bfe6..6aa3f94ef 100755 --- a/server/service/system/serviceRegistry.js +++ b/server/service/system/serviceRegistry.js @@ -6,6 +6,10 @@ class ServiceRegistry { this.services = {}; } + get serviceName() { + return ServiceRegistry.SERVICE_NAME; + } + register(name, service) { logger.info({ message: `Registering service ${name}`, diff --git a/server/service/system/settingsService.js b/server/service/system/settingsService.js index d2c1a17ae..4f5e18abb 100755 --- a/server/service/system/settingsService.js +++ b/server/service/system/settingsService.js @@ -1,4 +1,5 @@ const SERVICE_NAME = "SettingsService"; + const envConfig = { nodeEnv: process.env.NODE_ENV, logLevel: process.env.LOG_LEVEL, @@ -23,7 +24,8 @@ const envConfig = { * This service is responsible for loading and managing the application settings. */ class SettingsService { - static SERVICE_NAME = SERVICE_NAME; + static SERVICE_NAME = "SettingsService"; + /** * Constructs a new SettingsService * @constructor @@ -32,6 +34,11 @@ class SettingsService { this.AppSettings = AppSettings; this.settings = { ...envConfig }; } + + get serviceName() { + return SettingsService.SERVICE_NAME; + } + /** * Load settings from env settings * @returns {Object>} The settings. diff --git a/server/service/system/stringService.js b/server/service/system/stringService.js index b88e73917..5df3410f9 100755 --- a/server/service/system/stringService.js +++ b/server/service/system/stringService.js @@ -1,5 +1,7 @@ +const SERVICE_NAME = "StringService"; + class StringService { - static SERVICE_NAME = "StringService"; + static SERVICE_NAME = SERVICE_NAME; constructor(translationService) { if (StringService.instance) { @@ -11,6 +13,10 @@ class StringService { StringService.instance = this; } + get serviceName() { + return StringService.SERVICE_NAME; + } + setLanguage(language) { this._language = language; } diff --git a/server/service/system/translationService.js b/server/service/system/translationService.js index 38346f35d..20b3431e0 100755 --- a/server/service/system/translationService.js +++ b/server/service/system/translationService.js @@ -3,7 +3,6 @@ import path from "path"; class TranslationService { static SERVICE_NAME = "TranslationService"; - constructor(logger) { this.logger = logger; this.translations = {}; @@ -11,6 +10,10 @@ class TranslationService { this.localesDir = path.join(process.cwd(), "locales"); } + get serviceName() { + return TranslationService.SERVICE_NAME; + } + setLanguage(language) { this._language = language; } diff --git a/server/service/userService.js b/server/service/userService.js deleted file mode 100644 index 3e1bf285f..000000000 --- a/server/service/userService.js +++ /dev/null @@ -1,208 +0,0 @@ -const SERVICE_NAME = "userService"; -import { createAuthError, createError } from "../utils/errorUtils.js"; - -class UserService { - static SERVICE_NAME = SERVICE_NAME; - constructor({ db, emailService, settingsService, logger, stringService, jwt }) { - this.db = db; - this.emailService = emailService; - this.settingsService = settingsService; - this.logger = logger; - this.stringService = stringService; - this.jwt = jwt; - } - - issueToken = (payload, appSettings) => { - const tokenTTL = appSettings?.jwtTTL ?? "2h"; - const tokenSecret = appSettings?.jwtSecret; - const payloadData = payload; - return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL }); - }; - - registerUser = async (user, file) => { - // Create a new user - // If superAdmin exists, a token should be attached to all further register requests - const superAdminExists = await this.db.checkSuperadmin(); - if (superAdminExists) { - const invitedUser = await this.db.getInviteTokenAndDelete(user.inviteToken); - user.role = invitedUser.role; - user.teamId = invitedUser.teamId; - } else { - // This is the first account, create JWT secret to use if one is not supplied by env - const jwtSecret = crypto.randomBytes(64).toString("hex"); - await this.db.updateAppSettings({ jwtSecret }); - } - - const newUser = await this.db.insertUser({ ...user }, file); - - this.logger.debug({ - message: "New user created", - service: SERVICE_NAME, - method: "registerUser", - details: newUser._id, - }); - - const userForToken = { ...newUser._doc }; - delete userForToken.profileImage; - delete userForToken.avatarImage; - - const appSettings = await this.settingsService.getSettings(); - - const token = this.issueToken(userForToken, appSettings); - - try { - const html = await this.emailService.buildEmail("welcomeEmailTemplate", { - name: newUser.firstName, - }); - this.emailService.sendEmail(newUser.email, "Welcome to Uptime Monitor", html).catch((error) => { - this.logger.warn({ - message: error.message, - service: SERVICE_NAME, - method: "registerUser", - stack: error.stack, - }); - }); - } catch (error) { - this.logger.warn({ - message: error.message, - service: SERVICE_NAME, - method: "registerUser", - stack: error.stack, - }); - } - - return { user: newUser, token }; - }; - - loginUser = async (email, password) => { - // Check if user exists - const user = await this.db.getUserByEmail(email); - // Compare password - const match = await user.comparePassword(password); - if (match !== true) { - throw createAuthError(this.stringService.authIncorrectPassword); - } - - // Remove password from user object. Should this be abstracted to DB layer? - const userWithoutPassword = { ...user._doc }; - delete userWithoutPassword.password; - delete userWithoutPassword.avatarImage; - - // Happy path, return token - const appSettings = await this.settingsService.getSettings(); - const token = this.issueToken(userWithoutPassword, appSettings); - // reset avatar image - userWithoutPassword.avatarImage = user.avatarImage; - return { user: userWithoutPassword, token }; - }; - - editUser = async (updates, file, currentUser) => { - // Change Password check - if (updates?.password && updates?.newPassword) { - // Get user's email - // Add user email to body for DB operation - updates.email = currentUser.email; - // Get user - const user = await this.db.getUserByEmail(currentUser.email); - // Compare passwords - const match = await user.comparePassword(updates?.password); - // If not a match, throw a 403 - // 403 instead of 401 to avoid triggering axios interceptor - if (!match) { - throw createError(this.stringService.authIncorrectPassword, 403); - } - // If a match, update the password - updates.password = updates.newPassword; - } - - const updatedUser = await this.db.updateUser({ userId: currentUser?._id, user: updates, file: file }); - return updatedUser; - }; - - checkSuperadminExists = async () => { - const superAdminExists = await this.db.checkSuperadmin(); - return superAdminExists; - }; - - requestRecovery = async (email) => { - const user = await this.db.getUserByEmail(email); - const recoveryToken = await this.db.requestRecoveryToken(email); - const name = user.firstName; - const { clientHost } = this.settingsService.getSettings(); - const url = `${clientHost}/set-new-password/${recoveryToken.token}`; - - const html = await this.emailService.buildEmail("passwordResetTemplate", { - name, - email, - url, - }); - const msgId = await this.emailService.sendEmail(email, "Checkmate Password Reset", html); - return msgId; - }; - - validateRecovery = async (recoveryToken) => { - await this.db.validateRecoveryToken(recoveryToken); - }; - - resetPassword = async (password, recoveryToken) => { - const user = await this.db.resetPassword(password, recoveryToken); - const appSettings = await this.settingsService.getSettings(); - const token = this.issueToken(user._doc, appSettings); - return { user, token }; - }; - - deleteUser = async (user) => { - const email = user?.email; - if (!email) { - throw new Error("No email in request"); - } - - const teamId = user?.teamId; - const userId = user?._id; - - if (!teamId) { - throw new Error("No team ID in request"); - } - - if (!userId) { - throw new Error("No user ID in request"); - } - - const roles = user?.role; - if (roles.includes("demo")) { - throw new Error("Demo user cannot be deleted"); - } - - // 1. Find all the monitors associated with the team ID if superadmin - const result = await this.db.getMonitorsByTeamId({ - teamId: teamId, - }); - - if (roles.includes("superadmin")) { - // 2. Remove all jobs, delete checks and alerts - result?.monitors.length > 0 && - (await Promise.all( - result.monitors.map(async (monitor) => { - await this.jobQueue.deleteJob(monitor); - }) - )); - } - // 6. Delete the user by id - await this.db.deleteUser(userId); - }; - - getAllUsers = async () => { - const users = await this.db.getAllUsers(); - return users; - }; - - getUserById = async (roles, userId) => { - const user = await this.db.getUserById(roles, userId); - return user; - }; - - editUserById = async (userId, user) => { - await this.db.editUserById(userId, user); - }; -} -export default UserService; diff --git a/server/shutdown.js b/server/shutdown.js new file mode 100644 index 000000000..b9b2b53c5 --- /dev/null +++ b/server/shutdown.js @@ -0,0 +1,34 @@ +import logger from "./utils/logger.js"; + +export const initShutdownListener = (server, services) => { + const SERVICE_NAME = "Server"; + + let isShuttingDown = false; + + const shutdown = async () => { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + logger.info({ message: "Attempting graceful shutdown" }); + + try { + server.close(); + await services.jobQueue.shutdown(); + await services.db.disconnect(); + logger.info({ message: "Graceful shutdown complete" }); + process.exit(0); + } catch (error) { + logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "shutdown", + stack: error.stack, + }); + process.exit(1); + } + }; + process.on("SIGUSR2", shutdown); + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +}; diff --git a/server/utils/logger.js b/server/utils/logger.js index 8cb57f9a7..a94ad6be2 100755 --- a/server/utils/logger.js +++ b/server/utils/logger.js @@ -61,6 +61,11 @@ class Logger { ], }); } + + get serviceName() { + return Logger.SERVICE_NAME; + } + /** * Logs an informational message. * @param {Object} config - The configuration object. From 1c47c8ce2cb91d22178e54bb3fa1c2aaa0cdf8b7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 25 Jul 2025 15:33:48 -0700 Subject: [PATCH 090/226] move to src --- docker/dev/server.Dockerfile | 2 +- docker/dist-arm/server.Dockerfile | 2 +- docker/dist-mono/server.Dockerfile | 2 +- docker/dist/build_images.sh | 4 +- docker/dist/server.Dockerfile | 2 +- docker/prod/server.Dockerfile | 2 +- docker/staging/server.Dockerfile | 2 +- server/index.js | 10 ++-- server/nodemon.json | 4 +- server/package.json | 2 +- server/server.js | 1 - server/{ => src}/app.js | 0 server/{ => src}/config/controllers.js | 0 server/{ => src}/config/database.js | 0 server/{ => src}/config/routes.js | 0 server/{ => src}/config/services.js | 0 .../controllers/announcementsController.js | 0 .../{ => src}/controllers/authController.js | 0 .../{ => src}/controllers/baseController.js | 0 .../{ => src}/controllers/checkController.js | 0 .../{ => src}/controllers/controllerUtils.js | 0 .../controllers/diagnosticController.js | 0 .../{ => src}/controllers/inviteController.js | 0 server/{ => src}/controllers/logController.js | 0 .../maintenanceWindowController.js | 0 .../controllers/monitorController.js | 0 .../controllers/notificationController.js | 0 .../{ => src}/controllers/queueController.js | 0 .../controllers/settingsController.js | 0 .../controllers/statusPageController.js | 0 server/{ => src}/db/models/AppSettings.js | 0 server/{ => src}/db/models/Check.js | 0 server/{ => src}/db/models/HardwareCheck.js | 0 server/{ => src}/db/models/InviteToken.js | 0 .../{ => src}/db/models/MaintenanceWindow.js | 0 server/{ => src}/db/models/Monitor.js | 0 server/{ => src}/db/models/MonitorStats.js | 0 server/{ => src}/db/models/NetworkCheck.js | 0 server/{ => src}/db/models/Notification.js | 0 server/{ => src}/db/models/PageSpeedCheck.js | 2 - server/{ => src}/db/models/RecoveryToken.js | 0 server/{ => src}/db/models/StatusPage.js | 0 server/{ => src}/db/models/Team.js | 0 server/{ => src}/db/models/User.js | 0 server/{ => src}/db/mongo/MongoDB.js | 0 .../{ => src}/db/mongo/modules/checkModule.js | 0 .../db/mongo/modules/checkModuleQueries.js | 0 .../db/mongo/modules/diagnosticModule.js | 0 .../db/mongo/modules/hardwareCheckModule.js | 0 .../db/mongo/modules/inviteModule.js | 0 .../mongo/modules/maintenanceWindowModule.js | 0 .../db/mongo/modules/monitorModule.js | 0 .../db/mongo/modules/monitorModuleQueries.js | 0 .../db/mongo/modules/networkCheckModule.js | 0 .../db/mongo/modules/notificationModule.js | 0 .../db/mongo/modules/pageSpeedCheckModule.js | 0 .../db/mongo/modules/recoveryModule.js | 0 .../db/mongo/modules/settingsModule.js | 0 .../db/mongo/modules/statusPageModule.js | 0 .../{ => src}/db/mongo/modules/userModule.js | 0 server/{ => src}/db/mongo/utils/seedDb.js | 0 server/src/index.js | 55 +++++++++++++++++++ server/{ => src}/locales/en.json | 0 server/{ => src}/locales/en.json.bak | 0 server/{ => src}/locales/tr.json | 0 server/{ => src}/middleware/handleErrors.js | 0 server/{ => src}/middleware/isAllowed.js | 0 .../middleware/languageMiddleware.js | 0 .../{ => src}/middleware/responseHandler.js | 0 server/{ => src}/middleware/verifyJWT.js | 0 .../{ => src}/middleware/verifyOwnership.js | 0 .../{ => src}/middleware/verifySuperAdmin.js | 0 .../{ => src}/middleware/verifyTeamAccess.js | 0 server/{ => src}/routes/announcementsRoute.js | 0 server/{ => src}/routes/authRoute.js | 0 server/{ => src}/routes/checkRoute.js | 0 server/{ => src}/routes/diagnosticRoute.js | 0 server/{ => src}/routes/inviteRoute.js | 0 server/{ => src}/routes/logRoutes.js | 0 .../routes/maintenanceWindowRoute.js | 0 server/{ => src}/routes/monitorRoute.js | 0 server/{ => src}/routes/notificationRoute.js | 0 server/{ => src}/routes/queueRoute.js | 0 server/{ => src}/routes/settingsRoute.js | 0 server/{ => src}/routes/statusPageRoute.js | 0 .../service/business/checkService.js | 0 .../service/business/diagnosticService.js | 0 .../service/business/inviteService.js | 0 .../business/maintenanceWindowService.js | 0 .../service/business/monitorService.js | 0 .../{ => src}/service/business/userService.js | 0 server/{ => src}/service/data/redisService.js | 0 .../infrastructure/JobQueue/JobQueue.js | 0 .../infrastructure/JobQueue/JobQueueHelper.js | 0 .../infrastructure/PulseQueue/PulseQueue.js | 0 .../PulseQueue/PulseQueueHelper.js | 0 .../SuperSimpleQueue/SuperSimpleQueue.js | 0 .../SuperSimpleQueueHelper.js | 0 .../service/infrastructure/bufferService.js | 0 .../service/infrastructure/emailService.js | 0 .../service/infrastructure/errorService.js | 0 .../service/infrastructure/networkService.js | 0 .../infrastructure/notificationService.js | 0 .../infrastructure/notificationUtils.js | 0 .../service/infrastructure/statusService.js | 0 .../service/system/serviceRegistry.js | 0 .../service/system/settingsService.js | 0 .../{ => src}/service/system/stringService.js | 0 .../service/system/translationService.js | 0 server/{ => src}/shutdown.js | 0 server/{ => src}/templates/addReview.mjml | 0 .../templates/employeeActivation.mjml | 0 .../{ => src}/templates/hardwareIncident.mjml | 0 .../templates/noIncidentsThisWeek.mjml | 0 server/{ => src}/templates/passwordReset.mjml | 0 server/{ => src}/templates/serverIsDown.mjml | 0 server/{ => src}/templates/serverIsUp.mjml | 0 .../templates/testEmailTemplate.mjml | 0 server/{ => src}/templates/welcomeEmail.mjml | 0 server/{ => src}/utils/dataUtils.js | 0 server/{ => src}/utils/demoMonitors.json | 0 server/{ => src}/utils/demoMonitorsOld.json | 0 server/{ => src}/utils/imageProcessing.js | 0 server/{ => src}/utils/logger.js | 0 server/{ => src}/utils/roleUtils.js | 0 server/{ => src}/utils/utils.js | 0 server/{ => src}/validation/joi.js | 0 127 files changed, 71 insertions(+), 19 deletions(-) mode change 100755 => 100644 server/index.js delete mode 100644 server/server.js rename server/{ => src}/app.js (100%) rename server/{ => src}/config/controllers.js (100%) rename server/{ => src}/config/database.js (100%) rename server/{ => src}/config/routes.js (100%) rename server/{ => src}/config/services.js (100%) rename server/{ => src}/controllers/announcementsController.js (100%) rename server/{ => src}/controllers/authController.js (100%) rename server/{ => src}/controllers/baseController.js (100%) rename server/{ => src}/controllers/checkController.js (100%) rename server/{ => src}/controllers/controllerUtils.js (100%) rename server/{ => src}/controllers/diagnosticController.js (100%) rename server/{ => src}/controllers/inviteController.js (100%) rename server/{ => src}/controllers/logController.js (100%) rename server/{ => src}/controllers/maintenanceWindowController.js (100%) rename server/{ => src}/controllers/monitorController.js (100%) rename server/{ => src}/controllers/notificationController.js (100%) rename server/{ => src}/controllers/queueController.js (100%) rename server/{ => src}/controllers/settingsController.js (100%) rename server/{ => src}/controllers/statusPageController.js (100%) rename server/{ => src}/db/models/AppSettings.js (100%) rename server/{ => src}/db/models/Check.js (100%) rename server/{ => src}/db/models/HardwareCheck.js (100%) rename server/{ => src}/db/models/InviteToken.js (100%) rename server/{ => src}/db/models/MaintenanceWindow.js (100%) rename server/{ => src}/db/models/Monitor.js (100%) rename server/{ => src}/db/models/MonitorStats.js (100%) rename server/{ => src}/db/models/NetworkCheck.js (100%) rename server/{ => src}/db/models/Notification.js (100%) rename server/{ => src}/db/models/PageSpeedCheck.js (96%) rename server/{ => src}/db/models/RecoveryToken.js (100%) rename server/{ => src}/db/models/StatusPage.js (100%) rename server/{ => src}/db/models/Team.js (100%) rename server/{ => src}/db/models/User.js (100%) rename server/{ => src}/db/mongo/MongoDB.js (100%) rename server/{ => src}/db/mongo/modules/checkModule.js (100%) rename server/{ => src}/db/mongo/modules/checkModuleQueries.js (100%) rename server/{ => src}/db/mongo/modules/diagnosticModule.js (100%) rename server/{ => src}/db/mongo/modules/hardwareCheckModule.js (100%) rename server/{ => src}/db/mongo/modules/inviteModule.js (100%) rename server/{ => src}/db/mongo/modules/maintenanceWindowModule.js (100%) rename server/{ => src}/db/mongo/modules/monitorModule.js (100%) rename server/{ => src}/db/mongo/modules/monitorModuleQueries.js (100%) rename server/{ => src}/db/mongo/modules/networkCheckModule.js (100%) rename server/{ => src}/db/mongo/modules/notificationModule.js (100%) rename server/{ => src}/db/mongo/modules/pageSpeedCheckModule.js (100%) rename server/{ => src}/db/mongo/modules/recoveryModule.js (100%) rename server/{ => src}/db/mongo/modules/settingsModule.js (100%) rename server/{ => src}/db/mongo/modules/statusPageModule.js (100%) rename server/{ => src}/db/mongo/modules/userModule.js (100%) rename server/{ => src}/db/mongo/utils/seedDb.js (100%) create mode 100755 server/src/index.js rename server/{ => src}/locales/en.json (100%) rename server/{ => src}/locales/en.json.bak (100%) rename server/{ => src}/locales/tr.json (100%) rename server/{ => src}/middleware/handleErrors.js (100%) rename server/{ => src}/middleware/isAllowed.js (100%) rename server/{ => src}/middleware/languageMiddleware.js (100%) rename server/{ => src}/middleware/responseHandler.js (100%) rename server/{ => src}/middleware/verifyJWT.js (100%) rename server/{ => src}/middleware/verifyOwnership.js (100%) rename server/{ => src}/middleware/verifySuperAdmin.js (100%) rename server/{ => src}/middleware/verifyTeamAccess.js (100%) rename server/{ => src}/routes/announcementsRoute.js (100%) rename server/{ => src}/routes/authRoute.js (100%) rename server/{ => src}/routes/checkRoute.js (100%) rename server/{ => src}/routes/diagnosticRoute.js (100%) rename server/{ => src}/routes/inviteRoute.js (100%) rename server/{ => src}/routes/logRoutes.js (100%) rename server/{ => src}/routes/maintenanceWindowRoute.js (100%) rename server/{ => src}/routes/monitorRoute.js (100%) rename server/{ => src}/routes/notificationRoute.js (100%) rename server/{ => src}/routes/queueRoute.js (100%) rename server/{ => src}/routes/settingsRoute.js (100%) rename server/{ => src}/routes/statusPageRoute.js (100%) rename server/{ => src}/service/business/checkService.js (100%) rename server/{ => src}/service/business/diagnosticService.js (100%) rename server/{ => src}/service/business/inviteService.js (100%) rename server/{ => src}/service/business/maintenanceWindowService.js (100%) rename server/{ => src}/service/business/monitorService.js (100%) rename server/{ => src}/service/business/userService.js (100%) rename server/{ => src}/service/data/redisService.js (100%) rename server/{ => src}/service/infrastructure/JobQueue/JobQueue.js (100%) rename server/{ => src}/service/infrastructure/JobQueue/JobQueueHelper.js (100%) rename server/{ => src}/service/infrastructure/PulseQueue/PulseQueue.js (100%) rename server/{ => src}/service/infrastructure/PulseQueue/PulseQueueHelper.js (100%) rename server/{ => src}/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js (100%) rename server/{ => src}/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js (100%) rename server/{ => src}/service/infrastructure/bufferService.js (100%) rename server/{ => src}/service/infrastructure/emailService.js (100%) rename server/{ => src}/service/infrastructure/errorService.js (100%) rename server/{ => src}/service/infrastructure/networkService.js (100%) rename server/{ => src}/service/infrastructure/notificationService.js (100%) rename server/{ => src}/service/infrastructure/notificationUtils.js (100%) rename server/{ => src}/service/infrastructure/statusService.js (100%) rename server/{ => src}/service/system/serviceRegistry.js (100%) rename server/{ => src}/service/system/settingsService.js (100%) rename server/{ => src}/service/system/stringService.js (100%) rename server/{ => src}/service/system/translationService.js (100%) rename server/{ => src}/shutdown.js (100%) rename server/{ => src}/templates/addReview.mjml (100%) rename server/{ => src}/templates/employeeActivation.mjml (100%) rename server/{ => src}/templates/hardwareIncident.mjml (100%) rename server/{ => src}/templates/noIncidentsThisWeek.mjml (100%) rename server/{ => src}/templates/passwordReset.mjml (100%) rename server/{ => src}/templates/serverIsDown.mjml (100%) rename server/{ => src}/templates/serverIsUp.mjml (100%) rename server/{ => src}/templates/testEmailTemplate.mjml (100%) rename server/{ => src}/templates/welcomeEmail.mjml (100%) rename server/{ => src}/utils/dataUtils.js (100%) rename server/{ => src}/utils/demoMonitors.json (100%) rename server/{ => src}/utils/demoMonitorsOld.json (100%) rename server/{ => src}/utils/imageProcessing.js (100%) rename server/{ => src}/utils/logger.js (100%) rename server/{ => src}/utils/roleUtils.js (100%) rename server/{ => src}/utils/utils.js (100%) rename server/{ => src}/validation/joi.js (100%) diff --git a/docker/dev/server.Dockerfile b/docker/dev/server.Dockerfile index d9d99a09d..a3542c168 100755 --- a/docker/dev/server.Dockerfile +++ b/docker/dev/server.Dockerfile @@ -10,4 +10,4 @@ COPY ./server/ ./ EXPOSE 52345 -CMD ["node", "index.js"] \ No newline at end of file +CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/docker/dist-arm/server.Dockerfile b/docker/dist-arm/server.Dockerfile index 981d1cf8f..c6d31a893 100644 --- a/docker/dist-arm/server.Dockerfile +++ b/docker/dist-arm/server.Dockerfile @@ -22,4 +22,4 @@ RUN chmod +x ./scripts/inject-vars.sh EXPOSE 52345 -CMD ./scripts/inject-vars.sh && node ./index.js +CMD ./scripts/inject-vars.sh && node ./src/index.js diff --git a/docker/dist-mono/server.Dockerfile b/docker/dist-mono/server.Dockerfile index f28c834ea..4063e4d7a 100644 --- a/docker/dist-mono/server.Dockerfile +++ b/docker/dist-mono/server.Dockerfile @@ -22,4 +22,4 @@ RUN chmod +x ./scripts/inject-vars.sh EXPOSE 52345 -CMD ./scripts/inject-vars.sh && node ./index.js +CMD ./scripts/inject-vars.sh && node ./src/index.js diff --git a/docker/dist/build_images.sh b/docker/dist/build_images.sh index ccfa9cc85..953682175 100755 --- a/docker/dist/build_images.sh +++ b/docker/dist/build_images.sh @@ -12,10 +12,10 @@ declare -A services=( ) for service in "${!services[@]}"; do - docker buildx build \ - --platform linux/amd64,linux/arm64 \ + docker build \ -f "${services[$service]}" \ -t "$service" \ + . if [ $? -ne 0 ]; then echo "Error building $service image. Exiting..." diff --git a/docker/dist/server.Dockerfile b/docker/dist/server.Dockerfile index d9d99a09d..a3542c168 100755 --- a/docker/dist/server.Dockerfile +++ b/docker/dist/server.Dockerfile @@ -10,4 +10,4 @@ COPY ./server/ ./ EXPOSE 52345 -CMD ["node", "index.js"] \ No newline at end of file +CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/docker/prod/server.Dockerfile b/docker/prod/server.Dockerfile index c80f468f8..94018c07d 100755 --- a/docker/prod/server.Dockerfile +++ b/docker/prod/server.Dockerfile @@ -12,4 +12,4 @@ COPY ./server ./ EXPOSE 52345 -CMD ["node", "index.js"] \ No newline at end of file +CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/docker/staging/server.Dockerfile b/docker/staging/server.Dockerfile index c80f468f8..94018c07d 100755 --- a/docker/staging/server.Dockerfile +++ b/docker/staging/server.Dockerfile @@ -12,4 +12,4 @@ COPY ./server ./ EXPOSE 52345 -CMD ["node", "index.js"] \ No newline at end of file +CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/server/index.js b/server/index.js old mode 100755 new mode 100644 index 607847e8b..5c835b9f6 --- a/server/index.js +++ b/server/index.js @@ -1,14 +1,14 @@ -import { initializeServices } from "./config/services.js"; -import { initializeControllers } from "./config/controllers.js"; -import { createApp } from "./app.js"; +import { initializeServices } from "./src/config/services.js"; +import { initializeControllers } from "./src/config/controllers.js"; +import { createApp } from "./src/app.js"; import { initShutdownListener } from "./shutdown.js"; import logger from "./utils/logger.js"; import { fileURLToPath } from "url"; import path from "path"; import fs from "fs"; -import SettingsService from "./service/system/settingsService.js"; -import AppSettings from "./db/models/AppSettings.js"; +import SettingsService from "./src/service/system/settingsService.js"; +import AppSettings from "./src/db/models/AppSettings.js"; const SERVICE_NAME = "Server"; diff --git a/server/nodemon.json b/server/nodemon.json index 928d1abc2..88d6ac686 100755 --- a/server/nodemon.json +++ b/server/nodemon.json @@ -1,5 +1,5 @@ { - "ignore": ["locales/*", "*.log", "node_modules/*"], - "watch": ["*.js", "*.json"], + "ignore": ["src/locales/*", "*.log", "node_modules/*"], + "watch": ["src/**/*.js", "*.json"], "ext": "js,json" } diff --git a/server/package.json b/server/package.json index 62a91921b..d3d7344e9 100755 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "test": "c8 mocha", - "dev": "nodemon index.js", + "dev": "nodemon src/index.js", "lint": "eslint .", "lint-fix": "eslint --fix .", "format": "prettier --write .", diff --git a/server/server.js b/server/server.js deleted file mode 100644 index 13a01fae6..000000000 --- a/server/server.js +++ /dev/null @@ -1 +0,0 @@ -export const startServer = () => {}; diff --git a/server/app.js b/server/src/app.js similarity index 100% rename from server/app.js rename to server/src/app.js diff --git a/server/config/controllers.js b/server/src/config/controllers.js similarity index 100% rename from server/config/controllers.js rename to server/src/config/controllers.js diff --git a/server/config/database.js b/server/src/config/database.js similarity index 100% rename from server/config/database.js rename to server/src/config/database.js diff --git a/server/config/routes.js b/server/src/config/routes.js similarity index 100% rename from server/config/routes.js rename to server/src/config/routes.js diff --git a/server/config/services.js b/server/src/config/services.js similarity index 100% rename from server/config/services.js rename to server/src/config/services.js diff --git a/server/controllers/announcementsController.js b/server/src/controllers/announcementsController.js similarity index 100% rename from server/controllers/announcementsController.js rename to server/src/controllers/announcementsController.js diff --git a/server/controllers/authController.js b/server/src/controllers/authController.js similarity index 100% rename from server/controllers/authController.js rename to server/src/controllers/authController.js diff --git a/server/controllers/baseController.js b/server/src/controllers/baseController.js similarity index 100% rename from server/controllers/baseController.js rename to server/src/controllers/baseController.js diff --git a/server/controllers/checkController.js b/server/src/controllers/checkController.js similarity index 100% rename from server/controllers/checkController.js rename to server/src/controllers/checkController.js diff --git a/server/controllers/controllerUtils.js b/server/src/controllers/controllerUtils.js similarity index 100% rename from server/controllers/controllerUtils.js rename to server/src/controllers/controllerUtils.js diff --git a/server/controllers/diagnosticController.js b/server/src/controllers/diagnosticController.js similarity index 100% rename from server/controllers/diagnosticController.js rename to server/src/controllers/diagnosticController.js diff --git a/server/controllers/inviteController.js b/server/src/controllers/inviteController.js similarity index 100% rename from server/controllers/inviteController.js rename to server/src/controllers/inviteController.js diff --git a/server/controllers/logController.js b/server/src/controllers/logController.js similarity index 100% rename from server/controllers/logController.js rename to server/src/controllers/logController.js diff --git a/server/controllers/maintenanceWindowController.js b/server/src/controllers/maintenanceWindowController.js similarity index 100% rename from server/controllers/maintenanceWindowController.js rename to server/src/controllers/maintenanceWindowController.js diff --git a/server/controllers/monitorController.js b/server/src/controllers/monitorController.js similarity index 100% rename from server/controllers/monitorController.js rename to server/src/controllers/monitorController.js diff --git a/server/controllers/notificationController.js b/server/src/controllers/notificationController.js similarity index 100% rename from server/controllers/notificationController.js rename to server/src/controllers/notificationController.js diff --git a/server/controllers/queueController.js b/server/src/controllers/queueController.js similarity index 100% rename from server/controllers/queueController.js rename to server/src/controllers/queueController.js diff --git a/server/controllers/settingsController.js b/server/src/controllers/settingsController.js similarity index 100% rename from server/controllers/settingsController.js rename to server/src/controllers/settingsController.js diff --git a/server/controllers/statusPageController.js b/server/src/controllers/statusPageController.js similarity index 100% rename from server/controllers/statusPageController.js rename to server/src/controllers/statusPageController.js diff --git a/server/db/models/AppSettings.js b/server/src/db/models/AppSettings.js similarity index 100% rename from server/db/models/AppSettings.js rename to server/src/db/models/AppSettings.js diff --git a/server/db/models/Check.js b/server/src/db/models/Check.js similarity index 100% rename from server/db/models/Check.js rename to server/src/db/models/Check.js diff --git a/server/db/models/HardwareCheck.js b/server/src/db/models/HardwareCheck.js similarity index 100% rename from server/db/models/HardwareCheck.js rename to server/src/db/models/HardwareCheck.js diff --git a/server/db/models/InviteToken.js b/server/src/db/models/InviteToken.js similarity index 100% rename from server/db/models/InviteToken.js rename to server/src/db/models/InviteToken.js diff --git a/server/db/models/MaintenanceWindow.js b/server/src/db/models/MaintenanceWindow.js similarity index 100% rename from server/db/models/MaintenanceWindow.js rename to server/src/db/models/MaintenanceWindow.js diff --git a/server/db/models/Monitor.js b/server/src/db/models/Monitor.js similarity index 100% rename from server/db/models/Monitor.js rename to server/src/db/models/Monitor.js diff --git a/server/db/models/MonitorStats.js b/server/src/db/models/MonitorStats.js similarity index 100% rename from server/db/models/MonitorStats.js rename to server/src/db/models/MonitorStats.js diff --git a/server/db/models/NetworkCheck.js b/server/src/db/models/NetworkCheck.js similarity index 100% rename from server/db/models/NetworkCheck.js rename to server/src/db/models/NetworkCheck.js diff --git a/server/db/models/Notification.js b/server/src/db/models/Notification.js similarity index 100% rename from server/db/models/Notification.js rename to server/src/db/models/Notification.js diff --git a/server/db/models/PageSpeedCheck.js b/server/src/db/models/PageSpeedCheck.js similarity index 96% rename from server/db/models/PageSpeedCheck.js rename to server/src/db/models/PageSpeedCheck.js index 513dd3039..67c7c375e 100755 --- a/server/db/models/PageSpeedCheck.js +++ b/server/src/db/models/PageSpeedCheck.js @@ -1,7 +1,5 @@ import mongoose from "mongoose"; import { BaseCheckSchema } from "./Check.js"; -import logger from "../../utils/logger.js"; -import { time } from "console"; const AuditSchema = mongoose.Schema({ id: { type: String, required: true }, title: { type: String, required: true }, diff --git a/server/db/models/RecoveryToken.js b/server/src/db/models/RecoveryToken.js similarity index 100% rename from server/db/models/RecoveryToken.js rename to server/src/db/models/RecoveryToken.js diff --git a/server/db/models/StatusPage.js b/server/src/db/models/StatusPage.js similarity index 100% rename from server/db/models/StatusPage.js rename to server/src/db/models/StatusPage.js diff --git a/server/db/models/Team.js b/server/src/db/models/Team.js similarity index 100% rename from server/db/models/Team.js rename to server/src/db/models/Team.js diff --git a/server/db/models/User.js b/server/src/db/models/User.js similarity index 100% rename from server/db/models/User.js rename to server/src/db/models/User.js diff --git a/server/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js similarity index 100% rename from server/db/mongo/MongoDB.js rename to server/src/db/mongo/MongoDB.js diff --git a/server/db/mongo/modules/checkModule.js b/server/src/db/mongo/modules/checkModule.js similarity index 100% rename from server/db/mongo/modules/checkModule.js rename to server/src/db/mongo/modules/checkModule.js diff --git a/server/db/mongo/modules/checkModuleQueries.js b/server/src/db/mongo/modules/checkModuleQueries.js similarity index 100% rename from server/db/mongo/modules/checkModuleQueries.js rename to server/src/db/mongo/modules/checkModuleQueries.js diff --git a/server/db/mongo/modules/diagnosticModule.js b/server/src/db/mongo/modules/diagnosticModule.js similarity index 100% rename from server/db/mongo/modules/diagnosticModule.js rename to server/src/db/mongo/modules/diagnosticModule.js diff --git a/server/db/mongo/modules/hardwareCheckModule.js b/server/src/db/mongo/modules/hardwareCheckModule.js similarity index 100% rename from server/db/mongo/modules/hardwareCheckModule.js rename to server/src/db/mongo/modules/hardwareCheckModule.js diff --git a/server/db/mongo/modules/inviteModule.js b/server/src/db/mongo/modules/inviteModule.js similarity index 100% rename from server/db/mongo/modules/inviteModule.js rename to server/src/db/mongo/modules/inviteModule.js diff --git a/server/db/mongo/modules/maintenanceWindowModule.js b/server/src/db/mongo/modules/maintenanceWindowModule.js similarity index 100% rename from server/db/mongo/modules/maintenanceWindowModule.js rename to server/src/db/mongo/modules/maintenanceWindowModule.js diff --git a/server/db/mongo/modules/monitorModule.js b/server/src/db/mongo/modules/monitorModule.js similarity index 100% rename from server/db/mongo/modules/monitorModule.js rename to server/src/db/mongo/modules/monitorModule.js diff --git a/server/db/mongo/modules/monitorModuleQueries.js b/server/src/db/mongo/modules/monitorModuleQueries.js similarity index 100% rename from server/db/mongo/modules/monitorModuleQueries.js rename to server/src/db/mongo/modules/monitorModuleQueries.js diff --git a/server/db/mongo/modules/networkCheckModule.js b/server/src/db/mongo/modules/networkCheckModule.js similarity index 100% rename from server/db/mongo/modules/networkCheckModule.js rename to server/src/db/mongo/modules/networkCheckModule.js diff --git a/server/db/mongo/modules/notificationModule.js b/server/src/db/mongo/modules/notificationModule.js similarity index 100% rename from server/db/mongo/modules/notificationModule.js rename to server/src/db/mongo/modules/notificationModule.js diff --git a/server/db/mongo/modules/pageSpeedCheckModule.js b/server/src/db/mongo/modules/pageSpeedCheckModule.js similarity index 100% rename from server/db/mongo/modules/pageSpeedCheckModule.js rename to server/src/db/mongo/modules/pageSpeedCheckModule.js diff --git a/server/db/mongo/modules/recoveryModule.js b/server/src/db/mongo/modules/recoveryModule.js similarity index 100% rename from server/db/mongo/modules/recoveryModule.js rename to server/src/db/mongo/modules/recoveryModule.js diff --git a/server/db/mongo/modules/settingsModule.js b/server/src/db/mongo/modules/settingsModule.js similarity index 100% rename from server/db/mongo/modules/settingsModule.js rename to server/src/db/mongo/modules/settingsModule.js diff --git a/server/db/mongo/modules/statusPageModule.js b/server/src/db/mongo/modules/statusPageModule.js similarity index 100% rename from server/db/mongo/modules/statusPageModule.js rename to server/src/db/mongo/modules/statusPageModule.js diff --git a/server/db/mongo/modules/userModule.js b/server/src/db/mongo/modules/userModule.js similarity index 100% rename from server/db/mongo/modules/userModule.js rename to server/src/db/mongo/modules/userModule.js diff --git a/server/db/mongo/utils/seedDb.js b/server/src/db/mongo/utils/seedDb.js similarity index 100% rename from server/db/mongo/utils/seedDb.js rename to server/src/db/mongo/utils/seedDb.js diff --git a/server/src/index.js b/server/src/index.js new file mode 100755 index 000000000..a34a517ca --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,55 @@ +import { initializeServices } from "./config/services.js"; +import { initializeControllers } from "./config/controllers.js"; +import { createApp } from "./app.js"; +import { initShutdownListener } from "./shutdown.js"; +import logger from "./utils/logger.js"; +import { fileURLToPath } from "url"; +import path from "path"; +import fs from "fs"; + +import SettingsService from "./service/system/settingsService.js"; +import AppSettings from "./db/models/AppSettings.js"; + +const SERVICE_NAME = "Server"; + +const startApp = async () => { + // FE path + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8")); + const frontendPath = path.join(__dirname, "public"); + // Create services + const settingsService = new SettingsService(AppSettings); + const appSettings = settingsService.loadSettings(); + + // Initialize services + const services = await initializeServices(appSettings, settingsService); + + // Initialize controllers + const controllers = initializeControllers(services); + + const app = createApp({ + services, + controllers, + appSettings, + frontendPath, + openApiSpec, + }); + + const port = appSettings.port || 52345; + const server = app.listen(port, () => { + logger.info({ message: `Server started on port:${port}` }); + }); + + initShutdownListener(server, services); +}; + +startApp().catch((error) => { + logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "startApp", + stack: error.stack, + }); + process.exit(1); +}); diff --git a/server/locales/en.json b/server/src/locales/en.json similarity index 100% rename from server/locales/en.json rename to server/src/locales/en.json diff --git a/server/locales/en.json.bak b/server/src/locales/en.json.bak similarity index 100% rename from server/locales/en.json.bak rename to server/src/locales/en.json.bak diff --git a/server/locales/tr.json b/server/src/locales/tr.json similarity index 100% rename from server/locales/tr.json rename to server/src/locales/tr.json diff --git a/server/middleware/handleErrors.js b/server/src/middleware/handleErrors.js similarity index 100% rename from server/middleware/handleErrors.js rename to server/src/middleware/handleErrors.js diff --git a/server/middleware/isAllowed.js b/server/src/middleware/isAllowed.js similarity index 100% rename from server/middleware/isAllowed.js rename to server/src/middleware/isAllowed.js diff --git a/server/middleware/languageMiddleware.js b/server/src/middleware/languageMiddleware.js similarity index 100% rename from server/middleware/languageMiddleware.js rename to server/src/middleware/languageMiddleware.js diff --git a/server/middleware/responseHandler.js b/server/src/middleware/responseHandler.js similarity index 100% rename from server/middleware/responseHandler.js rename to server/src/middleware/responseHandler.js diff --git a/server/middleware/verifyJWT.js b/server/src/middleware/verifyJWT.js similarity index 100% rename from server/middleware/verifyJWT.js rename to server/src/middleware/verifyJWT.js diff --git a/server/middleware/verifyOwnership.js b/server/src/middleware/verifyOwnership.js similarity index 100% rename from server/middleware/verifyOwnership.js rename to server/src/middleware/verifyOwnership.js diff --git a/server/middleware/verifySuperAdmin.js b/server/src/middleware/verifySuperAdmin.js similarity index 100% rename from server/middleware/verifySuperAdmin.js rename to server/src/middleware/verifySuperAdmin.js diff --git a/server/middleware/verifyTeamAccess.js b/server/src/middleware/verifyTeamAccess.js similarity index 100% rename from server/middleware/verifyTeamAccess.js rename to server/src/middleware/verifyTeamAccess.js diff --git a/server/routes/announcementsRoute.js b/server/src/routes/announcementsRoute.js similarity index 100% rename from server/routes/announcementsRoute.js rename to server/src/routes/announcementsRoute.js diff --git a/server/routes/authRoute.js b/server/src/routes/authRoute.js similarity index 100% rename from server/routes/authRoute.js rename to server/src/routes/authRoute.js diff --git a/server/routes/checkRoute.js b/server/src/routes/checkRoute.js similarity index 100% rename from server/routes/checkRoute.js rename to server/src/routes/checkRoute.js diff --git a/server/routes/diagnosticRoute.js b/server/src/routes/diagnosticRoute.js similarity index 100% rename from server/routes/diagnosticRoute.js rename to server/src/routes/diagnosticRoute.js diff --git a/server/routes/inviteRoute.js b/server/src/routes/inviteRoute.js similarity index 100% rename from server/routes/inviteRoute.js rename to server/src/routes/inviteRoute.js diff --git a/server/routes/logRoutes.js b/server/src/routes/logRoutes.js similarity index 100% rename from server/routes/logRoutes.js rename to server/src/routes/logRoutes.js diff --git a/server/routes/maintenanceWindowRoute.js b/server/src/routes/maintenanceWindowRoute.js similarity index 100% rename from server/routes/maintenanceWindowRoute.js rename to server/src/routes/maintenanceWindowRoute.js diff --git a/server/routes/monitorRoute.js b/server/src/routes/monitorRoute.js similarity index 100% rename from server/routes/monitorRoute.js rename to server/src/routes/monitorRoute.js diff --git a/server/routes/notificationRoute.js b/server/src/routes/notificationRoute.js similarity index 100% rename from server/routes/notificationRoute.js rename to server/src/routes/notificationRoute.js diff --git a/server/routes/queueRoute.js b/server/src/routes/queueRoute.js similarity index 100% rename from server/routes/queueRoute.js rename to server/src/routes/queueRoute.js diff --git a/server/routes/settingsRoute.js b/server/src/routes/settingsRoute.js similarity index 100% rename from server/routes/settingsRoute.js rename to server/src/routes/settingsRoute.js diff --git a/server/routes/statusPageRoute.js b/server/src/routes/statusPageRoute.js similarity index 100% rename from server/routes/statusPageRoute.js rename to server/src/routes/statusPageRoute.js diff --git a/server/service/business/checkService.js b/server/src/service/business/checkService.js similarity index 100% rename from server/service/business/checkService.js rename to server/src/service/business/checkService.js diff --git a/server/service/business/diagnosticService.js b/server/src/service/business/diagnosticService.js similarity index 100% rename from server/service/business/diagnosticService.js rename to server/src/service/business/diagnosticService.js diff --git a/server/service/business/inviteService.js b/server/src/service/business/inviteService.js similarity index 100% rename from server/service/business/inviteService.js rename to server/src/service/business/inviteService.js diff --git a/server/service/business/maintenanceWindowService.js b/server/src/service/business/maintenanceWindowService.js similarity index 100% rename from server/service/business/maintenanceWindowService.js rename to server/src/service/business/maintenanceWindowService.js diff --git a/server/service/business/monitorService.js b/server/src/service/business/monitorService.js similarity index 100% rename from server/service/business/monitorService.js rename to server/src/service/business/monitorService.js diff --git a/server/service/business/userService.js b/server/src/service/business/userService.js similarity index 100% rename from server/service/business/userService.js rename to server/src/service/business/userService.js diff --git a/server/service/data/redisService.js b/server/src/service/data/redisService.js similarity index 100% rename from server/service/data/redisService.js rename to server/src/service/data/redisService.js diff --git a/server/service/infrastructure/JobQueue/JobQueue.js b/server/src/service/infrastructure/JobQueue/JobQueue.js similarity index 100% rename from server/service/infrastructure/JobQueue/JobQueue.js rename to server/src/service/infrastructure/JobQueue/JobQueue.js diff --git a/server/service/infrastructure/JobQueue/JobQueueHelper.js b/server/src/service/infrastructure/JobQueue/JobQueueHelper.js similarity index 100% rename from server/service/infrastructure/JobQueue/JobQueueHelper.js rename to server/src/service/infrastructure/JobQueue/JobQueueHelper.js diff --git a/server/service/infrastructure/PulseQueue/PulseQueue.js b/server/src/service/infrastructure/PulseQueue/PulseQueue.js similarity index 100% rename from server/service/infrastructure/PulseQueue/PulseQueue.js rename to server/src/service/infrastructure/PulseQueue/PulseQueue.js diff --git a/server/service/infrastructure/PulseQueue/PulseQueueHelper.js b/server/src/service/infrastructure/PulseQueue/PulseQueueHelper.js similarity index 100% rename from server/service/infrastructure/PulseQueue/PulseQueueHelper.js rename to server/src/service/infrastructure/PulseQueue/PulseQueueHelper.js diff --git a/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js similarity index 100% rename from server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js rename to server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js diff --git a/server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js similarity index 100% rename from server/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js rename to server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js diff --git a/server/service/infrastructure/bufferService.js b/server/src/service/infrastructure/bufferService.js similarity index 100% rename from server/service/infrastructure/bufferService.js rename to server/src/service/infrastructure/bufferService.js diff --git a/server/service/infrastructure/emailService.js b/server/src/service/infrastructure/emailService.js similarity index 100% rename from server/service/infrastructure/emailService.js rename to server/src/service/infrastructure/emailService.js diff --git a/server/service/infrastructure/errorService.js b/server/src/service/infrastructure/errorService.js similarity index 100% rename from server/service/infrastructure/errorService.js rename to server/src/service/infrastructure/errorService.js diff --git a/server/service/infrastructure/networkService.js b/server/src/service/infrastructure/networkService.js similarity index 100% rename from server/service/infrastructure/networkService.js rename to server/src/service/infrastructure/networkService.js diff --git a/server/service/infrastructure/notificationService.js b/server/src/service/infrastructure/notificationService.js similarity index 100% rename from server/service/infrastructure/notificationService.js rename to server/src/service/infrastructure/notificationService.js diff --git a/server/service/infrastructure/notificationUtils.js b/server/src/service/infrastructure/notificationUtils.js similarity index 100% rename from server/service/infrastructure/notificationUtils.js rename to server/src/service/infrastructure/notificationUtils.js diff --git a/server/service/infrastructure/statusService.js b/server/src/service/infrastructure/statusService.js similarity index 100% rename from server/service/infrastructure/statusService.js rename to server/src/service/infrastructure/statusService.js diff --git a/server/service/system/serviceRegistry.js b/server/src/service/system/serviceRegistry.js similarity index 100% rename from server/service/system/serviceRegistry.js rename to server/src/service/system/serviceRegistry.js diff --git a/server/service/system/settingsService.js b/server/src/service/system/settingsService.js similarity index 100% rename from server/service/system/settingsService.js rename to server/src/service/system/settingsService.js diff --git a/server/service/system/stringService.js b/server/src/service/system/stringService.js similarity index 100% rename from server/service/system/stringService.js rename to server/src/service/system/stringService.js diff --git a/server/service/system/translationService.js b/server/src/service/system/translationService.js similarity index 100% rename from server/service/system/translationService.js rename to server/src/service/system/translationService.js diff --git a/server/shutdown.js b/server/src/shutdown.js similarity index 100% rename from server/shutdown.js rename to server/src/shutdown.js diff --git a/server/templates/addReview.mjml b/server/src/templates/addReview.mjml similarity index 100% rename from server/templates/addReview.mjml rename to server/src/templates/addReview.mjml diff --git a/server/templates/employeeActivation.mjml b/server/src/templates/employeeActivation.mjml similarity index 100% rename from server/templates/employeeActivation.mjml rename to server/src/templates/employeeActivation.mjml diff --git a/server/templates/hardwareIncident.mjml b/server/src/templates/hardwareIncident.mjml similarity index 100% rename from server/templates/hardwareIncident.mjml rename to server/src/templates/hardwareIncident.mjml diff --git a/server/templates/noIncidentsThisWeek.mjml b/server/src/templates/noIncidentsThisWeek.mjml similarity index 100% rename from server/templates/noIncidentsThisWeek.mjml rename to server/src/templates/noIncidentsThisWeek.mjml diff --git a/server/templates/passwordReset.mjml b/server/src/templates/passwordReset.mjml similarity index 100% rename from server/templates/passwordReset.mjml rename to server/src/templates/passwordReset.mjml diff --git a/server/templates/serverIsDown.mjml b/server/src/templates/serverIsDown.mjml similarity index 100% rename from server/templates/serverIsDown.mjml rename to server/src/templates/serverIsDown.mjml diff --git a/server/templates/serverIsUp.mjml b/server/src/templates/serverIsUp.mjml similarity index 100% rename from server/templates/serverIsUp.mjml rename to server/src/templates/serverIsUp.mjml diff --git a/server/templates/testEmailTemplate.mjml b/server/src/templates/testEmailTemplate.mjml similarity index 100% rename from server/templates/testEmailTemplate.mjml rename to server/src/templates/testEmailTemplate.mjml diff --git a/server/templates/welcomeEmail.mjml b/server/src/templates/welcomeEmail.mjml similarity index 100% rename from server/templates/welcomeEmail.mjml rename to server/src/templates/welcomeEmail.mjml diff --git a/server/utils/dataUtils.js b/server/src/utils/dataUtils.js similarity index 100% rename from server/utils/dataUtils.js rename to server/src/utils/dataUtils.js diff --git a/server/utils/demoMonitors.json b/server/src/utils/demoMonitors.json similarity index 100% rename from server/utils/demoMonitors.json rename to server/src/utils/demoMonitors.json diff --git a/server/utils/demoMonitorsOld.json b/server/src/utils/demoMonitorsOld.json similarity index 100% rename from server/utils/demoMonitorsOld.json rename to server/src/utils/demoMonitorsOld.json diff --git a/server/utils/imageProcessing.js b/server/src/utils/imageProcessing.js similarity index 100% rename from server/utils/imageProcessing.js rename to server/src/utils/imageProcessing.js diff --git a/server/utils/logger.js b/server/src/utils/logger.js similarity index 100% rename from server/utils/logger.js rename to server/src/utils/logger.js diff --git a/server/utils/roleUtils.js b/server/src/utils/roleUtils.js similarity index 100% rename from server/utils/roleUtils.js rename to server/src/utils/roleUtils.js diff --git a/server/utils/utils.js b/server/src/utils/utils.js similarity index 100% rename from server/utils/utils.js rename to server/src/utils/utils.js diff --git a/server/validation/joi.js b/server/src/validation/joi.js similarity index 100% rename from server/validation/joi.js rename to server/src/validation/joi.js From 8bfb187ef0c95931a844a9930e4fac73995d5ef6 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Sun, 27 Jul 2025 03:47:30 +0530 Subject: [PATCH 091/226] FIX merge conflicts --- docker/dev/docker-compose.yaml | 33 ++++++++------------------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/docker/dev/docker-compose.yaml b/docker/dev/docker-compose.yaml index 292d0b1d5..75d152052 100755 --- a/docker/dev/docker-compose.yaml +++ b/docker/dev/docker-compose.yaml @@ -1,29 +1,9 @@ services: - client: - image: uptime_client:latest - restart: always - ports: - - "80:80" - environment: - UPTIME_APP_API_BASE_URL: "http://localhost:52345/api/v1" - UPTIME_APP_CLIENT_HOST: "http://localhost" - volumes: - - ./nginx/conf.d:/etc/nginx/conf.d/ - depends_on: - - server - server: - image: uptime_server:latest - restart: always - ports: - - "52345:52345" - env_file: - - server.env - depends_on: - - redis - - mongodb redis: - image: uptime_redis:latest + image: redis:latest restart: always + ports: + - "6379:6379" # ✅ Expose Redis to host volumes: - ./redis/data:/data healthcheck: @@ -32,14 +12,17 @@ services: timeout: 10s retries: 5 start_period: 5s + mongodb: - image: uptime_mongo:latest + image: mongo:latest restart: always + ports: + - "27017:27017" # ✅ Expose MongoDB to host command: ["mongod", "--quiet", "--replSet", "rs0", "--bind_ip_all"] volumes: - ./mongo/data:/data/db healthcheck: - test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) }" | mongosh --port 27017 --quiet + test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'localhost:27017'}]}) }" | mongosh --port 27017 --quiet interval: 5s timeout: 30s start_period: 0s From e5e02b553a70c67cbd460f0601afb52d5e58c016 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sun, 27 Jul 2025 19:51:39 -0700 Subject: [PATCH 092/226] fix dependencies --- server/src/config/controllers.js | 2 +- server/src/config/services.js | 3 +++ server/src/service/business/userService.js | 4 +++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/server/src/config/controllers.js b/server/src/config/controllers.js index 2185f9ffd..693e35ab8 100644 --- a/server/src/config/controllers.js +++ b/server/src/config/controllers.js @@ -17,7 +17,7 @@ import DiagnosticController from "../controllers/diagnosticController.js"; export const initializeControllers = (services) => { const controllers = {}; - const commonDependencies = createCommonDependencies(services.db, services.logger, services.errorService, services.stringService); + const commonDependencies = createCommonDependencies(services.db, services.errorService, services.logger, services.stringService); controllers.authController = new AuthController(commonDependencies, { settingsService: services.settingsService, diff --git a/server/src/config/services.js b/server/src/config/services.js index 490c252b9..75936c29e 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -33,6 +33,7 @@ import pkg from "handlebars"; const { compile } = pkg; import mjml2html from "mjml"; import jwt from "jsonwebtoken"; +import crypto from "crypto"; export const initializeServices = async (appSettings, settingsService) => { const translationService = new TranslationService(logger); @@ -83,6 +84,7 @@ export const initializeServices = async (appSettings, settingsService) => { // Business services const userService = new UserService({ + crypto, db, emailService, settingsService, @@ -90,6 +92,7 @@ export const initializeServices = async (appSettings, settingsService) => { stringService, jwt, errorService, + jobQueue: superSimpleQueue, }); const checkService = new CheckService({ db, diff --git a/server/src/service/business/userService.js b/server/src/service/business/userService.js index e4296a9c7..d0d1a1205 100644 --- a/server/src/service/business/userService.js +++ b/server/src/service/business/userService.js @@ -3,7 +3,7 @@ const SERVICE_NAME = "userService"; class UserService { static SERVICE_NAME = SERVICE_NAME; - constructor({ db, emailService, settingsService, logger, stringService, jwt, errorService }) { + constructor({ crypto, db, emailService, settingsService, logger, stringService, jwt, errorService, jobQueue }) { this.db = db; this.emailService = emailService; this.settingsService = settingsService; @@ -11,6 +11,8 @@ class UserService { this.stringService = stringService; this.jwt = jwt; this.errorService = errorService; + this.jobQueue = jobQueue; + this.crypto = crypto; } get serviceName() { From ab8868e6c4855f071801f019a671a9c89b079351 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 28 Jul 2025 11:35:49 -0700 Subject: [PATCH 093/226] add an field to the monitor for status page queries --- .../src/db/mongo/modules/statusPageModule.js | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/server/src/db/mongo/modules/statusPageModule.js b/server/src/db/mongo/modules/statusPageModule.js index 757fdd266..e814c20f5 100755 --- a/server/src/db/mongo/modules/statusPageModule.js +++ b/server/src/db/mongo/modules/statusPageModule.js @@ -135,6 +135,14 @@ const getStatusPage = async (url) => { preserveNullAndEmptyArrays: true, }, }, + { + $lookup: { + from: "maintenancewindows", + let: { monitorId: "$monitors._id" }, + pipeline: [{ $match: { $expr: { $eq: ["$monitorId", "$$monitorId"] } } }], + as: "monitors.maintenanceWindows", + }, + }, { $lookup: { from: "checks", @@ -156,6 +164,20 @@ const getStatusPage = async (url) => { "monitors.orderIndex": { $indexOfArray: ["$originalMonitors", "$monitors._id"], }, + "monitors.isMaintenance": { + $reduce: { + input: "$monitors.maintenanceWindows", + initialValue: false, + in: { + $or: [ + "$$value", + { + $and: [{ $eq: ["$$this.active", true] }, { $lte: ["$$this.start", "$$NOW"] }, { $gte: ["$$this.end", "$$NOW"] }], + }, + ], + }, + }, + }, }, }, { $match: { "monitors.orderIndex": { $ne: -1 } } }, @@ -183,7 +205,36 @@ const getStatusPage = async (url) => { showAdminLoginLink: 1, url: 1, }, - monitors: 1, + monitors: { + _id: 1, + userId: 1, + teamId: 1, + name: 1, + description: 1, + status: 1, + type: 1, + ignoreTlsErrors: 1, + jsonPath: 1, + expectedValue: 1, + matchMethod: 1, + url: 1, + port: 1, + isActive: 1, + interval: 1, + uptimePercentage: 1, + notifications: 1, + secret: 1, + thresholds: 1, + alertThreshold: 1, + cpuAlertThreshold: 1, + memoryAlertThreshold: 1, + diskAlertThreshold: 1, + tempAlertThreshold: 1, + checks: 1, + isMaintenance: 1, + createdAt: 1, + updatedAt: 1, + }, }, }, ]); From 5c147562b3c6954f2d2dee084247e089d973b344 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Tue, 29 Jul 2025 00:17:34 +0530 Subject: [PATCH 094/226] Added net to be returned from api in backend for infra. --- server/db/models/HardwareCheck.js | 19 +++++ .../db/mongo/modules/monitorModuleQueries.js | 82 +++++++++++++++++++ .../service/infrastructure/statusService.js | 3 +- 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/server/db/models/HardwareCheck.js b/server/db/models/HardwareCheck.js index bda9f693c..f29df2dbc 100755 --- a/server/db/models/HardwareCheck.js +++ b/server/db/models/HardwareCheck.js @@ -40,6 +40,20 @@ const captureSchema = mongoose.Schema({ mode: { type: String, default: "" }, }); +const networkInterfaceSchema = mongoose.Schema({ + name: { type: String, required: true }, + bytes_sent: { type: Number, default: 0 }, + bytes_recv: { type: Number, default: 0 }, + packets_sent: { type: Number, default: 0 }, + packets_recv: { type: Number, default: 0 }, + err_in: { type: Number, default: 0 }, + err_out: { type: Number, default: 0 }, + drop_in: { type: Number, default: 0 }, + drop_out: { type: Number, default: 0 }, + fifo_in: { type: Number, default: 0 }, + fifo_out: { type: Number, default: 0 }, +}); + const HardwareCheckSchema = mongoose.Schema( { ...BaseCheckSchema.obj, @@ -69,6 +83,11 @@ const HardwareCheckSchema = mongoose.Schema( type: captureSchema, default: () => ({}), }, + + net: { + type: [networkInterfaceSchema], + default: () => [], + }, }, { timestamps: true } ); diff --git a/server/db/mongo/modules/monitorModuleQueries.js b/server/db/mongo/modules/monitorModuleQueries.js index d96e128fa..56c9a794b 100755 --- a/server/db/mongo/modules/monitorModuleQueries.js +++ b/server/db/mongo/modules/monitorModuleQueries.js @@ -220,6 +220,7 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { diskCount: { $size: "$disk", }, + netCount: { $size: "$net" }, }, }, { @@ -227,6 +228,7 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { from: "hardwarechecks", let: { diskCount: "$diskCount", + netCount: "$netCount", }, pipeline: [ { @@ -258,6 +260,9 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { disks: { $push: "$disk", }, + net: { + $push: "$net", + }, }, }, { @@ -371,6 +376,83 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { }, }, }, + net: { + $map: { + input: { $range: [0, "$$netCount"] }, + as: "netIndex", + in: { + name: { + $concat: ["net", { $toString: "$$netIndex" }], + }, + bytesSent: { + $avg: { + $map: { + input: "$net", + as: "netArray", + in: { + $arrayElemAt: ["$$netArray.bytes_sent", "$$netIndex"], + }, + }, + }, + }, + bytesRecv: { + $avg: { + $map: { + input: "$net", + as: "netArray", + in: { + $arrayElemAt: ["$$netArray.bytes_recv", "$$netIndex"], + }, + }, + }, + }, + packetsSent: { + $avg: { + $map: { + input: "$net", + as: "netArray", + in: { + $arrayElemAt: ["$$netArray.packets_sent", "$$netIndex"], + }, + }, + }, + }, + packetsRecv: { + $avg: { + $map: { + input: "$net", + as: "netArray", + in: { + $arrayElemAt: ["$$netArray.packets_recv", "$$netIndex"], + }, + }, + }, + }, + errIn: { + $avg: { + $map: { + input: "$net", + as: "netArray", + in: { + $arrayElemAt: ["$$netArray.errin", "$$netIndex"], + }, + }, + }, + }, + errOut: { + $avg: { + $map: { + input: "$net", + as: "netArray", + in: { + $arrayElemAt: ["$$netArray.errout", "$$netIndex"], + }, + }, + }, + }, + }, + }, + }, }, }, ], diff --git a/server/service/infrastructure/statusService.js b/server/service/infrastructure/statusService.js index 1c91a407c..0967d3d46 100755 --- a/server/service/infrastructure/statusService.js +++ b/server/service/infrastructure/statusService.js @@ -232,7 +232,7 @@ class StatusService { } if (type === "hardware") { - const { cpu, memory, disk, host } = payload?.data ?? {}; + const { cpu, memory, disk, host, net } = payload?.data ?? {}; const { errors } = payload?.errors ?? []; check.cpu = cpu ?? {}; check.memory = memory ?? {}; @@ -240,6 +240,7 @@ class StatusService { check.host = host ?? {}; check.errors = errors ?? []; check.capture = payload?.capture ?? {}; + check.net = net ?? {}; } return check; }; From 19fe41d738ce87098d0235c361da5d4daa39978b Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Tue, 29 Jul 2025 00:22:14 +0530 Subject: [PATCH 095/226] reverting the docker dev changes I made. --- docker/dev/docker-compose.yaml | 35 +++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/docker/dev/docker-compose.yaml b/docker/dev/docker-compose.yaml index 75d152052..f0b193b2d 100755 --- a/docker/dev/docker-compose.yaml +++ b/docker/dev/docker-compose.yaml @@ -1,9 +1,29 @@ services: - redis: - image: redis:latest + client: + image: uptime_client:latest restart: always ports: - - "6379:6379" # ✅ Expose Redis to host + - "80:80" + environment: + UPTIME_APP_API_BASE_URL: "http://localhost:52345/api/v1" + UPTIME_APP_CLIENT_HOST: "http://localhost" + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d/ + depends_on: + - server + server: + image: uptime_server:latest + restart: always + ports: + - "52345:52345" + env_file: + - server.env + depends_on: + - redis + - mongodb + redis: + image: uptime_redis:latest + restart: always volumes: - ./redis/data:/data healthcheck: @@ -12,19 +32,16 @@ services: timeout: 10s retries: 5 start_period: 5s - mongodb: - image: mongo:latest + image: uptime_mongo:latest restart: always - ports: - - "27017:27017" # ✅ Expose MongoDB to host command: ["mongod", "--quiet", "--replSet", "rs0", "--bind_ip_all"] volumes: - ./mongo/data:/data/db healthcheck: - test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'localhost:27017'}]}) }" | mongosh --port 27017 --quiet + test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) }" | mongosh --port 27017 --quiet interval: 5s timeout: 30s start_period: 0s start_interval: 1s - retries: 30 + retries: 30 \ No newline at end of file From ec99b5e9b48c11b2adda12d77006fa4fee518a5a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 28 Jul 2025 12:00:09 -0700 Subject: [PATCH 096/226] add rate limiting --- server/package-lock.json | 44 +++++++++++++++++++++++----- server/package.json | 3 +- server/src/app.js | 4 ++- server/src/config/routes.js | 3 +- server/src/middleware/rateLimiter.js | 17 +++++++++++ 5 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 server/src/middleware/rateLimiter.js diff --git a/server/package-lock.json b/server/package-lock.json index d2ba13f13..a41a50518 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -13,11 +13,12 @@ "axios": "^1.7.2", "bcryptjs": "3.0.2", "bullmq": "5.41.2", - "compression": "1.8.0", + "compression": "1.8.1", "cors": "^2.8.5", "dockerode": "4.0.6", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-rate-limit": "8.0.1", "handlebars": "^4.7.8", "helmet": "^8.0.0", "ioredis": "^5.4.2", @@ -2022,16 +2023,16 @@ } }, "node_modules/compression": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", - "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", - "on-headers": "~1.0.2", + "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" }, @@ -3159,6 +3160,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.0.1.tgz", + "integrity": "sha512-aZVCnybn7TVmxO4BtlmnvX+nuz8qHW124KKJ8dumsBsmv5ZLxE0pYu7S2nwyRBGHHCAzdmnGyrc5U/rksSPO7Q==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -3901,6 +3920,15 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5705,9 +5733,9 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" diff --git a/server/package.json b/server/package.json index d3d7344e9..4768fb45f 100755 --- a/server/package.json +++ b/server/package.json @@ -20,11 +20,12 @@ "axios": "^1.7.2", "bcryptjs": "3.0.2", "bullmq": "5.41.2", - "compression": "1.8.0", + "compression": "1.8.1", "cors": "^2.8.5", "dockerode": "4.0.6", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-rate-limit": "8.0.1", "handlebars": "^4.7.8", "helmet": "^8.0.0", "ioredis": "^5.4.2", diff --git a/server/src/app.js b/server/src/app.js index af53dbf56..04166b313 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -8,11 +8,13 @@ import languageMiddleware from "./middleware/languageMiddleware.js"; import swaggerUi from "swagger-ui-express"; import { handleErrors } from "./middleware/handleErrors.js"; import { setupRoutes } from "./config/routes.js"; +import { generalApiLimiter } from "./middleware/rateLimiter.js"; + export const createApp = ({ services, controllers, appSettings, frontendPath, openApiSpec }) => { const allowedOrigin = appSettings.clientHost; const app = express(); - + app.use(generalApiLimiter); // Static files app.use(express.static(frontendPath)); diff --git a/server/src/config/routes.js b/server/src/config/routes.js index 64b1df474..7056393f9 100644 --- a/server/src/config/routes.js +++ b/server/src/config/routes.js @@ -1,4 +1,5 @@ import { verifyJWT } from "../middleware/verifyJWT.js"; +import { authApiLimiter } from "../middleware/rateLimiter.js"; import AuthRoutes from "../routes/authRoute.js"; import InviteRoutes from "../routes/inviteRoute.js"; @@ -25,7 +26,7 @@ export const setupRoutes = (app, controllers) => { const notificationRoutes = new NotificationRoutes(controllers.notificationController); const diagnosticRoutes = new DiagnosticRoutes(controllers.diagnosticController); - app.use("/api/v1/auth", authRoutes.getRouter()); + app.use("/api/v1/auth", authApiLimiter, authRoutes.getRouter()); app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter()); app.use("/api/v1/settings", verifyJWT, settingsRoutes.getRouter()); app.use("/api/v1/checks", verifyJWT, checkRoutes.getRouter()); diff --git a/server/src/middleware/rateLimiter.js b/server/src/middleware/rateLimiter.js new file mode 100644 index 000000000..2e5a34f3b --- /dev/null +++ b/server/src/middleware/rateLimiter.js @@ -0,0 +1,17 @@ +import rateLimit from "express-rate-limit"; + +export const generalApiLimiter = rateLimit({ + window: 60 * 1000, + limit: 600, + standardHeaders: true, + legacyHeaders: false, + ipv6Subnet: 64, +}); + +export const authApiLimiter = rateLimit({ + window: 60 * 1000, + limit: 5, + standardHeaders: true, + legacyHeaders: false, + ipv6Subnet: 64, +}); From ab0bf5ca5f9a8cbe10437d1e1b4a43ba31a63322 Mon Sep 17 00:00:00 2001 From: Owaise Imdad Date: Tue, 29 Jul 2025 00:42:55 +0530 Subject: [PATCH 097/226] Fixed the names and err out and err in . --- .../src/db/mongo/modules/monitorModuleQueries.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/server/src/db/mongo/modules/monitorModuleQueries.js b/server/src/db/mongo/modules/monitorModuleQueries.js index 56c9a794b..df15593e2 100755 --- a/server/src/db/mongo/modules/monitorModuleQueries.js +++ b/server/src/db/mongo/modules/monitorModuleQueries.js @@ -382,7 +382,16 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { as: "netIndex", in: { name: { - $concat: ["net", { $toString: "$$netIndex" }], + $arrayElemAt: [ + { + $map: { + input: "$net", + as: "netArray", + in: { $arrayElemAt: ["$$netArray.name", "$$netIndex"] }, + }, + }, + 0, + ], }, bytesSent: { $avg: { @@ -434,7 +443,7 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { input: "$net", as: "netArray", in: { - $arrayElemAt: ["$$netArray.errin", "$$netIndex"], + $arrayElemAt: ["$$netArray.err_in", "$$netIndex"], }, }, }, @@ -445,7 +454,7 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { input: "$net", as: "netArray", in: { - $arrayElemAt: ["$$netArray.errout", "$$netIndex"], + $arrayElemAt: ["$$netArray.err_out", "$$netIndex"], }, }, }, From 1a5f16c4e32268bcf3ba44d1d1a733c01ee044f8 Mon Sep 17 00:00:00 2001 From: karenvicent Date: Mon, 28 Jul 2025 16:28:44 -0400 Subject: [PATCH 098/226] improves in light mode --- client/src/Components/Check/Check.jsx | 7 ++++--- client/src/Components/ConfigBox/index.jsx | 5 +++++ .../Fallback/FallbackBackground.jsx | 15 +++++++++++++-- .../Components/Fallback/FallbackContainer.jsx | 4 ++-- .../src/Components/Fallback/FallbackTitle.jsx | 2 +- .../src/Pages/Infrastructure/Create/index.jsx | 9 +++++++-- .../src/Pages/Notifications/create/index.jsx | 17 ++++++++++++++--- client/src/Pages/Settings/SettingsURL.jsx | 7 ++++++- client/src/Pages/Uptime/Create/index.jsx | 7 ++++++- client/src/Utils/Theme/constants.js | 14 +++++++++++++- client/src/Utils/Theme/globalTheme.js | 18 +++++++++++++++++- client/src/assets/Animations/output.gif | Bin 0 -> 189144 bytes 12 files changed, 88 insertions(+), 17 deletions(-) create mode 100644 client/src/assets/Animations/output.gif diff --git a/client/src/Components/Check/Check.jsx b/client/src/Components/Check/Check.jsx index 557414146..18d75b4cf 100644 --- a/client/src/Components/Check/Check.jsx +++ b/client/src/Components/Check/Check.jsx @@ -51,10 +51,11 @@ const Check = ({ text, noHighlightText, variant = "info", outlined = false }) => sx={{ color: variant === "info" - ? theme.palette.primary.contrastTextTertiary + ? theme.palette.primary.contrastTextSecondary : colors[variant], - opacity: 0.8, - }} + opacity: 0.9, + fontWeight: 450, + }} > {noHighlightText && {noHighlightText}}{" "} {text} diff --git a/client/src/Components/ConfigBox/index.jsx b/client/src/Components/ConfigBox/index.jsx index e26e49e8d..0586234af 100644 --- a/client/src/Components/ConfigBox/index.jsx +++ b/client/src/Components/ConfigBox/index.jsx @@ -20,6 +20,11 @@ const ConfigBox = styled(Stack)(({ theme }) => ({ borderRightColor: theme.palette.primary.lowContrast, paddingRight: theme.spacing(15), paddingLeft: theme.spacing(15), + backgroundColor: theme.palette.container.background, + "& :is(h1, h2):first-of-type": { + fontWeight: 600, + marginBottom: theme.spacing(4), + }, }, "& > div:last-of-type": { flex: 1, diff --git a/client/src/Components/Fallback/FallbackBackground.jsx b/client/src/Components/Fallback/FallbackBackground.jsx index e01659f49..29214d6bb 100644 --- a/client/src/Components/Fallback/FallbackBackground.jsx +++ b/client/src/Components/Fallback/FallbackBackground.jsx @@ -1,8 +1,8 @@ import { useTheme } from "@emotion/react"; import Box from "@mui/material/Box"; -import Skeleton from "../../assets/Images/create-placeholder.svg?react"; import Background from "../../assets/Images/background-grid.svg?react"; import SkeletonDark from "../../assets/Images/create-placeholder-dark.svg?react"; +import OutputAnimation from "../../assets/Animations/output.gif"; import { useSelector } from "react-redux"; const FallbackBackground = () => { const theme = useTheme(); @@ -10,7 +10,18 @@ const FallbackBackground = () => { return ( <> {mode === "light" ? ( - + ) : ( )} diff --git a/client/src/Components/Fallback/FallbackContainer.jsx b/client/src/Components/Fallback/FallbackContainer.jsx index 65d927190..12711701b 100644 --- a/client/src/Components/Fallback/FallbackContainer.jsx +++ b/client/src/Components/Fallback/FallbackContainer.jsx @@ -7,9 +7,9 @@ const FallbackContainer = ({ children, type }) => { return ( { alignSelf="center" component="h1" marginY={theme.spacing(4)} - color={theme.palette.primary.contrastTextTertiary} + color={theme.palette.primary.contrastText} > {title} diff --git a/client/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx index 0800bb500..c406b6b40 100644 --- a/client/src/Pages/Infrastructure/Create/index.jsx +++ b/client/src/Pages/Infrastructure/Create/index.jsx @@ -265,7 +265,7 @@ const CreateInfrastructureMonitor = () => { - + { - {t("notificationConfig.title")} + + {t("notificationConfig.title")} + {t("notificationConfig.description")} { > - + {t("createNotifications.nameSettings.title")} @@ -191,7 +194,10 @@ const CreateNotifications = () => { - + {t("createNotifications.typeSettings.title")} @@ -210,7 +216,12 @@ const CreateNotifications = () => { - {t(TITLE_MAP[type])} + + {t(TITLE_MAP[type])} + {t(DESCRIPTION_MAP[type])} diff --git a/client/src/Pages/Settings/SettingsURL.jsx b/client/src/Pages/Settings/SettingsURL.jsx index 298dfa821..1ce2e8c73 100644 --- a/client/src/Pages/Settings/SettingsURL.jsx +++ b/client/src/Pages/Settings/SettingsURL.jsx @@ -15,7 +15,12 @@ const SettingsURL = ({ HEADING_SX, handleChange, showURL = false }) => { return ( - {t("settingsPage.urlSettings.title")} + + {t("settingsPage.urlSettings.title")} + {t("settingsPage.urlSettings.description")} diff --git a/client/src/Pages/Uptime/Create/index.jsx b/client/src/Pages/Uptime/Create/index.jsx index 761fd6346..2416d2120 100644 --- a/client/src/Pages/Uptime/Create/index.jsx +++ b/client/src/Pages/Uptime/Create/index.jsx @@ -357,7 +357,12 @@ const CreateMonitor = () => { - {t("notificationConfig.title")} + + {t("notificationConfig.title")} + {t("notificationConfig.description")} ({ styleOverrides: { root: ({ theme }) => ({ variants: [ + { + props: (props) => props.variant === "contained" && props.color === "accent", + style: { + backgroundColor: theme.palette.accent.main, + color: theme.palette.primary.contrastTextSecondaryDarkBg, + fontWeight: 700, + letterSpacing: "0.5px", + textShadow: "0 0 1px rgba(0, 0, 0, 0.15)", + "&:hover": { + backgroundColor: darken(theme.palette.accent.darker, 0.05), + boxShadow: `0 2px 6px rgba(0, 0, 0, 0.1)`, + transition: "all 0.2s ease-in-out", + }, + }, + }, { props: (props) => props.color === "accent", style: { @@ -566,7 +582,7 @@ const baseTheme = (palette) => ({ root: ({ theme }) => ({ ml: "auto", "& .MuiButtonBase-root, & .MuiButtonBase-root:hover": { - borderColor: theme.palette.primary.contrastBorder, + borderColor: theme.palette.primary.lowContrast, width: "auto", whiteSpace: "nowrap", }, diff --git a/client/src/assets/Animations/output.gif b/client/src/assets/Animations/output.gif new file mode 100644 index 0000000000000000000000000000000000000000..c7823227ad5cd0edc4283138f8fb282417fd3e20 GIT binary patch literal 189144 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
    Q8!}i(91D$>=TI=$m#?LKwd0(ia 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

    I+AI6(BWI1 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�j(Peng(qUv>^LVzMS+Lv6aj|BLZn&M#xT4cGzh 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 literal 0 HcmV?d00001 From 6025323c4b944b48c440def741e5b9e4829835cf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 28 Jul 2025 13:48:09 -0700 Subject: [PATCH 099/226] less aggressive auth rate limiting --- server/src/middleware/rateLimiter.js | 2 +- .../service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/middleware/rateLimiter.js b/server/src/middleware/rateLimiter.js index 2e5a34f3b..70b080d23 100644 --- a/server/src/middleware/rateLimiter.js +++ b/server/src/middleware/rateLimiter.js @@ -10,7 +10,7 @@ export const generalApiLimiter = rateLimit({ export const authApiLimiter = rateLimit({ window: 60 * 1000, - limit: 5, + limit: 15, standardHeaders: true, legacyHeaders: false, ipv6Subnet: 64, diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js index e9021bf97..70ad97f1c 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js @@ -28,7 +28,7 @@ class SuperSimpleQueue { // storeType: "redis", logLevel: "debug", debug: true, - dbUri: this.appSettings.dbConnectionString, + // dbUri: this.appSettings.dbConnectionString, }); this.scheduler.start(); From 56ea28aa8373d48672803b2c23fa397675a7c26c Mon Sep 17 00:00:00 2001 From: karenvicent Date: Mon, 28 Jul 2025 17:05:22 -0400 Subject: [PATCH 100/226] run format --- client/src/Components/Check/Check.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/Components/Check/Check.jsx b/client/src/Components/Check/Check.jsx index 18d75b4cf..7f37a4dc1 100644 --- a/client/src/Components/Check/Check.jsx +++ b/client/src/Components/Check/Check.jsx @@ -53,9 +53,9 @@ const Check = ({ text, noHighlightText, variant = "info", outlined = false }) => variant === "info" ? theme.palette.primary.contrastTextSecondary : colors[variant], - opacity: 0.9, - fontWeight: 450, - }} + opacity: 0.9, + fontWeight: 450, + }} > {noHighlightText && {noHighlightText}}{" "} {text} From 850510b7e8b79f52e53f8c9663e7ee008d53001b Mon Sep 17 00:00:00 2001 From: "Gorkem Cetin (BWL)" <167266851+gorkem-bwl@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:30:33 -0400 Subject: [PATCH 101/226] Update README.md --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d584d832d..2dfcc2ffb 100644 --- a/README.md +++ b/README.md @@ -94,14 +94,16 @@ Feel free to ask questions or share your ideas - we'd love to hear from you! - E-mail, Webhooks, Discord, Telegram, Slack notifications - Scheduled maintenance - JSON query monitoring -- Support for multiple languages +- Multi-language support for English, German, Japanese, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, Chinese (Traditional, Taiwan) -**Short term roadmap:** ([Milestone 2.2](https://github.com/bluewave-labs/Checkmate/milestone/8)) +**Short term roadmap:** - Better notifications - Network monitoring - ..and a few more features +If you would like to sponsor an additional feature, [see this page](https://checkmate.so/sponsored-features). + ## Screenshots

    From f3e50120eec6979934a02ab42b2f265e7e5be74e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 28 Jul 2025 14:37:18 -0700 Subject: [PATCH 102/226] appSettings -> envSettings --- server/index.js | 55 --- server/src/app.js | 4 +- server/src/config/services.js | 12 +- server/src/db/mongo/MongoDB.js | 8 +- server/src/index.js | 8 +- server/src/service/data/redisService.js | 64 ---- .../infrastructure/JobQueue/JobQueue.js | 321 ------------------ .../infrastructure/JobQueue/JobQueueHelper.js | 308 ----------------- .../infrastructure/PulseQueue/PulseQueue.js | 213 ------------ .../PulseQueue/PulseQueueHelper.js | 104 ------ .../SuperSimpleQueue/SuperSimpleQueue.js | 10 +- .../service/infrastructure/bufferService.js | 8 +- server/src/service/system/settingsService.js | 25 +- 13 files changed, 27 insertions(+), 1113 deletions(-) delete mode 100644 server/index.js delete mode 100644 server/src/service/data/redisService.js delete mode 100644 server/src/service/infrastructure/JobQueue/JobQueue.js delete mode 100644 server/src/service/infrastructure/JobQueue/JobQueueHelper.js delete mode 100644 server/src/service/infrastructure/PulseQueue/PulseQueue.js delete mode 100644 server/src/service/infrastructure/PulseQueue/PulseQueueHelper.js diff --git a/server/index.js b/server/index.js deleted file mode 100644 index 5c835b9f6..000000000 --- a/server/index.js +++ /dev/null @@ -1,55 +0,0 @@ -import { initializeServices } from "./src/config/services.js"; -import { initializeControllers } from "./src/config/controllers.js"; -import { createApp } from "./src/app.js"; -import { initShutdownListener } from "./shutdown.js"; -import logger from "./utils/logger.js"; -import { fileURLToPath } from "url"; -import path from "path"; -import fs from "fs"; - -import SettingsService from "./src/service/system/settingsService.js"; -import AppSettings from "./src/db/models/AppSettings.js"; - -const SERVICE_NAME = "Server"; - -const startApp = async () => { - // FE path - const __filename = fileURLToPath(import.meta.url); - const __dirname = path.dirname(__filename); - const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "openapi.json"), "utf8")); - const frontendPath = path.join(__dirname, "public"); - // Create services - const settingsService = new SettingsService(AppSettings); - const appSettings = settingsService.loadSettings(); - - // Initialize services - const services = await initializeServices(appSettings, settingsService); - - // Initialize controllers - const controllers = initializeControllers(services); - - const app = createApp({ - services, - controllers, - appSettings, - frontendPath, - openApiSpec, - }); - - const port = appSettings.port || 52345; - const server = app.listen(port, () => { - logger.info({ message: `Server started on port:${port}` }); - }); - - initShutdownListener(server, services); -}; - -startApp().catch((error) => { - logger.error({ - message: error.message, - service: SERVICE_NAME, - method: "startApp", - stack: error.stack, - }); - process.exit(1); -}); diff --git a/server/src/app.js b/server/src/app.js index 04166b313..6ba1d0d05 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -10,8 +10,8 @@ import { handleErrors } from "./middleware/handleErrors.js"; import { setupRoutes } from "./config/routes.js"; import { generalApiLimiter } from "./middleware/rateLimiter.js"; -export const createApp = ({ services, controllers, appSettings, frontendPath, openApiSpec }) => { - const allowedOrigin = appSettings.clientHost; +export const createApp = ({ services, controllers, envSettings, frontendPath, openApiSpec }) => { + const allowedOrigin = envSettings.clientHost; const app = express(); app.use(generalApiLimiter); diff --git a/server/src/config/services.js b/server/src/config/services.js index 75936c29e..84f9ddca9 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -9,7 +9,6 @@ import BufferService from "../service/infrastructure/bufferService.js"; import StatusService from "../service/infrastructure/statusService.js"; import NotificationUtils from "../service/infrastructure/notificationUtils.js"; import NotificationService from "../service/infrastructure/notificationService.js"; -import RedisService from "../service/data/redisService.js"; import ErrorService from "../service/infrastructure/errorService.js"; import SuperSimpleQueueHelper from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import SuperSimpleQueue from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; @@ -19,7 +18,6 @@ import DiagnosticService from "../service/business/diagnosticService.js"; import InviteService from "../service/business/inviteService.js"; import MaintenanceWindowService from "../service/business/maintenanceWindowService.js"; import MonitorService from "../service/business/monitorService.js"; -import IORedis from "ioredis"; import papaparse from "papaparse"; import axios from "axios"; import ping from "ping"; @@ -35,19 +33,19 @@ import mjml2html from "mjml"; import jwt from "jsonwebtoken"; import crypto from "crypto"; -export const initializeServices = async (appSettings, settingsService) => { +export const initializeServices = async (envSettings, settingsService) => { const translationService = new TranslationService(logger); await translationService.initialize(); const stringService = new StringService(translationService); // Create DB - const db = new MongoDB({ appSettings }); + const db = new MongoDB({ envSettings }); await db.connect(); const networkService = new NetworkService(axios, ping, logger, http, Docker, net, stringService, settingsService); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); - const bufferService = new BufferService({ db, logger }); + const bufferService = new BufferService({ db, logger, envSettings }); const statusService = new StatusService({ db, logger, buffer: bufferService }); const notificationUtils = new NotificationUtils({ @@ -64,7 +62,6 @@ export const initializeServices = async (appSettings, settingsService) => { notificationUtils, }); - const redisService = new RedisService({ Redis: IORedis, logger }); const errorService = new ErrorService(); const superSimpleQueueHelper = new SuperSimpleQueueHelper({ @@ -76,7 +73,7 @@ export const initializeServices = async (appSettings, settingsService) => { }); const superSimpleQueue = await SuperSimpleQueue.create({ - appSettings, + envSettings, db, logger, helper: superSimpleQueueHelper, @@ -135,7 +132,6 @@ export const initializeServices = async (appSettings, settingsService) => { bufferService, statusService, notificationService, - redisService, jobQueue: superSimpleQueue, userService, checkService, diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index e063214fd..1f08facc4 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -70,8 +70,8 @@ import * as diagnosticModule from "./modules/diagnosticModule.js"; class MongoDB { static SERVICE_NAME = "MongoDB"; - constructor({ appSettings }) { - this.appSettings = appSettings; + constructor({ envSettings }) { + this.envSettings = envSettings; Object.assign(this, userModule); Object.assign(this, inviteModule); Object.assign(this, recoveryModule); @@ -92,9 +92,9 @@ class MongoDB { connect = async () => { try { - const connectionString = this.appSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db"; + const connectionString = this.envSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db"; await mongoose.connect(connectionString); - // If there are no AppSettings, create one + // If there are no AppSettings, create one // TODO why is this here? await AppSettings.findOneAndUpdate( {}, // empty filter to match any document {}, // empty update diff --git a/server/src/index.js b/server/src/index.js index a34a517ca..87c140fac 100755 --- a/server/src/index.js +++ b/server/src/index.js @@ -20,10 +20,10 @@ const startApp = async () => { const frontendPath = path.join(__dirname, "public"); // Create services const settingsService = new SettingsService(AppSettings); - const appSettings = settingsService.loadSettings(); + const envSettings = settingsService.loadSettings(); // Initialize services - const services = await initializeServices(appSettings, settingsService); + const services = await initializeServices(envSettings, settingsService); // Initialize controllers const controllers = initializeControllers(services); @@ -31,12 +31,12 @@ const startApp = async () => { const app = createApp({ services, controllers, - appSettings, + envSettings, frontendPath, openApiSpec, }); - const port = appSettings.port || 52345; + const port = envSettings.port || 52345; const server = app.listen(port, () => { logger.info({ message: `Server started on port:${port}` }); }); diff --git a/server/src/service/data/redisService.js b/server/src/service/data/redisService.js deleted file mode 100644 index 3bcfe73b8..000000000 --- a/server/src/service/data/redisService.js +++ /dev/null @@ -1,64 +0,0 @@ -const SERVICE_NAME = "RedisService"; - -class RedisService { - static SERVICE_NAME = SERVICE_NAME; - - constructor({ Redis, logger }) { - this.Redis = Redis; - this.connections = new Set(); - this.logger = logger; - } - - get serviceName() { - return RedisService.SERVICE_NAME; - } - - getNewConnection(options = {}) { - const connection = new this.Redis(process.env.REDIS_URL, { - retryStrategy: (times) => { - return null; - }, - ...options, - }); - this.connections.add(connection); - return connection; - } - - async closeAllConnections() { - const closePromises = Array.from(this.connections).map((conn) => - conn.quit().catch((err) => { - this.logger.error({ - message: "Error closing Redis connection", - service: SERVICE_NAME, - method: "closeAllConnections", - details: { error: err }, - }); - }) - ); - - await Promise.all(closePromises); - this.connections.clear(); - this.logger.info({ - message: "All Redis connections closed", - service: SERVICE_NAME, - method: "closeAllConnections", - }); - } - - async flushRedis() { - this.logger.info({ - message: "Flushing Redis", - service: SERVICE_NAME, - method: "flushRedis", - }); - const flushPromises = Array.from(this.connections).map((conn) => conn.flushall()); - await Promise.all(flushPromises); - this.logger.info({ - message: "Redis flushed", - service: SERVICE_NAME, - method: "flushRedis", - }); - } -} - -export default RedisService; diff --git a/server/src/service/infrastructure/JobQueue/JobQueue.js b/server/src/service/infrastructure/JobQueue/JobQueue.js deleted file mode 100644 index 88f0a3cac..000000000 --- a/server/src/service/infrastructure/JobQueue/JobQueue.js +++ /dev/null @@ -1,321 +0,0 @@ -const QUEUE_NAMES = ["uptime", "pagespeed", "hardware"]; -const SERVICE_NAME = "JobQueue"; -const HEALTH_CHECK_INTERVAL = 10 * 60 * 1000; // 10 minutes -const QUEUE_LOOKUP = { - hardware: "hardware", - http: "uptime", - ping: "uptime", - port: "uptime", - docker: "uptime", - pagespeed: "pagespeed", -}; -const getSchedulerId = (monitor) => `scheduler:${monitor.type}:${monitor._id}`; - -class JobQueue { - static SERVICE_NAME = SERVICE_NAME; - - constructor({ db, jobQueueHelper, logger, stringService }) { - this.db = db; - this.jobQueueHelper = jobQueueHelper; - this.stringService = stringService; - this.logger = logger; - this.queues = {}; - this.workers = []; - } - - get serviceName() { - return JobQueue.SERVICE_NAME; - } - - static async create({ db, jobQueueHelper, logger, stringService }) { - const instance = new JobQueue({ db, jobQueueHelper, logger, stringService }); - await instance.init(); - return instance; - } - - async init() { - try { - await this.initQueues(); - await this.initWorkers(); - const monitors = await this.db.getAllMonitors(); - await Promise.all( - monitors - .filter((monitor) => monitor.isActive) - .map(async (monitor) => { - try { - await this.addJob(monitor._id, monitor); - } catch (error) { - this.logger.error({ - message: error.message, - service: SERVICE_NAME, - method: "initJobQueue", - stack: error.stack, - }); - } - }) - ); - this.healthCheckInterval = setInterval(async () => { - try { - const queueHealthChecks = await this.checkQueueHealth(); - const queueIsStuck = queueHealthChecks.some((healthCheck) => healthCheck.stuck); - if (queueIsStuck) { - this.logger.warn({ - message: "Queue is stuck", - service: SERVICE_NAME, - method: "periodicHealthCheck", - details: queueHealthChecks, - }); - await this.flushQueues(); - } - } catch (error) { - this.logger.error({ - message: error.message, - service: SERVICE_NAME, - method: "periodicHealthCheck", - stack: error.stack, - }); - } - }, HEALTH_CHECK_INTERVAL); - } catch (error) { - this.logger.error({ - message: error.message, - service: SERVICE_NAME, - method: "initJobQueue", - stack: error.stack, - }); - } - } - - async initQueues() { - const readyPromises = []; - - for (const queueName of QUEUE_NAMES) { - const q = this.jobQueueHelper.createQueue(queueName); - this.queues[queueName] = q; - readyPromises.push(q.waitUntilReady()); - } - await Promise.all(readyPromises); - this.logger.info({ - message: "Queues ready", - service: SERVICE_NAME, - method: "initQueues", - }); - } - - async initWorkers() { - const workerReadyPromises = []; - - for (const queueName of QUEUE_NAMES) { - const worker = this.jobQueueHelper.createWorker(queueName, this.queues[queueName]); - this.workers.push(worker); - workerReadyPromises.push(worker.waitUntilReady()); - } - await Promise.all(workerReadyPromises); - this.logger.info({ - message: "Workers ready", - service: SERVICE_NAME, - method: "initWorkers", - }); - } - - pauseJob = async (monitor) => { - this.deleteJob(monitor); - }; - - resumeJob = async (monitor) => { - this.addJob(monitor._id, monitor); - }; - - async addJob(jobName, monitor) { - this.logger.info({ - message: `Adding job ${monitor?.url ?? "No URL"}`, - service: SERVICE_NAME, - method: "addJob", - }); - - const queueName = QUEUE_LOOKUP[monitor.type]; - const queue = this.queues[queueName]; - if (typeof queue === "undefined") { - throw new Error(`Queue for ${monitor.type} not found`); - } - const jobTemplate = { - name: jobName, - data: monitor, - opts: { - attempts: 1, - backoff: { - type: "exponential", - delay: 1000, - }, - removeOnComplete: true, - removeOnFail: false, - timeout: 1 * 60 * 1000, - }, - }; - - const schedulerId = getSchedulerId(monitor); - await queue.upsertJobScheduler(schedulerId, { every: monitor?.interval ?? 60000 }, jobTemplate); - } - - async deleteJob(monitor) { - try { - const queue = this.queues[QUEUE_LOOKUP[monitor.type]]; - const schedulerId = getSchedulerId(monitor); - const wasDeleted = await queue.removeJobScheduler(schedulerId); - - if (wasDeleted === true) { - this.logger.info({ - message: this.stringService.jobQueueDeleteJob, - service: SERVICE_NAME, - method: "deleteJob", - details: `Deleted job ${monitor._id}`, - }); - return true; - } else { - this.logger.error({ - message: this.stringService.jobQueueDeleteJob, - service: SERVICE_NAME, - method: "deleteJob", - details: `Failed to delete job ${monitor._id}`, - }); - return false; - } - } catch (error) { - error.service === undefined ? (error.service = SERVICE_NAME) : null; - error.method === undefined ? (error.method = "deleteJob") : null; - throw error; - } - } - - async updateJob(monitor) { - await this.deleteJob(monitor); - await this.addJob(monitor._id, monitor); - } - - async getJobs() { - try { - let stats = {}; - await Promise.all( - QUEUE_NAMES.map(async (name) => { - const queue = this.queues[name]; - const jobs = await queue.getJobs(); - const ret = await Promise.all( - jobs.map(async (job) => { - const state = await job.getState(); - return { url: job.data.url, state, progress: job.progress }; - }) - ); - stats[name] = { jobs: ret }; - }) - ); - return stats; - } catch (error) { - error.service === undefined ? (error.service = SERVICE_NAME) : null; - error.method === undefined ? (error.method = "getJobStats") : null; - throw error; - } - } - - async getMetrics() { - try { - let metrics = {}; - - await Promise.all( - QUEUE_NAMES.map(async (name) => { - const queue = this.queues[name]; - const [waiting, active, failed, delayed, repeatableJobs] = await Promise.all([ - queue.getWaitingCount(), - queue.getActiveCount(), - queue.getFailedCount(), - queue.getDelayedCount(), - queue.getRepeatableJobs(), - ]); - - metrics[name] = { - waiting, - active, - failed, - delayed, - repeatableJobs: repeatableJobs.length, - }; - }) - ); - - return metrics; - } catch (error) { - this.logger.error({ - message: error.message, - service: SERVICE_NAME, - method: "getMetrics", - stack: error.stack, - }); - } - } - - async checkQueueHealth() { - const res = []; - for (const queueName of QUEUE_NAMES) { - const q = this.queues[queueName]; - await q.waitUntilReady(); - - const lastJobProcessedTime = q.lastJobProcessedTime; - const currentTime = Date.now(); - const timeDiff = currentTime - lastJobProcessedTime; - - // Check for jobs - const jobCounts = await q.getJobCounts(); - const hasJobs = Object.values(jobCounts).some((count) => count > 0); - - res.push({ - queueName, - timeSinceLastJob: timeDiff, - stuck: hasJobs && timeDiff > 10000, - jobCounts, - }); - } - return res; - } - - async flushQueues() { - try { - this.logger.warn({ - message: "Flushing queues", - method: "flushQueues", - service: SERVICE_NAME, - }); - for (const worker of this.workers) { - await worker.close(); - } - this.workers = []; - - for (const queue of Object.values(this.queues)) { - await queue.obliterate(); - } - this.queue = {}; - await this.init(); - return true; - } catch (error) { - this.logger.warn({ - message: `${error.message} - Flushing redis manually`, - service: SERVICE_NAME, - method: "flushQueues", - }); - return await this.jobQueueHelper.flushRedis(); - } - } - - async shutdown() { - if (this.healthCheckInterval) { - clearInterval(this.healthCheckInterval); - this.healthCheckInterval = null; - } - for (const worker of this.workers) { - await worker.close(); - } - for (const queue of Object.values(this.queues)) { - await queue.obliterate(); - } - } -} - -export default JobQueue; diff --git a/server/src/service/infrastructure/JobQueue/JobQueueHelper.js b/server/src/service/infrastructure/JobQueue/JobQueueHelper.js deleted file mode 100644 index c746edae1..000000000 --- a/server/src/service/infrastructure/JobQueue/JobQueueHelper.js +++ /dev/null @@ -1,308 +0,0 @@ -const SERVICE_NAME = "JobQueueHelper"; - -class JobQueueHelper { - static SERVICE_NAME = SERVICE_NAME; - - constructor({ redisService, Queue, Worker, logger, db, networkService, statusService, notificationService }) { - this.db = db; - this.redisService = redisService; - this.Queue = Queue; - this.Worker = Worker; - this.logger = logger; - this.networkService = networkService; - this.statusService = statusService; - this.notificationService = notificationService; - } - - get serviceName() { - return JobQueueHelper.SERVICE_NAME; - } - - createQueue(queueName) { - const connection = this.redisService.getNewConnection(); - const q = new this.Queue(queueName, { - connection, - }); - q.lastJobProcessedTime = Date.now(); - q.on("cleaned", (jobs, type) => { - this.logger.debug({ - message: `Queue ${queueName} is cleaned with jobs: ${jobs} and type: ${type}`, - service: SERVICE_NAME, - method: "createQueue:cleaned", - }); - }); - q.on("error", (err) => { - this.logger.error({ - message: `Queue ${queueName} is error with msg: ${err}`, - service: SERVICE_NAME, - method: "createQueue:error", - }); - }); - q.on("ioredis:close", () => { - this.logger.debug({ - message: `Queue ${queueName} is ioredis:close`, - service: SERVICE_NAME, - method: "createQueue:ioredis:close", - }); - }); - q.on("paused", () => { - this.logger.debug({ - message: `Queue ${queueName} is paused`, - service: SERVICE_NAME, - method: "createQueue:paused", - }); - }); - q.on("progress", (job, progress) => { - this.logger.debug({ - message: `Queue ${queueName} is progress with msg: ${progress}`, - service: SERVICE_NAME, - method: "createQueue:progress", - }); - }); - q.on("removed", (job) => { - this.logger.debug({ - message: `Queue ${queueName} is removed with msg: ${job}`, - service: SERVICE_NAME, - method: "createQueue:removed", - }); - }); - q.on("resumed", () => { - this.logger.debug({ - message: `Queue ${queueName} is resumed`, - service: SERVICE_NAME, - method: "createQueue:resumed", - }); - }); - q.on("waiting", () => { - this.logger.debug({ - message: `Queue ${queueName} is waiting`, - service: SERVICE_NAME, - method: "createQueue:waiting", - }); - }); - return q; - } - - createWorker(queueName, queue) { - const connection = this.redisService.getNewConnection({ - maxRetriesPerRequest: null, - }); - const worker = new this.Worker(queueName, this.createJobHandler(queue), { - connection, - concurrency: 50, - }); - worker.on("active", (job) => { - this.logger.debug({ - message: `Worker ${queueName} is active`, - service: SERVICE_NAME, - method: "createWorker:active", - }); - }); - - worker.on("closed", () => { - this.logger.debug({ - message: `Worker ${queueName} is closed`, - service: SERVICE_NAME, - method: "createWorker:closed", - }); - }); - - worker.on("closing", (msg) => { - this.logger.debug({ - message: `Worker ${queueName} is closing with msg: ${msg}`, - service: SERVICE_NAME, - method: "createWorker:closing", - }); - }); - - worker.on("completed", (job) => { - this.logger.debug({ - message: `Worker ${queueName} is completed`, - service: SERVICE_NAME, - method: "createWorker:completed", - }); - }); - - worker.on("drained", () => { - this.logger.debug({ - message: `Worker ${queueName} is drained`, - service: SERVICE_NAME, - method: "createWorker:drained", - }); - }); - - worker.on("error", (failedReason) => { - this.logger.error({ - message: `Worker ${queueName} is error with msg: ${failedReason}`, - service: SERVICE_NAME, - method: "createWorker:error", - }); - }); - - worker.on("failed", (job, error, prev) => { - this.logger.error({ - message: `Worker ${queueName} is failed with msg: ${error.message}`, - service: error?.service ?? SERVICE_NAME, - method: error?.method ?? "createWorker:failed", - stack: error?.stack, - }); - }); - - worker.on("ioredis:close", () => { - this.logger.debug({ - message: `Worker ${queueName} is ioredis:close`, - service: SERVICE_NAME, - method: "createWorker:ioredis:close", - }); - }); - - worker.on("paused", () => { - this.logger.debug({ - message: `Worker ${queueName} is paused`, - service: SERVICE_NAME, - method: "createWorker:paused", - }); - }); - - worker.on("progress", (job, progress) => { - this.logger.debug({ - message: `Worker ${queueName} is progress with msg: ${progress}`, - service: SERVICE_NAME, - method: "createWorker:progress", - }); - }); - - worker.on("ready", () => { - this.logger.debug({ - message: `Worker ${queueName} is ready`, - service: SERVICE_NAME, - method: "createWorker:ready", - }); - }); - - worker.on("resumed", () => { - this.logger.debug({ - message: `Worker ${queueName} is resumed`, - service: SERVICE_NAME, - method: "createWorker:resumed", - }); - }); - - worker.on("stalled", () => { - this.logger.warn({ - message: `Worker ${queueName} is stalled`, - service: SERVICE_NAME, - method: "createWorker:stalled", - }); - }); - return worker; - } - - async isInMaintenanceWindow(monitorId) { - const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(monitorId); - // Check for active maintenance window: - const maintenanceWindowIsActive = maintenanceWindows.reduce((acc, window) => { - if (window.active) { - const start = new Date(window.start); - const end = new Date(window.end); - const now = new Date(); - const repeatInterval = window.repeat || 0; - - // If start is < now and end > now, we're in maintenance - if (start <= now && end >= now) return true; - - // If maintenance window was set in the past with a repeat, - // we need to advance start and end to see if we are in range - - while (start < now && repeatInterval !== 0) { - start.setTime(start.getTime() + repeatInterval); - end.setTime(end.getTime() + repeatInterval); - if (start <= now && end >= now) { - return true; - } - } - return false; - } - return acc; - }, false); - return maintenanceWindowIsActive; - } - - createJobHandler(q) { - return async (job) => { - try { - // Update the last job processed time for this queue - q.lastJobProcessedTime = Date.now(); - // Get all maintenance windows for this monitor - await job.updateProgress(0); - const monitorId = job.data._id; - const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId); - // If a maintenance window is active, we're done - - if (maintenanceWindowActive) { - await job.updateProgress(100); - this.logger.info({ - message: `Monitor ${monitorId} is in maintenance window`, - service: SERVICE_NAME, - method: "createWorker", - }); - return false; - } - - // Get the current status - await job.updateProgress(30); - const monitor = job.data; - const networkResponse = await this.networkService.getStatus(monitor); - - // If the network response is not found, we're done - if (!networkResponse) { - await job.updateProgress(100); - return false; - } - - // Handle status change - await job.updateProgress(60); - const { monitor: updatedMonitor, statusChanged, prevStatus } = await this.statusService.updateStatus(networkResponse); - // Handle notifications - await job.updateProgress(80); - this.notificationService - .handleNotifications({ - ...networkResponse, - monitor: updatedMonitor, - prevStatus, - statusChanged, - }) - .catch((error) => { - this.logger.error({ - message: error.message, - service: SERVICE_NAME, - method: "createJobHandler", - details: `Error sending notifications for job ${job.id}: ${error.message}`, - stack: error.stack, - }); - }); - await job.updateProgress(100); - return true; - } catch (error) { - await job.updateProgress(100); - throw error; - } - }; - } - async flushRedis() { - try { - const connection = this.redisService.getNewConnection(); - const flushResult = await connection.flushall(); - return flushResult; - } catch (error) { - this.logger.warn({ - message: error.message, - service: SERVICE_NAME, - method: "flushRedis", - }); - return false; - } - } -} - -export default JobQueueHelper; diff --git a/server/src/service/infrastructure/PulseQueue/PulseQueue.js b/server/src/service/infrastructure/PulseQueue/PulseQueue.js deleted file mode 100644 index d822b371d..000000000 --- a/server/src/service/infrastructure/PulseQueue/PulseQueue.js +++ /dev/null @@ -1,213 +0,0 @@ -import { Pulse } from "@pulsecron/pulse"; - -const SERVICE_NAME = "JobQueue"; -class PulseQueue { - static SERVICE_NAME = SERVICE_NAME; - - constructor({ appSettings, db, pulseQueueHelper, logger }) { - this.db = db; - this.appSettings = appSettings; - this.pulseQueueHelper = pulseQueueHelper; - this.logger = logger; - } - - get serviceName() { - return PulseQueue.SERVICE_NAME; - } - - static async create({ appSettings, db, pulseQueueHelper, logger }) { - const instance = new PulseQueue({ appSettings, db, pulseQueueHelper, logger }); - await instance.init(); - return instance; - } - - // **************************************** - // Core methods - // **************************************** - init = async () => { - try { - const mongoConnectionString = this.appSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db"; - this.pulse = new Pulse({ db: { address: mongoConnectionString } }); - await this.pulse.start(); - this.pulse.define("monitor-job", this.pulseQueueHelper.getMonitorJob(), {}); - - const monitors = await this.db.getAllMonitors(); - for (const monitor of monitors) { - await this.addJob(monitor._id, monitor); - } - return true; - } catch (error) { - this.logger.error({ - message: "Failed to initialize PulseQueue", - service: SERVICE_NAME, - method: "init", - details: error, - }); - return false; - } - }; - - addJob = async (monitorId, monitor) => { - this.logger.debug({ - message: `Adding job ${monitor?.url ?? "No URL"}`, - service: SERVICE_NAME, - method: "addJob", - }); - const intervalInSeconds = monitor.interval / 1000; - const job = this.pulse.create("monitor-job", { - monitor, - }); - job.unique({ "data.monitor._id": monitor._id }); - job.attrs.jobId = monitorId.toString(); - job.repeatEvery(`${intervalInSeconds} seconds`); - if (monitor.isActive === false) { - job.disable(); - } - await job.save(); - }; - - deleteJob = async (monitor) => { - this.logger.debug({ - message: `Deleting job ${monitor?.url ?? "No URL"}`, - service: SERVICE_NAME, - method: "deleteJob", - }); - await this.pulse.cancel({ - "data.monitor._id": monitor._id, - }); - }; - - pauseJob = async (monitor) => { - const result = await this.pulse.disable({ - "data.monitor._id": monitor._id, - }); - - if (result.length < 1) { - throw new Error("Failed to pause monitor"); - } - - this.logger.debug({ - message: `Paused monitor ${monitor._id}`, - service: SERVICE_NAME, - method: "pauseJob", - }); - }; - - resumeJob = async (monitor) => { - const result = await this.pulse.enable({ - "data.monitor._id": monitor._id, - }); - - if (result.length < 1) { - throw new Error("Failed to resume monitor"); - } - - this.logger.debug({ - message: `Resumed monitor ${monitor._id}`, - service: SERVICE_NAME, - method: "resumeJob", - }); - }; - - updateJob = async (monitor) => { - const jobs = await this.pulse.jobs({ - "data.monitor._id": monitor._id, - }); - - const job = jobs[0]; - if (!job) { - throw new Error("Job not found"); - } - - const intervalInSeconds = monitor.interval / 1000; - job.repeatEvery(`${intervalInSeconds} seconds`); - job.attrs.data.monitor = monitor; - await job.save(); - }; - - shutdown = async () => { - this.logger.info({ - message: "Shutting down JobQueue", - service: SERVICE_NAME, - method: "shutdown", - }); - await this.pulse.stop(); - }; - - // **************************************** - // Diagnostic methods - // **************************************** - - getMetrics = async () => { - const jobs = await this.pulse.jobs(); - const metrics = jobs.reduce( - (acc, job) => { - acc.totalRuns += job.attrs.runCount || 0; - acc.totalFailures += job.attrs.failCount || 0; - acc.jobs++; - if (job.attrs.failCount > 0 && job.attrs.failedAt >= job.attrs.lastFinishedAt) { - acc.failingJobs++; - } - if (job.attrs.lockedAt) { - acc.activeJobs++; - } - if (job.attrs.failCount > 0) { - acc.jobsWithFailures.push({ - monitorId: job.attrs.data.monitor._id, - monitorUrl: job.attrs.data.monitor.url, - monitorType: job.attrs.data.monitor.type, - failedAt: job.attrs.failedAt, - failCount: job.attrs.failCount, - failReason: job.attrs.failReason, - }); - } - return acc; - }, - { - jobs: 0, - activeJobs: 0, - failingJobs: 0, - jobsWithFailures: [], - totalRuns: 0, - totalFailures: 0, - } - ); - return metrics; - }; - - getJobs = async () => { - const jobs = await this.pulse.jobs(); - return jobs.map((job) => { - return { - monitorId: job.attrs.data.monitor._id, - monitorUrl: job.attrs.data.monitor.url, - monitorType: job.attrs.data.monitor.type, - active: !job.attrs.disabled, - lockedAt: job.attrs.lockedAt, - runCount: job.attrs.runCount || 0, - failCount: job.attrs.failCount || 0, - failReason: job.attrs.failReason, - lastRunAt: job.attrs.lastRunAt, - lastFinishedAt: job.attrs.lastFinishedAt, - lastRunTook: job.attrs.lockedAt ? null : job.attrs.lastFinishedAt - job.attrs.lastRunAt, - lastFailedAt: job.attrs.failedAt, - }; - }); - }; - - flushQueues = async () => { - const cancelRes = await this.pulse.cancel(); - await this.pulse.stop(); - const initRes = await this.init(); - return { - flushedJobs: cancelRes, - initSuccess: initRes, - }; - }; - - obliterate = async () => { - await this.flushQueues(); - }; -} - -export default PulseQueue; diff --git a/server/src/service/infrastructure/PulseQueue/PulseQueueHelper.js b/server/src/service/infrastructure/PulseQueue/PulseQueueHelper.js deleted file mode 100644 index a5ec34d01..000000000 --- a/server/src/service/infrastructure/PulseQueue/PulseQueueHelper.js +++ /dev/null @@ -1,104 +0,0 @@ -const SERVICE_NAME = "PulseQueueHelper"; - -class PulseQueueHelper { - static SERVICE_NAME = SERVICE_NAME; - - constructor({ db, logger, networkService, statusService, notificationService }) { - this.db = db; - this.logger = logger; - this.networkService = networkService; - this.statusService = statusService; - this.notificationService = notificationService; - } - - get serviceName() { - return PulseQueueHelper.SERVICE_NAME; - } - - getMonitorJob = () => { - return async (job) => { - try { - const monitor = job.attrs.data.monitor; - const monitorId = job.attrs.data.monitor._id; - if (!monitorId) { - throw new Error("No monitor id"); - } - - const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId); - if (maintenanceWindowActive) { - this.logger.info({ - message: `Monitor ${monitorId} is in maintenance window`, - service: SERVICE_NAME, - method: "getMonitorJob", - }); - return; - } - - const networkResponse = await this.networkService.getStatus(monitor); - - if (!networkResponse) { - throw new Error("No network response"); - } - - const { monitor: updatedMonitor, statusChanged, prevStatus } = await this.statusService.updateStatus(networkResponse); - - this.notificationService - .handleNotifications({ - ...networkResponse, - monitor: updatedMonitor, - prevStatus, - statusChanged, - }) - .catch((error) => { - this.logger.error({ - message: error.message, - service: SERVICE_NAME, - method: "getMonitorJob", - details: `Error sending notifications for job ${job.id}: ${error.message}`, - stack: error.stack, - }); - }); - } catch (error) { - this.logger.warn({ - message: error.message, - service: error.service || SERVICE_NAME, - method: error.method || "getMonitorJob", - stack: error.stack, - }); - throw error; - } - }; - }; - - async isInMaintenanceWindow(monitorId) { - const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(monitorId); - // Check for active maintenance window: - const maintenanceWindowIsActive = maintenanceWindows.reduce((acc, window) => { - if (window.active) { - const start = new Date(window.start); - const end = new Date(window.end); - const now = new Date(); - const repeatInterval = window.repeat || 0; - - // If start is < now and end > now, we're in maintenance - if (start <= now && end >= now) return true; - - // If maintenance window was set in the past with a repeat, - // we need to advance start and end to see if we are in range - - while (start < now && repeatInterval !== 0) { - start.setTime(start.getTime() + repeatInterval); - end.setTime(end.getTime() + repeatInterval); - if (start <= now && end >= now) { - return true; - } - } - return false; - } - return acc; - }, false); - return maintenanceWindowIsActive; - } -} - -export default PulseQueueHelper; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js index 70ad97f1c..f5661c9e6 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js @@ -4,8 +4,8 @@ const SERVICE_NAME = "JobQueue"; class SuperSimpleQueue { static SERVICE_NAME = SERVICE_NAME; - constructor({ appSettings, db, logger, helper }) { - this.appSettings = appSettings; + constructor({ envSettings, db, logger, helper }) { + this.envSettings = envSettings; this.db = db; this.logger = logger; this.helper = helper; @@ -15,8 +15,8 @@ class SuperSimpleQueue { return SuperSimpleQueue.SERVICE_NAME; } - static async create({ appSettings, db, logger, helper }) { - const instance = new SuperSimpleQueue({ appSettings, db, logger, helper }); + static async create({ envSettings, db, logger, helper }) { + const instance = new SuperSimpleQueue({ envSettings, db, logger, helper }); await instance.init(); return instance; } @@ -28,7 +28,7 @@ class SuperSimpleQueue { // storeType: "redis", logLevel: "debug", debug: true, - // dbUri: this.appSettings.dbConnectionString, + // dbUri: this.envSettings.dbConnectionString, }); this.scheduler.start(); diff --git a/server/src/service/infrastructure/bufferService.js b/server/src/service/infrastructure/bufferService.js index 439b251f6..d67e917d7 100755 --- a/server/src/service/infrastructure/bufferService.js +++ b/server/src/service/infrastructure/bufferService.js @@ -1,5 +1,4 @@ const SERVICE_NAME = "BufferService"; -const BUFFER_TIMEOUT = process.env.NODE_ENV === "development" ? 5000 : 1000 * 60 * 1; // 1 minute const TYPE_MAP = { http: "checks", ping: "checks", @@ -11,7 +10,8 @@ const TYPE_MAP = { class BufferService { static SERVICE_NAME = SERVICE_NAME; - constructor({ db, logger }) { + constructor({ db, logger, envSettings }) { + this.BUFFER_TIMEOUT = envSettings.nodeEnv === "development" ? 5000 : 1000 * 60 * 1; // 1 minute this.db = db; this.logger = logger; this.SERVICE_NAME = SERVICE_NAME; @@ -28,7 +28,7 @@ class BufferService { this.scheduleNextFlush(); this.logger.info({ - message: `Buffer service initialized, flushing every ${BUFFER_TIMEOUT / 1000}s`, + message: `Buffer service initialized, flushing every ${this.BUFFER_TIMEOUT / 1000}s`, service: this.SERVICE_NAME, method: "constructor", }); @@ -66,7 +66,7 @@ class BufferService { // Schedule the next flush only after the current one completes this.scheduleNextFlush(); } - }, BUFFER_TIMEOUT); + }, this.BUFFER_TIMEOUT); } async flushBuffers() { let items = 0; diff --git a/server/src/service/system/settingsService.js b/server/src/service/system/settingsService.js index 4f5e18abb..1c274d944 100755 --- a/server/src/service/system/settingsService.js +++ b/server/src/service/system/settingsService.js @@ -3,20 +3,9 @@ const SERVICE_NAME = "SettingsService"; const envConfig = { nodeEnv: process.env.NODE_ENV, logLevel: process.env.LOG_LEVEL, - systemEmailHost: process.env.SYSTEM_EMAIL_HOST, - systemEmailPort: process.env.SYSTEM_EMAIL_PORT, - systemEmailUser: process.env.SYSTEM_EMAIL_USER, - systemEmailAddress: process.env.SYSTEM_EMAIL_ADDRESS, - systemEmailPassword: process.env.SYSTEM_EMAIL_PASSWORD, - jwtSecret: process.env.JWT_SECRET, - jwtTTL: process.env.TOKEN_TTL, - clientHost: process.env.CLIENT_HOST, - dbConnectionString: process.env.DB_CONNECTION_STRING, - redisUrl: process.env.REDIS_URL, - callbackUrl: process.env.CALLBACK_URL, - port: process.env.PORT, - pagespeedApiKey: process.env.PAGESPEED_API_KEY, - uprockApiKey: process.env.UPROCK_API_KEY, + clientHost: process.env.CLIENT_HOST, //* + dbConnectionString: process.env.DB_CONNECTION_STRING, //* + port: process.env.PORT, //* }; /** * SettingsService @@ -46,13 +35,7 @@ class SettingsService { loadSettings() { return this.settings; } - /** - * Reload settings by calling loadSettings. - * @returns {Promise} The reloaded settings. - */ - reloadSettings() { - return this.loadSettings(); - } + /** * Get the current settings. * @returns {Object} The current settings. From 592c184ffe17607df6cd18b2743b5da8b921c997 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 28 Jul 2025 14:40:10 -0700 Subject: [PATCH 103/226] add ja --- .github/workflows/poeditor-sync.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/poeditor-sync.yml b/.github/workflows/poeditor-sync.yml index 83dac7f09..a8b330290 100644 --- a/.github/workflows/poeditor-sync.yml +++ b/.github/workflows/poeditor-sync.yml @@ -7,7 +7,7 @@ on: languages: description: "Languages to synchronize (comma separated, e.g.: tr,en,es)" required: false - default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr" + default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja" format: description: "Export format (key_value_json or json)" required: false From 0b7d1abb71314bab1735750f2f42b046473b6d16 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 28 Jul 2025 14:41:24 -0700 Subject: [PATCH 104/226] add ja --- .github/scripts/download-translations.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/scripts/download-translations.js b/.github/scripts/download-translations.js index 8f35be0de..7a8125ab8 100644 --- a/.github/scripts/download-translations.js +++ b/.github/scripts/download-translations.js @@ -6,7 +6,9 @@ import { URLSearchParams } from "url"; // POEditor API information const API_TOKEN = process.env.POEDITOR_API; const PROJECT_ID = process.env.POEDITOR_PROJECT_ID; -const LANGUAGES = (process.env.LANGUAGES || "tr,en").split(","); +const LANGUAGES = ( + process.env.LANGUAGES || "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja" +).split(","); const EXPORT_FORMAT = process.env.EXPORT_FORMAT || "key_value_json"; // POEditor API endpoint From 63b106d8446630d07c3c3283694c7f43430107f2 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 28 Jul 2025 21:42:35 +0000 Subject: [PATCH 105/226] feat: translations updated from POEditor --- client/src/locales/ar.json | 969 ++++++++++++++++++++ client/src/locales/cs.json | 969 ++++++++++++++++++++ client/src/locales/de.json | 1049 ++++++++++++---------- client/src/locales/en.json | 1594 ++++++++++++++++----------------- client/src/locales/es.json | 969 ++++++++++++++++++++ client/src/locales/fi.json | 969 ++++++++++++++++++++ client/src/locales/fr.json | 973 ++++++++++++++++++++ client/src/locales/ja.json | 1393 +++++++++++++++------------- client/src/locales/pt-BR.json | 243 +++-- client/src/locales/ru.json | 153 +++- client/src/locales/tr.json | 293 ++++-- client/src/locales/zh-TW.json | 151 +++- 12 files changed, 7684 insertions(+), 2041 deletions(-) create mode 100644 client/src/locales/ar.json create mode 100644 client/src/locales/cs.json create mode 100644 client/src/locales/es.json create mode 100644 client/src/locales/fi.json create mode 100644 client/src/locales/fr.json diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json new file mode 100644 index 000000000..7188c3711 --- /dev/null +++ b/client/src/locales/ar.json @@ -0,0 +1,969 @@ +{ + "submit": "", + "title": "", + "distributedStatusHeaderText": "", + "distributedStatusSubHeaderText": "", + "settingsDisabled": "", + "settingsSuccessSaved": "", + "settingsFailedToSave": "", + "settingsStatsCleared": "", + "settingsFailedToClearStats": "", + "settingsMonitorsDeleted": "", + "settingsFailedToDeleteMonitors": "", + "starPromptTitle": "", + "starPromptDescription": "", + "https": "", + "http": "", + "monitor": "", + "aboutus": "", + "now": "", + "delete": "", + "configure": "", + "responseTime": "", + "ms": "", + "bar": "", + "area": "", + "country": "", + "city": "", + "response": "", + "monitorStatusUp": "", + "monitorStatusDown": "", + "webhookSendSuccess": "", + "webhookSendError": "", + "webhookUnsupportedPlatform": "", + "distributedRightCategoryTitle": "", + "distributedStatusServerMonitors": "", + "distributedStatusServerMonitorsDescription": "", + "distributedUptimeCreateSelectURL": "", + "distributedUptimeCreateChecks": "", + "distributedUptimeCreateChecksDescription": "", + "distributedUptimeCreateIncidentNotification": "", + "distributedUptimeCreateIncidentDescription": "", + "distributedUptimeCreateAdvancedSettings": "", + "distributedUptimeDetailsNoMonitorHistory": "", + "distributedUptimeDetailsStatusHeaderUptime": "", + "distributedUptimeDetailsStatusHeaderLastUpdate": "", + "notifications": { + "enableNotifications": "", + "testNotification": "", + "addOrEditNotifications": "", + "slack": { + "label": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "", + "webhookRequired": "" + }, + "discord": { + "label": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "", + "webhookRequired": "" + }, + "telegram": { + "label": "", + "description": "", + "tokenLabel": "", + "tokenPlaceholder": "", + "chatIdLabel": "", + "chatIdPlaceholder": "", + "fieldsRequired": "" + }, + "webhook": { + "label": "", + "description": "", + "urlLabel": "", + "urlPlaceholder": "", + "urlRequired": "" + }, + "testNotificationDevelop": "", + "integrationButton": "", + "testSuccess": "", + "testFailed": "", + "unsupportedType": "", + "networkError": "", + "fallback": { + "title": "", + "checks": [""], + "actionButton": "" + }, + "createButton": "", + "createTitle": "", + "create": { + "success": "", + "failed": "" + }, + "fetch": { + "success": "", + "failed": "" + }, + "delete": { + "success": "", + "failed": "" + }, + "edit": { + "success": "", + "failed": "" + }, + "test": { + "success": "", + "failed": "" + } + }, + "testLocale": "", + "add": "", + "monitors": "", + "distributedUptimeStatusCreateStatusPage": "", + "distributedUptimeStatusCreateStatusPageAccess": "", + "distributedUptimeStatusCreateStatusPageReady": "", + "distributedUptimeStatusBasicInfoHeader": "", + "distributedUptimeStatusBasicInfoDescription": "", + "distributedUptimeStatusLogoHeader": "", + "distributedUptimeStatusLogoDescription": "", + "distributedUptimeStatusLogoUploadButton": "", + "distributedUptimeStatusStandardMonitorsHeader": "", + "distributedUptimeStatusStandardMonitorsDescription": "", + "distributedUptimeStatusCreateYour": "", + "distributedUptimeStatusEditYour": "", + "distributedUptimeStatusPublishedLabel": "", + "distributedUptimeStatusCompanyNameLabel": "", + "distributedUptimeStatusPageAddressLabel": "", + "distributedUptimeStatus30Days": "", + "distributedUptimeStatus60Days": "", + "distributedUptimeStatus90Days": "", + "distributedUptimeStatusPageNotSetUp": "", + "distributedUptimeStatusContactAdmin": "", + "distributedUptimeStatusPageNotPublic": "", + "distributedUptimeStatusPageDeleteDialog": "", + "distributedUptimeStatusPageDeleteConfirm": "", + "distributedUptimeStatusPageDeleteDescription": "", + "distributedUptimeStatusDevices": "", + "distributedUptimeStatusUpt": "", + "distributedUptimeStatusUptBurned": "", + "distributedUptimeStatusUptLogo": "", + "incidentsTableNoIncidents": "", + "incidentsTablePaginationLabel": "", + "incidentsTableMonitorName": "", + "incidentsTableStatus": "", + "incidentsTableDateTime": "", + "incidentsTableStatusCode": "", + "incidentsTableMessage": "", + "incidentsOptionsHeader": "", + "incidentsOptionsHeaderFilterBy": "", + "incidentsOptionsHeaderFilterAll": "", + "incidentsOptionsHeaderFilterDown": "", + "incidentsOptionsHeaderFilterCannotResolve": "", + "incidentsOptionsHeaderShow": "", + "incidentsOptionsHeaderLastHour": "", + "incidentsOptionsHeaderLastDay": "", + "incidentsOptionsHeaderLastWeek": "", + "incidentsOptionsPlaceholderAllServers": "", + "infrastructureCreateYour": "", + "infrastructureCreateGeneralSettingsDescription": "", + "infrastructureServerRequirement": "", + "infrastructureCustomizeAlerts": "", + "infrastructureAlertNotificationDescription": "", + "infrastructureCreateMonitor": "", + "infrastructureProtocol": "", + "infrastructureServerUrlLabel": "", + "infrastructureDisplayNameLabel": "", + "infrastructureAuthorizationSecretLabel": "", + "gb": "", + "mb": "", + "mem": "", + "memoryUsage": "", + "cpu": "", + "cpuUsage": "", + "cpuTemperature": "", + "diskUsage": "", + "used": "", + "total": "", + "cores": "", + "frequency": "", + "status": "", + "cpuPhysical": "", + "cpuLogical": "", + "cpuFrequency": "", + "avgCpuTemperature": "", + "memory": "", + "disk": "", + "uptime": "", + "os": "", + "host": "", + "actions": "", + "integrations": "", + "integrationsPrism": "", + "integrationsSlack": "", + "integrationsSlackInfo": "", + "integrationsDiscord": "", + "integrationsDiscordInfo": "", + "integrationsZapier": "", + "integrationsZapierInfo": "", + "commonSave": "", + "createYour": "", + "createMonitor": "", + "pause": "", + "resume": "", + "editing": "", + "url": "", + "access": "", + "timezone": "", + "features": "", + "administrator": "", + "loginHere": "", + "displayName": "", + "urlMonitor": "", + "portToMonitor": "", + "websiteMonitoring": "", + "websiteMonitoringDescription": "", + "pingMonitoring": "", + "pingMonitoringDescription": "", + "dockerContainerMonitoring": "", + "dockerContainerMonitoringDescription": "", + "portMonitoring": "", + "portMonitoringDescription": "", + "createMaintenanceWindow": "", + "createMaintenance": "", + "editMaintenance": "", + "maintenanceWindowName": "", + "friendlyNameInput": "", + "friendlyNamePlaceholder": "", + "maintenanceRepeat": "", + "maintenance": "", + "duration": "", + "addMonitors": "", + "window": "", + "cancel": "", + "message": "", + "low": "", + "high": "", + "statusCode": "", + "date&Time": "", + "type": "", + "statusPageName": "", + "publicURL": "", + "repeat": "", + "edit": "", + "createA": "", + "remove": "", + "maintenanceWindowDescription": "", + "startTime": "", + "timeZoneInfo": "", + "monitorsToApply": "", + "nextWindow": "", + "notFoundButton": "", + "pageSpeedConfigureSettingsDescription": "", + "monitorDisplayName": "", + "whenNewIncident": "", + "notifySMS": "", + "notifyEmails": "", + "seperateEmails": "", + "checkFrequency": "", + "matchMethod": "", + "expectedValue": "", + "deleteDialogTitle": "", + "deleteDialogDescription": "", + "pageSpeedMonitor": "", + "shown": "", + "ago": "", + "companyName": "", + "pageSpeedDetailsPerformanceReport": "", + "pageSpeedDetailsPerformanceReportCalculator": "", + "checkingEvery": "", + "statusPageCreateSettings": "", + "basicInformation": "", + "statusPageCreateBasicInfoDescription": "", + "statusPageCreateSelectTimeZoneDescription": "", + "statusPageCreateAppearanceDescription": "", + "statusPageCreateSettingsCheckboxLabel": "", + "statusPageCreateBasicInfoStatusPageAddress": "", + "statusPageCreateTabsContent": "", + "statusPageCreateTabsContentDescription": "", + "statusPageCreateTabsContentFeaturesDescription": "", + "showCharts": "", + "showUptimePercentage": "", + "removeLogo": "", + "statusPageStatus": "", + "statusPageStatusContactAdmin": "", + "statusPageStatusNotPublic": "", + "statusPageStatusNoPage": "", + "statusPageStatusServiceStatus": "", + "deleteStatusPage": "", + "deleteStatusPageConfirm": "", + "deleteStatusPageDescription": "", + "uptimeCreate": "", + "uptimeCreateJsonPath": "", + "uptimeCreateJsonPathQuery": "", + "maintenanceTableActionMenuDialogTitle": "", + "infrastructureEditYour": "", + "infrastructureEditMonitor": "", + "infrastructureMonitorCreated": "", + "infrastructureMonitorUpdated": "", + "errorInvalidTypeId": "", + "errorInvalidFieldId": "", + "inviteNoTokenFound": "", + "pageSpeedWarning": "", + "pageSpeedLearnMoreLink": "", + "pageSpeedAddApiKey": "", + "update": "", + "invalidFileFormat": "", + "invalidFileSize": "", + "ClickUpload": "", + "DragandDrop": "", + "MaxSize": "", + "SupportedFormats": "", + "FirstName": "", + "LastName": "", + "EmailDescriptionText": "", + "YourPhoto": "", + "PhotoDescriptionText": "", + "save": "", + "DeleteDescriptionText": "", + "DeleteAccountWarning": "", + "DeleteWarningTitle": "", + "bulkImport": { + "title": "", + "selectFileTips": "", + "selectFileDescription": "", + "selectFile": "", + "parsingFailed": "", + "uploadSuccess": "", + "validationFailed": "", + "noFileSelected": "", + "fallbackPage": "", + "invalidFileType": "", + "uploadFailed": "" + }, + "DeleteAccountTitle": "", + "DeleteAccountButton": "", + "publicLink": "", + "maskedPageSpeedKeyPlaceholder": "", + "reset": "", + "ignoreTLSError": "", + "tlsErrorIgnored": "", + "ignoreTLSErrorDescription": "", + "createNew": "", + "greeting": { + "prepend": "", + "append": "", + "overview": "" + }, + "roles": { + "superAdmin": "", + "admin": "", + "teamMember": "", + "demoUser": "" + }, + "teamPanel": { + "teamMembers": "", + "filter": { + "all": "", + "member": "" + }, + "inviteTeamMember": "", + "inviteNewTeamMember": "", + "inviteDescription": "", + "email": "", + "selectRole": "", + "inviteLink": "", + "cancel": "", + "noMembers": "", + "getToken": "", + "emailToken": "", + "table": { + "name": "", + "email": "", + "role": "", + "created": "" + } + }, + "monitorState": { + "paused": "", + "resumed": "", + "active": "" + }, + "menu": { + "uptime": "", + "pagespeed": "", + "infrastructure": "", + "incidents": "", + "statusPages": "", + "maintenance": "", + "integrations": "", + "settings": "", + "support": "", + "discussions": "", + "docs": "", + "changelog": "", + "profile": "", + "password": "", + "team": "", + "logOut": "", + "notifications": "", + "logs": "" + }, + "settingsEmailUser": "", + "state": "", + "statusBreadCrumbsStatusPages": "", + "statusBreadCrumbsDetails": "", + "commonSaving": "", + "navControls": "", + "incidentsPageTitle": "", + "passwordPanel": { + "passwordChangedSuccess": "", + "passwordInputIncorrect": "", + "currentPassword": "", + "enterCurrentPassword": "", + "newPassword": "", + "enterNewPassword": "", + "confirmNewPassword": "", + "passwordRequirements": "", + "saving": "" + }, + "emailSent": "", + "failedToSendEmail": "", + "settingsTestEmailSuccess": "", + "settingsTestEmailFailed": "", + "settingsTestEmailFailedWithReason": "", + "settingsTestEmailUnknownError": "", + "statusMsg": { + "paused": "", + "up": "", + "down": "", + "pending": "" + }, + "uptimeGeneralInstructions": { + "http": "", + "ping": "", + "docker": "", + "port": "" + }, + "common": { + "appName": "", + "monitoringAgentName": "", + "buttons": { + "toggleTheme": "" + }, + "toasts": { + "networkError": "", + "checkConnection": "", + "unknownError": "" + } + }, + "auth": { + "common": { + "navigation": { + "continue": "", + "back": "" + }, + "inputs": { + "email": { + "label": "", + "placeholder": "", + "errors": { + "empty": "", + "invalid": "" + } + }, + "password": { + "label": "", + "rules": { + "length": { + "beginning": "", + "highlighted": "" + }, + "special": { + "beginning": "", + "highlighted": "" + }, + "number": { + "beginning": "", + "highlighted": "" + }, + "uppercase": { + "beginning": "", + "highlighted": "" + }, + "lowercase": { + "beginning": "", + "highlighted": "" + }, + "match": { + "beginning": "", + "highlighted": "" + } + }, + "errors": { + "empty": "", + "length": "", + "uppercase": "", + "lowercase": "", + "number": "", + "special": "", + "incorrect": "" + } + }, + "passwordConfirm": { + "label": "", + "placeholder": "", + "errors": { + "empty": "", + "different": "" + } + }, + "firstName": { + "label": "", + "placeholder": "", + "errors": { + "empty": "", + "length": "", + "pattern": "" + } + }, + "lastName": { + "label": "", + "placeholder": "", + "errors": { + "empty": "", + "length": "", + "pattern": "" + } + } + }, + "errors": { + "validation": "" + }, + "fields": { + "password": { + "errors": { + "incorrect": "" + } + }, + "role": { + "errors": { + "min": "" + } + } + } + }, + "login": { + "heading": "", + "subheadings": { + "stepOne": "", + "stepTwo": "" + }, + "links": { + "forgotPassword": "", + "register": "", + "forgotPasswordLink": "", + "registerLink": "" + }, + "toasts": { + "success": "", + "incorrectPassword": "" + }, + "errors": { + "password": { + "incorrect": "" + } + } + }, + "registration": { + "heading": { + "superAdmin": "", + "user": "" + }, + "subheadings": { + "stepOne": "", + "stepTwo": "", + "stepThree": "" + }, + "description": { + "superAdmin": "", + "user": "" + }, + "gettingStartedButton": { + "superAdmin": "", + "user": "" + }, + "termsAndPolicies": "", + "links": { + "login": "" + }, + "toasts": { + "success": "" + } + }, + "forgotPassword": { + "heading": "", + "subheadings": { + "stepOne": "", + "stepTwo": "", + "stepThree": "", + "stepFour": "" + }, + "buttons": { + "openEmail": "", + "resetPassword": "" + }, + "imageAlts": { + "passwordKey": "", + "email": "", + "lock": "", + "passwordConfirm": "" + }, + "links": { + "login": "", + "resend": "" + }, + "toasts": { + "sent": "", + "emailNotFound": "", + "redirect": "", + "success": "", + "error": "" + } + } + }, + "errorPages": { + "serverUnreachable": { + "toasts": { + "reconnected": "", + "stillUnreachable": "" + }, + "alertBox": "", + "description": "", + "retryButton": { + "default": "", + "processing": "" + } + } + }, + "createNotifications": { + "title": "", + "nameSettings": { + "title": "", + "description": "", + "nameLabel": "", + "namePlaceholder": "" + }, + "typeSettings": { + "title": "", + "description": "", + "typeLabel": "" + }, + "emailSettings": { + "title": "", + "description": "", + "emailLabel": "", + "emailPlaceholder": "" + }, + "slackSettings": { + "title": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "" + }, + "pagerdutySettings": { + "title": "", + "description": "", + "integrationKeyLabel": "", + "integrationKeyPlaceholder": "" + }, + "discordSettings": { + "title": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "" + }, + "webhookSettings": { + "title": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "" + }, + "testNotification": "", + "dialogDeleteTitle": "", + "dialogDeleteConfirm": "" + }, + "notificationConfig": { + "title": "", + "description": "" + }, + "monitorStatus": { + "checkingEvery": "", + "withCaptureAgent": "", + "up": "", + "down": "", + "paused": "" + }, + "advancedMatching": "", + "sendTestNotifications": "", + "selectAll": "", + "showAdminLoginLink": "", + "logsPage": { + "title": "", + "description": "", + "tabs": { + "queue": "", + "logs": "", + "diagnostics": "" + }, + "toast": { + "fetchLogsSuccess": "" + }, + "logLevelSelect": { + "title": "", + "values": { + "all": "", + "info": "", + "warn": "", + "error": "", + "debug": "" + } + } + }, + "queuePage": { + "title": "", + "refreshButton": "", + "flushButton": "", + "jobTable": { + "title": "", + "idHeader": "", + "urlHeader": "", + "typeHeader": "", + "activeHeader": "", + "lockedAtHeader": "", + "runCountHeader": "", + "failCountHeader": "", + "lastRunHeader": "", + "lastFinishedAtHeader": "", + "lastRunTookHeader": "" + }, + "metricsTable": { + "title": "", + "metricHeader": "", + "valueHeader": "" + }, + "failedJobTable": { + "title": "", + "monitorIdHeader": "", + "monitorUrlHeader": "", + "failCountHeader": "", + "failedAtHeader": "", + "failReasonHeader": "" + } + }, + "export": { + "title": "", + "success": "", + "failed": "" + }, + "monitorActions": { + "title": "", + "import": "", + "export": "", + "deleteSuccess": "", + "deleteFailed": "", + "details": "" + }, + "settingsPage": { + "aboutSettings": { + "labelDevelopedBy": "", + "labelVersion": "", + "title": "" + }, + "demoMonitorsSettings": { + "buttonAddMonitors": "", + "description": "", + "title": "" + }, + "emailSettings": { + "buttonSendTestEmail": "", + "description": "", + "descriptionTransport": "", + "labelAddress": "", + "labelConnectionHost": "", + "labelHost": "", + "labelIgnoreTLS": "", + "labelPassword": "", + "labelPasswordSet": "", + "labelPool": "", + "labelPort": "", + "labelRejectUnauthorized": "", + "labelRequireTLS": "", + "labelSecure": "", + "labelTLSServername": "", + "labelUser": "", + "linkTransport": "", + "placeholderUser": "", + "title": "", + "toastEmailRequiredFieldsError": "" + }, + "pageSpeedSettings": { + "description": "", + "labelApiKeySet": "", + "labelApiKey": "", + "title": "" + }, + "saveButtonLabel": "", + "statsSettings": { + "clearAllStatsButton": "", + "clearAllStatsDescription": "", + "clearAllStatsDialogConfirm": "", + "clearAllStatsDialogDescription": "", + "clearAllStatsDialogTitle": "", + "description": "", + "labelTTL": "", + "labelTTLOptional": "", + "title": "" + }, + "systemResetSettings": { + "buttonRemoveAllMonitors": "", + "description": "", + "dialogConfirm": "", + "dialogDescription": "", + "dialogTitle": "", + "title": "" + }, + "timezoneSettings": { + "description": "", + "label": "", + "title": "" + }, + "title": "", + "uiSettings": { + "description": "", + "labelLanguage": "", + "labelTheme": "", + "title": "" + }, + "urlSettings": { + "description": "", + "label": "", + "selectDisabled": "", + "selectEnabled": "", + "title": "" + } + }, + "statusPageCreate": { + "buttonSave": "" + }, + "incidentsOptionsHeaderFilterResolved": "", + "settingsSave": "", + "statusPageCreateAppearanceTitle": "", + "confirmPassword": "", + "monitorHooks": { + "failureAddDemoMonitors": "", + "successAddDemoMonitors": "" + }, + "settingsAppearance": "", + "settingsDisplayTimezone": "", + "settingsGeneralSettings": "", + "incidentsOptionsHeaderTotalIncidents": "", + "statusPage": { + "deleteSuccess": "", + "deleteFailed": "", + "createSuccess": "", + "updateSuccess": "", + "generalSettings": "", + "contents": "", + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "testNotificationsDisabled": "", + "incidentsTableResolvedAt": "", + "incidentsTableActionResolve": "", + "checkHooks": { + "failureResolveOne": "", + "failureResolveAll": "", + "failureResolveMonitor": "" + }, + "checkFormError": "", + "diagnosticsPage": { + "diagnosticDescription": "", + "statsDescription": "", + "gauges": { + "heapAllocationTitle": "", + "heapAllocationSubtitle": "", + "heapUsageTitle": "", + "heapUsageSubtitle": "", + "heapUtilizationTitle": "", + "heapUtilizationSubtitle": "", + "instantCpuUsageTitle": "", + "instantCpuUsageSubtitle": "" + }, + "stats": { + "eventLoopDelayTitle": "", + "uptimeTitle": "", + "usedHeapSizeTitle": "", + "totalHeapSizeTitle": "", + "osMemoryLimitTitle": "" + } + }, + "pageSpeedLighthouseAPI": "", + "time": { + "threeMinutes": "", + "fiveMinutes": "", + "tenMinutes": "", + "twentyMinutes": "", + "oneHour": "", + "oneDay": "", + "oneWeek": "" + }, + "general": { + "noOptionsFound": "" + }, + "infrastructureMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "maintenanceWindow": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "pageSpeed": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "uptimeMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "editUserPage": { + "form": { + "email": "", + "firstName": "", + "lastName": "", + "role": "", + "save": "" + }, + "table": { + "actionHeader": "", + "roleHeader": "" + }, + "title": "", + "toast": { + "successUserUpdate": "", + "validationErrors": "" + } + }, + "incidentsPageActionResolveMonitor": "", + "incidentsPageActionResolveAll": "" +} diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json new file mode 100644 index 000000000..00c513203 --- /dev/null +++ b/client/src/locales/cs.json @@ -0,0 +1,969 @@ +{ + "submit": "Odeslat", + "title": "", + "distributedStatusHeaderText": "", + "distributedStatusSubHeaderText": "", + "settingsDisabled": "Vypnuto", + "settingsSuccessSaved": "Nastavení bylo úspěšně uloženo", + "settingsFailedToSave": "Nepodařilo se uložit nastavení", + "settingsStatsCleared": "", + "settingsFailedToClearStats": "", + "settingsMonitorsDeleted": "", + "settingsFailedToDeleteMonitors": "", + "starPromptTitle": "", + "starPromptDescription": "", + "https": "", + "http": "", + "monitor": "", + "aboutus": "", + "now": "", + "delete": "", + "configure": "", + "responseTime": "", + "ms": "", + "bar": "", + "area": "", + "country": "", + "city": "", + "response": "", + "monitorStatusUp": "", + "monitorStatusDown": "", + "webhookSendSuccess": "", + "webhookSendError": "", + "webhookUnsupportedPlatform": "", + "distributedRightCategoryTitle": "", + "distributedStatusServerMonitors": "", + "distributedStatusServerMonitorsDescription": "", + "distributedUptimeCreateSelectURL": "", + "distributedUptimeCreateChecks": "", + "distributedUptimeCreateChecksDescription": "", + "distributedUptimeCreateIncidentNotification": "", + "distributedUptimeCreateIncidentDescription": "", + "distributedUptimeCreateAdvancedSettings": "", + "distributedUptimeDetailsNoMonitorHistory": "", + "distributedUptimeDetailsStatusHeaderUptime": "", + "distributedUptimeDetailsStatusHeaderLastUpdate": "", + "notifications": { + "enableNotifications": "", + "testNotification": "", + "addOrEditNotifications": "", + "slack": { + "label": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "", + "webhookRequired": "" + }, + "discord": { + "label": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "", + "webhookRequired": "" + }, + "telegram": { + "label": "", + "description": "", + "tokenLabel": "", + "tokenPlaceholder": "", + "chatIdLabel": "", + "chatIdPlaceholder": "", + "fieldsRequired": "" + }, + "webhook": { + "label": "", + "description": "", + "urlLabel": "", + "urlPlaceholder": "", + "urlRequired": "" + }, + "testNotificationDevelop": "", + "integrationButton": "", + "testSuccess": "", + "testFailed": "", + "unsupportedType": "", + "networkError": "", + "fallback": { + "title": "", + "checks": [""], + "actionButton": "" + }, + "createButton": "", + "createTitle": "", + "create": { + "success": "", + "failed": "" + }, + "fetch": { + "success": "", + "failed": "" + }, + "delete": { + "success": "", + "failed": "" + }, + "edit": { + "success": "", + "failed": "" + }, + "test": { + "success": "", + "failed": "" + } + }, + "testLocale": "", + "add": "", + "monitors": "", + "distributedUptimeStatusCreateStatusPage": "", + "distributedUptimeStatusCreateStatusPageAccess": "", + "distributedUptimeStatusCreateStatusPageReady": "", + "distributedUptimeStatusBasicInfoHeader": "", + "distributedUptimeStatusBasicInfoDescription": "", + "distributedUptimeStatusLogoHeader": "", + "distributedUptimeStatusLogoDescription": "", + "distributedUptimeStatusLogoUploadButton": "", + "distributedUptimeStatusStandardMonitorsHeader": "", + "distributedUptimeStatusStandardMonitorsDescription": "", + "distributedUptimeStatusCreateYour": "", + "distributedUptimeStatusEditYour": "", + "distributedUptimeStatusPublishedLabel": "", + "distributedUptimeStatusCompanyNameLabel": "", + "distributedUptimeStatusPageAddressLabel": "", + "distributedUptimeStatus30Days": "", + "distributedUptimeStatus60Days": "", + "distributedUptimeStatus90Days": "", + "distributedUptimeStatusPageNotSetUp": "", + "distributedUptimeStatusContactAdmin": "", + "distributedUptimeStatusPageNotPublic": "", + "distributedUptimeStatusPageDeleteDialog": "", + "distributedUptimeStatusPageDeleteConfirm": "", + "distributedUptimeStatusPageDeleteDescription": "", + "distributedUptimeStatusDevices": "", + "distributedUptimeStatusUpt": "", + "distributedUptimeStatusUptBurned": "", + "distributedUptimeStatusUptLogo": "", + "incidentsTableNoIncidents": "", + "incidentsTablePaginationLabel": "", + "incidentsTableMonitorName": "", + "incidentsTableStatus": "", + "incidentsTableDateTime": "", + "incidentsTableStatusCode": "", + "incidentsTableMessage": "", + "incidentsOptionsHeader": "", + "incidentsOptionsHeaderFilterBy": "", + "incidentsOptionsHeaderFilterAll": "", + "incidentsOptionsHeaderFilterDown": "", + "incidentsOptionsHeaderFilterCannotResolve": "", + "incidentsOptionsHeaderShow": "", + "incidentsOptionsHeaderLastHour": "", + "incidentsOptionsHeaderLastDay": "", + "incidentsOptionsHeaderLastWeek": "", + "incidentsOptionsPlaceholderAllServers": "", + "infrastructureCreateYour": "", + "infrastructureCreateGeneralSettingsDescription": "", + "infrastructureServerRequirement": "", + "infrastructureCustomizeAlerts": "", + "infrastructureAlertNotificationDescription": "", + "infrastructureCreateMonitor": "", + "infrastructureProtocol": "", + "infrastructureServerUrlLabel": "", + "infrastructureDisplayNameLabel": "", + "infrastructureAuthorizationSecretLabel": "", + "gb": "", + "mb": "", + "mem": "", + "memoryUsage": "", + "cpu": "", + "cpuUsage": "", + "cpuTemperature": "", + "diskUsage": "", + "used": "", + "total": "", + "cores": "", + "frequency": "", + "status": "", + "cpuPhysical": "", + "cpuLogical": "", + "cpuFrequency": "", + "avgCpuTemperature": "", + "memory": "", + "disk": "", + "uptime": "", + "os": "", + "host": "", + "actions": "", + "integrations": "", + "integrationsPrism": "", + "integrationsSlack": "", + "integrationsSlackInfo": "", + "integrationsDiscord": "", + "integrationsDiscordInfo": "", + "integrationsZapier": "", + "integrationsZapierInfo": "", + "commonSave": "", + "createYour": "", + "createMonitor": "", + "pause": "", + "resume": "", + "editing": "", + "url": "", + "access": "", + "timezone": "", + "features": "", + "administrator": "", + "loginHere": "", + "displayName": "", + "urlMonitor": "", + "portToMonitor": "", + "websiteMonitoring": "", + "websiteMonitoringDescription": "", + "pingMonitoring": "", + "pingMonitoringDescription": "", + "dockerContainerMonitoring": "", + "dockerContainerMonitoringDescription": "", + "portMonitoring": "", + "portMonitoringDescription": "", + "createMaintenanceWindow": "", + "createMaintenance": "", + "editMaintenance": "", + "maintenanceWindowName": "", + "friendlyNameInput": "", + "friendlyNamePlaceholder": "", + "maintenanceRepeat": "", + "maintenance": "", + "duration": "", + "addMonitors": "", + "window": "", + "cancel": "", + "message": "", + "low": "", + "high": "", + "statusCode": "", + "date&Time": "", + "type": "", + "statusPageName": "", + "publicURL": "", + "repeat": "", + "edit": "", + "createA": "", + "remove": "", + "maintenanceWindowDescription": "", + "startTime": "", + "timeZoneInfo": "", + "monitorsToApply": "", + "nextWindow": "", + "notFoundButton": "", + "pageSpeedConfigureSettingsDescription": "", + "monitorDisplayName": "", + "whenNewIncident": "", + "notifySMS": "", + "notifyEmails": "", + "seperateEmails": "", + "checkFrequency": "", + "matchMethod": "", + "expectedValue": "", + "deleteDialogTitle": "", + "deleteDialogDescription": "", + "pageSpeedMonitor": "", + "shown": "", + "ago": "", + "companyName": "", + "pageSpeedDetailsPerformanceReport": "", + "pageSpeedDetailsPerformanceReportCalculator": "", + "checkingEvery": "", + "statusPageCreateSettings": "", + "basicInformation": "", + "statusPageCreateBasicInfoDescription": "", + "statusPageCreateSelectTimeZoneDescription": "", + "statusPageCreateAppearanceDescription": "", + "statusPageCreateSettingsCheckboxLabel": "", + "statusPageCreateBasicInfoStatusPageAddress": "", + "statusPageCreateTabsContent": "", + "statusPageCreateTabsContentDescription": "", + "statusPageCreateTabsContentFeaturesDescription": "", + "showCharts": "", + "showUptimePercentage": "", + "removeLogo": "", + "statusPageStatus": "", + "statusPageStatusContactAdmin": "", + "statusPageStatusNotPublic": "", + "statusPageStatusNoPage": "", + "statusPageStatusServiceStatus": "", + "deleteStatusPage": "", + "deleteStatusPageConfirm": "", + "deleteStatusPageDescription": "", + "uptimeCreate": "", + "uptimeCreateJsonPath": "", + "uptimeCreateJsonPathQuery": "", + "maintenanceTableActionMenuDialogTitle": "", + "infrastructureEditYour": "", + "infrastructureEditMonitor": "", + "infrastructureMonitorCreated": "", + "infrastructureMonitorUpdated": "", + "errorInvalidTypeId": "", + "errorInvalidFieldId": "", + "inviteNoTokenFound": "", + "pageSpeedWarning": "", + "pageSpeedLearnMoreLink": "", + "pageSpeedAddApiKey": "", + "update": "", + "invalidFileFormat": "", + "invalidFileSize": "", + "ClickUpload": "", + "DragandDrop": "", + "MaxSize": "", + "SupportedFormats": "", + "FirstName": "", + "LastName": "", + "EmailDescriptionText": "", + "YourPhoto": "", + "PhotoDescriptionText": "", + "save": "", + "DeleteDescriptionText": "", + "DeleteAccountWarning": "", + "DeleteWarningTitle": "", + "bulkImport": { + "title": "", + "selectFileTips": "", + "selectFileDescription": "", + "selectFile": "", + "parsingFailed": "", + "uploadSuccess": "", + "validationFailed": "", + "noFileSelected": "", + "fallbackPage": "", + "invalidFileType": "", + "uploadFailed": "" + }, + "DeleteAccountTitle": "", + "DeleteAccountButton": "", + "publicLink": "", + "maskedPageSpeedKeyPlaceholder": "", + "reset": "", + "ignoreTLSError": "", + "tlsErrorIgnored": "", + "ignoreTLSErrorDescription": "", + "createNew": "", + "greeting": { + "prepend": "", + "append": "", + "overview": "" + }, + "roles": { + "superAdmin": "", + "admin": "", + "teamMember": "", + "demoUser": "" + }, + "teamPanel": { + "teamMembers": "", + "filter": { + "all": "", + "member": "" + }, + "inviteTeamMember": "", + "inviteNewTeamMember": "", + "inviteDescription": "", + "email": "", + "selectRole": "", + "inviteLink": "", + "cancel": "", + "noMembers": "", + "getToken": "", + "emailToken": "", + "table": { + "name": "", + "email": "", + "role": "", + "created": "" + } + }, + "monitorState": { + "paused": "", + "resumed": "", + "active": "" + }, + "menu": { + "uptime": "", + "pagespeed": "", + "infrastructure": "", + "incidents": "", + "statusPages": "", + "maintenance": "", + "integrations": "", + "settings": "", + "support": "", + "discussions": "", + "docs": "", + "changelog": "", + "profile": "", + "password": "", + "team": "", + "logOut": "", + "notifications": "", + "logs": "" + }, + "settingsEmailUser": "", + "state": "", + "statusBreadCrumbsStatusPages": "", + "statusBreadCrumbsDetails": "", + "commonSaving": "", + "navControls": "", + "incidentsPageTitle": "", + "passwordPanel": { + "passwordChangedSuccess": "", + "passwordInputIncorrect": "", + "currentPassword": "", + "enterCurrentPassword": "", + "newPassword": "", + "enterNewPassword": "", + "confirmNewPassword": "", + "passwordRequirements": "", + "saving": "" + }, + "emailSent": "", + "failedToSendEmail": "", + "settingsTestEmailSuccess": "", + "settingsTestEmailFailed": "", + "settingsTestEmailFailedWithReason": "", + "settingsTestEmailUnknownError": "", + "statusMsg": { + "paused": "", + "up": "", + "down": "", + "pending": "" + }, + "uptimeGeneralInstructions": { + "http": "", + "ping": "", + "docker": "", + "port": "" + }, + "common": { + "appName": "Checkmate", + "monitoringAgentName": "Capture", + "buttons": { + "toggleTheme": "Přepnout mezi světlým a tmavým motivem" + }, + "toasts": { + "networkError": "Chyba připojení k síti", + "checkConnection": "Zkontrolujte prosím své připojení k síti", + "unknownError": "Nastala neznámá chyba" + } + }, + "auth": { + "common": { + "navigation": { + "continue": "Pokračovat", + "back": "Zpět" + }, + "inputs": { + "email": { + "label": "E-mail", + "placeholder": "jan.novak@domena.cz", + "errors": { + "empty": "Zadejte prosím e-mailovou adresu", + "invalid": "Překontrolujte si prosím správnost zadané e-mailové adresy" + } + }, + "password": { + "label": "Heslo", + "rules": { + "length": { + "beginning": "Heslo musí být dlouhé alespoň", + "highlighted": "8 znaků" + }, + "special": { + "beginning": "Heslo musí obsahovat alespoň", + "highlighted": "1 speciální znak" + }, + "number": { + "beginning": "Heslo musí obsahovat alespoň", + "highlighted": "1 číslo" + }, + "uppercase": { + "beginning": "Heslo musí obsahovat alespoň", + "highlighted": "1 velké písmeno" + }, + "lowercase": { + "beginning": "Heslo musí obsahovat alespoň", + "highlighted": "1 malé písmeno" + }, + "match": { + "beginning": "Obě hesla se", + "highlighted": "musí shodovat" + } + }, + "errors": { + "empty": "Zadejte prosím heslo", + "length": "Heslo musí být dlouhé alespoň 8 znaků", + "uppercase": "Heslo musí obsahovat alespoň 1 velké písmeno", + "lowercase": "Heslo musí obsahovat alespoň 1 malé písmeno", + "number": "Heslo musí obsahovat alespoň 1 číslo", + "special": "Heslo musí obsahovat alespoň 1 speciální znak", + "incorrect": "Zadané heslo neodpovídá tomu, co bylo nastaveno" + } + }, + "passwordConfirm": { + "label": "Potvrzení hesla", + "placeholder": "Pro ověření správnosti zadejte heslo ještě jednou", + "errors": { + "empty": "Zadejte prosím své heslo ještě jednou, aby mohla být potvrzena jeho správnost (odhalí překlepy)", + "different": "Zadaná hesla se liší, takže je nejspíše jedno z nich špatně zapsané" + } + }, + "firstName": { + "label": "Jméno", + "placeholder": "Jan", + "errors": { + "empty": "Zadejte prosím své křestní jméno", + "length": "Jméno se musí vejít do 50 znaků", + "pattern": "Součástí jména mohou být pouze písmena, mezery apostrofy nebo spojovníky" + } + }, + "lastName": { + "label": "Příjmení", + "placeholder": "Novák", + "errors": { + "empty": "Zadejte prosím své příjmení", + "length": "Příjmení se musí vejít do 50 znaků", + "pattern": "Součástí příjmení mohou být pouze písmena, mezery apostrofy nebo spojovníky" + } + } + }, + "errors": { + "validation": "Nastala chyba při ověřování dat." + }, + "fields": { + "password": { + "errors": { + "incorrect": "" + } + }, + "role": { + "errors": { + "min": "" + } + } + } + }, + "login": { + "heading": "Přihlášení", + "subheadings": { + "stepOne": "Zadejte svůj e-mail", + "stepTwo": "Zadejte své heslo" + }, + "links": { + "forgotPassword": "Zapomněli jste heslo? Obnovte si ho", + "register": "Ještě nemáte účet? Zaregistrujte se", + "forgotPasswordLink": "", + "registerLink": "" + }, + "toasts": { + "success": "Vítejte zpět", + "incorrectPassword": "Nesprávné heslo" + }, + "errors": { + "password": { + "incorrect": "" + } + } + }, + "registration": { + "heading": { + "superAdmin": "Vytvoření superadministrátora", + "user": "Registrace" + }, + "subheadings": { + "stepOne": "Zadejte své osobní údaje", + "stepTwo": "Zadejte svůj e-mail", + "stepThree": "Nastavte si heslo" + }, + "description": { + "superAdmin": "Pro začátek vytvořte účet superadministrátora", + "user": "Zaregistrujte se jako uživatel a požádejte svého superadministrátora, aby vám přidělil oprávnění k hlídačům" + }, + "gettingStartedButton": { + "superAdmin": "Vytvořit účet superadministrátora", + "user": "Zaregistrovat se jako běžný uživatel" + }, + "termsAndPolicies": "Vytvořením účtu souhlasíte s našimi Podmínkami použití (anglicky) a Podmínkami zpracování osobních údajů (anglicky).", + "links": { + "login": "Máte již vytvořený účet? Přihlašte se" + }, + "toasts": { + "success": "Vítejte! Váš účet byl úspěšně vytvořen." + } + }, + "forgotPassword": { + "heading": "Zapomenuté heslo", + "subheadings": { + "stepOne": "Nebojte se, zašleme vám pokyny pro obnovení hesla.", + "stepTwo": "Odkaz pro obnovení hesla byl odeslán na ", + "stepThree": "Nové heslo se musí lišit od těch, které jste již použili.", + "stepFour": "Heslo bylo úspěšně obnoveno. Klepněte na tlačítko a dojde k čarovnému přihlášení." + }, + "buttons": { + "openEmail": "Otevřít e-mailovou aplikaci", + "resetPassword": "Obnovit heslo" + }, + "imageAlts": { + "passwordKey": "Ikonka přístupového klíče", + "email": "Ikonka e-mailu", + "lock": "Ikonka zámku", + "passwordConfirm": "Ikonka potvrzeného hesla" + }, + "links": { + "login": "Vrátit se k Přihlášení", + "resend": "Nedorazil vám e-mail? Klepněte pro opětovné zaslání" + }, + "toasts": { + "sent": "Instrukce vám dorazí na .", + "emailNotFound": "Nepodařilo se nalézt e-mailovou adresu.", + "redirect": "Přesměrování proběhne za ...", + "success": "Heslo bylo úspěšně obnoveno.", + "error": "Obnova hesla se nepodařila. Zkuste to prosím znovu nebo kontaktujte podporu." + } + } + }, + "errorPages": { + "serverUnreachable": { + "toasts": { + "reconnected": "Podařilo se úspěšně obnovit spojení se serverem", + "stillUnreachable": "Server je stále nedosažitelný. Zkuste to prosím později." + }, + "alertBox": "Chyba připojení k serveru", + "description": "Nepodařilo se připojit k serveru. Zkontrolujte si prosím internetové připojení a pokud problém přetrvává, ověřte konfiguraci aplikace Checkmate.", + "retryButton": { + "default": "Zkusit se znovu připojit", + "processing": "Probíhá připojování…" + } + } + }, + "createNotifications": { + "title": "", + "nameSettings": { + "title": "", + "description": "", + "nameLabel": "", + "namePlaceholder": "" + }, + "typeSettings": { + "title": "", + "description": "", + "typeLabel": "" + }, + "emailSettings": { + "title": "", + "description": "", + "emailLabel": "", + "emailPlaceholder": "" + }, + "slackSettings": { + "title": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "" + }, + "pagerdutySettings": { + "title": "", + "description": "", + "integrationKeyLabel": "", + "integrationKeyPlaceholder": "" + }, + "discordSettings": { + "title": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "" + }, + "webhookSettings": { + "title": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "" + }, + "testNotification": "", + "dialogDeleteTitle": "", + "dialogDeleteConfirm": "" + }, + "notificationConfig": { + "title": "", + "description": "" + }, + "monitorStatus": { + "checkingEvery": "", + "withCaptureAgent": "", + "up": "", + "down": "", + "paused": "" + }, + "advancedMatching": "", + "sendTestNotifications": "", + "selectAll": "", + "showAdminLoginLink": "", + "logsPage": { + "title": "", + "description": "", + "tabs": { + "queue": "", + "logs": "", + "diagnostics": "" + }, + "toast": { + "fetchLogsSuccess": "" + }, + "logLevelSelect": { + "title": "", + "values": { + "all": "", + "info": "", + "warn": "", + "error": "", + "debug": "" + } + } + }, + "queuePage": { + "title": "", + "refreshButton": "", + "flushButton": "", + "jobTable": { + "title": "", + "idHeader": "", + "urlHeader": "", + "typeHeader": "", + "activeHeader": "", + "lockedAtHeader": "", + "runCountHeader": "", + "failCountHeader": "", + "lastRunHeader": "", + "lastFinishedAtHeader": "", + "lastRunTookHeader": "" + }, + "metricsTable": { + "title": "", + "metricHeader": "", + "valueHeader": "" + }, + "failedJobTable": { + "title": "", + "monitorIdHeader": "", + "monitorUrlHeader": "", + "failCountHeader": "", + "failedAtHeader": "", + "failReasonHeader": "" + } + }, + "export": { + "title": "", + "success": "", + "failed": "" + }, + "monitorActions": { + "title": "", + "import": "", + "export": "", + "deleteSuccess": "", + "deleteFailed": "", + "details": "" + }, + "settingsPage": { + "aboutSettings": { + "labelDevelopedBy": "", + "labelVersion": "", + "title": "" + }, + "demoMonitorsSettings": { + "buttonAddMonitors": "", + "description": "", + "title": "" + }, + "emailSettings": { + "buttonSendTestEmail": "", + "description": "", + "descriptionTransport": "", + "labelAddress": "", + "labelConnectionHost": "", + "labelHost": "", + "labelIgnoreTLS": "", + "labelPassword": "", + "labelPasswordSet": "", + "labelPool": "", + "labelPort": "", + "labelRejectUnauthorized": "", + "labelRequireTLS": "", + "labelSecure": "", + "labelTLSServername": "", + "labelUser": "", + "linkTransport": "", + "placeholderUser": "", + "title": "", + "toastEmailRequiredFieldsError": "" + }, + "pageSpeedSettings": { + "description": "", + "labelApiKeySet": "", + "labelApiKey": "", + "title": "" + }, + "saveButtonLabel": "", + "statsSettings": { + "clearAllStatsButton": "", + "clearAllStatsDescription": "", + "clearAllStatsDialogConfirm": "", + "clearAllStatsDialogDescription": "", + "clearAllStatsDialogTitle": "", + "description": "", + "labelTTL": "", + "labelTTLOptional": "", + "title": "" + }, + "systemResetSettings": { + "buttonRemoveAllMonitors": "", + "description": "", + "dialogConfirm": "", + "dialogDescription": "", + "dialogTitle": "", + "title": "" + }, + "timezoneSettings": { + "description": "", + "label": "", + "title": "" + }, + "title": "", + "uiSettings": { + "description": "", + "labelLanguage": "", + "labelTheme": "", + "title": "" + }, + "urlSettings": { + "description": "", + "label": "", + "selectDisabled": "", + "selectEnabled": "", + "title": "" + } + }, + "statusPageCreate": { + "buttonSave": "" + }, + "incidentsOptionsHeaderFilterResolved": "", + "settingsSave": "", + "statusPageCreateAppearanceTitle": "", + "confirmPassword": "", + "monitorHooks": { + "failureAddDemoMonitors": "", + "successAddDemoMonitors": "" + }, + "settingsAppearance": "", + "settingsDisplayTimezone": "", + "settingsGeneralSettings": "", + "incidentsOptionsHeaderTotalIncidents": "", + "statusPage": { + "deleteSuccess": "", + "deleteFailed": "", + "createSuccess": "", + "updateSuccess": "", + "generalSettings": "", + "contents": "", + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "testNotificationsDisabled": "", + "incidentsTableResolvedAt": "", + "incidentsTableActionResolve": "", + "checkHooks": { + "failureResolveOne": "", + "failureResolveAll": "", + "failureResolveMonitor": "" + }, + "checkFormError": "", + "diagnosticsPage": { + "diagnosticDescription": "", + "statsDescription": "", + "gauges": { + "heapAllocationTitle": "", + "heapAllocationSubtitle": "", + "heapUsageTitle": "", + "heapUsageSubtitle": "", + "heapUtilizationTitle": "", + "heapUtilizationSubtitle": "", + "instantCpuUsageTitle": "", + "instantCpuUsageSubtitle": "" + }, + "stats": { + "eventLoopDelayTitle": "", + "uptimeTitle": "", + "usedHeapSizeTitle": "", + "totalHeapSizeTitle": "", + "osMemoryLimitTitle": "" + } + }, + "pageSpeedLighthouseAPI": "", + "time": { + "threeMinutes": "", + "fiveMinutes": "", + "tenMinutes": "", + "twentyMinutes": "", + "oneHour": "", + "oneDay": "", + "oneWeek": "" + }, + "general": { + "noOptionsFound": "" + }, + "infrastructureMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "maintenanceWindow": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "pageSpeed": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "uptimeMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "editUserPage": { + "form": { + "email": "", + "firstName": "", + "lastName": "", + "role": "", + "save": "" + }, + "table": { + "actionHeader": "", + "roleHeader": "" + }, + "title": "", + "toast": { + "successUserUpdate": "", + "validationErrors": "" + } + }, + "incidentsPageActionResolveMonitor": "", + "incidentsPageActionResolveAll": "" +} diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 1938dcccd..aa416bd95 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -1,84 +1,83 @@ { "submit": "Absenden", "title": "Titel", - "distributedStatusHeaderText": "", - "distributedStatusSubHeaderText": "", + "distributedStatusHeaderText": "Echtzeit Abdeckung", + "distributedStatusSubHeaderText": "Genutzt von Millionen von Geräten weltweit, siehst Du die Systemleistung nach globaler Region, Land oder Stadt", "settingsDisabled": "Inaktiv", "settingsSuccessSaved": "Einstellungen erfolgreich gespeichert", "settingsFailedToSave": "Fehler beim Speichern der Einstellungen", "settingsStatsCleared": "Statistiken gelöscht", "settingsFailedToClearStats": "Fehler beim löschen der Statistiken", - "settingsFailedToAddDemoMonitors": "", - "settingsMonitorsDeleted": "", - "settingsFailedToDeleteMonitors": "", - "starPromptTitle": "", - "starPromptDescription": "", + "settingsMonitorsDeleted": "Alle Monitore erfolgreich gelöscht.", + "settingsFailedToDeleteMonitors": "Fehler beim Löschen aller Monitore", + "starPromptTitle": "Stern vergeben", + "starPromptDescription": "Sieh dir die neuesten Veröffentlichungen an und hilf der Community auf GitHub", "https": "HTTPS", "http": "HTTP", - "monitor": "", + "monitor": "monitor", "aboutus": "Über uns", "now": "Jetzt", "delete": "Löschen", "configure": "Einstellungen", "responseTime": "Antwortzeit", "ms": "ms", - "bar": "", - "area": "", + "bar": "Bar", + "area": "Bereich", "country": "Land", "city": "Stadt", "response": "Antwort", - "monitorStatusUp": "", - "monitorStatusDown": "", - "webhookSendSuccess": "", - "webhookSendError": "", - "webhookUnsupportedPlatform": "", - "distributedRightCategoryTitle": "", - "distributedStatusServerMonitors": "", - "distributedStatusServerMonitorsDescription": "", - "distributedUptimeCreateSelectURL": "", - "distributedUptimeCreateChecks": "", - "distributedUptimeCreateChecksDescription": "", - "distributedUptimeCreateIncidentNotification": "", - "distributedUptimeCreateIncidentDescription": "", + "monitorStatusUp": "Monitor {name} ({url}) ist jetzt ONLINE und antwortet", + "monitorStatusDown": "Monitor {name} ({url}) ist jetzt OFFLINE und antwortet nicht", + "webhookSendSuccess": "Webhook-Benachrichtigung erfolgreich gesendet", + "webhookSendError": "Fehler beim Senden der Webhook-Benachrichtigung an {platform}", + "webhookUnsupportedPlatform": "Nicht unterstützte Plattform: {platform}", + "distributedRightCategoryTitle": "Monitor", + "distributedStatusServerMonitors": "Server Monitore", + "distributedStatusServerMonitorsDescription": "Überwache den Status der zugehörigen Server", + "distributedUptimeCreateSelectURL": "Hier kannst Du die URL und Typ des Hosts auswählen", + "distributedUptimeCreateChecks": "Durchzuführende Prüfungen", + "distributedUptimeCreateChecksDescription": "Du kannst jederzeit Überprüfungen hinzufügen oder entfernen, nachdem Du Deine Seite hinzugefügt hast.\n", + "distributedUptimeCreateIncidentNotification": "Ereignisbenachrichtigungen", + "distributedUptimeCreateIncidentDescription": "Wenn es ein Ereignis gibt, benachrichtige die Benutzer.", "distributedUptimeCreateAdvancedSettings": "Erweiterte Einstellungen", - "distributedUptimeDetailsNoMonitorHistory": "", - "distributedUptimeDetailsStatusHeaderUptime": "", - "distributedUptimeDetailsStatusHeaderLastUpdate": "", + "distributedUptimeDetailsNoMonitorHistory": "Es gibt noch keine Überprüfungshistorie für diesen Monitor.", + "distributedUptimeDetailsStatusHeaderUptime": "Uptime:", + "distributedUptimeDetailsStatusHeaderLastUpdate": "Zuletzt aktualisiert", "notifications": { - "enableNotifications": "", - "testNotification": "", - "addOrEditNotifications": "", + "enableNotifications": "Aktiviere {{platform}} Benachrichtigungen\n", + "testNotification": "Test Benachrichtigung", + "addOrEditNotifications": "Benachrichtigungen hinzufügen oder bearbeiten", "slack": { "label": "Slack", - "description": "", + "description": "Um Slack-Benachrichtigungen zu aktivieren, erstelle eine Slack-App und aktiviere eingehende Webhooks. Danach gib einfach die Webhook-URL hier ein.", "webhookLabel": "Webhook URL", "webhookPlaceholder": "", - "webhookRequired": "" + "webhookRequired": "Slack webhook URL wird benötigt" }, "discord": { "label": "Discord", - "description": "", + "description": "Um Daten von Checkmate an einen Discord-Kanal über Discord-Benachrichtigungen mithilfe von Webhooks zu senden, kannst Du die eingehenden Webhooks von Discord verwenden.", "webhookLabel": "Discord Webhook URL", "webhookPlaceholder": "", - "webhookRequired": "" + "webhookRequired": "Discord webhook URL wird benötigt" }, "telegram": { "label": "Telegram", - "description": "", - "tokenLabel": "", + "description": "Um Telegram-Benachrichtigungen zu aktivieren, erstelle einen Telegram-Bot mit BotFather, einem offiziellen Bot zum Erstellen und Verwalten von Telegram-Bots. Dann hole dir das API-Token und die Chat-ID und notiere sie hier.", + "tokenLabel": "Dein bot token", "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "", + "chatIdLabel": "Deine Chat ID", "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "" + "fieldsRequired": "Telegram token und Chat-ID werden benötigt" }, "webhook": { "label": "Webhooks", - "description": "", + "description": "Du kannst einen benutzerdefinierten Webhook einrichten, um Benachrichtigungen zu erhalten, wenn Ereignisse auftreten.", "urlLabel": "Webhook URL", "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "" + "urlRequired": "Webhook URL wird benötigt" }, - "testNotificationDevelop": "", + "testNotificationDevelop": "Testbenachrichtigung 2", "integrationButton": "", "testSuccess": "", "testFailed": "", @@ -86,7 +85,8 @@ "networkError": "", "fallback": { "title": "", - "checks": [""] + "checks": [""], + "actionButton": "" }, "createButton": "", "createTitle": "", @@ -107,166 +107,166 @@ "failed": "" }, "test": { - "success": "", - "failed": "" + "success": "Test Benachrichtigung erfolgreich gesendet", + "failed": "Fehler beim senden der Test Benachrichtigung" } }, - "testLocale": "", - "add": "", - "monitors": "", - "distributedUptimeStatusCreateStatusPage": "", - "distributedUptimeStatusCreateStatusPageAccess": "", - "distributedUptimeStatusCreateStatusPageReady": "", - "distributedUptimeStatusBasicInfoHeader": "", - "distributedUptimeStatusBasicInfoDescription": "", - "distributedUptimeStatusLogoHeader": "", - "distributedUptimeStatusLogoDescription": "", - "distributedUptimeStatusLogoUploadButton": "", - "distributedUptimeStatusStandardMonitorsHeader": "", - "distributedUptimeStatusStandardMonitorsDescription": "", + "testLocale": "testLocale", + "add": "Hinzufügen", + "monitors": "Monitore", + "distributedUptimeStatusCreateStatusPage": "Statusseite", + "distributedUptimeStatusCreateStatusPageAccess": "Zugriff", + "distributedUptimeStatusCreateStatusPageReady": "Wenn deine Statusseite bereit ist, kannst du sie als veröffentlicht markieren.", + "distributedUptimeStatusBasicInfoHeader": "Grundlegende Informationen", + "distributedUptimeStatusBasicInfoDescription": "Definiere den Firmennamen und die Subdomain, auf die deine Statusseite verweist.", + "distributedUptimeStatusLogoHeader": "Logo", + "distributedUptimeStatusLogoDescription": "Lade ein Logo für deine Statusseite hoch", + "distributedUptimeStatusLogoUploadButton": "Logo hochladen", + "distributedUptimeStatusStandardMonitorsHeader": "Standardmonitore", + "distributedUptimeStatusStandardMonitorsDescription": "Füge Standardmonitore zu deiner Statusseite hinzu.", "distributedUptimeStatusCreateYour": "Erstelle Dein", "distributedUptimeStatusEditYour": "Ändere Dein", - "distributedUptimeStatusPublishedLabel": "", + "distributedUptimeStatusPublishedLabel": "Veröffentlicht und für die Öffentlichkeit sichtbar", "distributedUptimeStatusCompanyNameLabel": "Firmenname", - "distributedUptimeStatusPageAddressLabel": "", + "distributedUptimeStatusPageAddressLabel": "Die Adresse deiner Statusseite", "distributedUptimeStatus30Days": "30 Tage", "distributedUptimeStatus60Days": "60 Tage", "distributedUptimeStatus90Days": "90 Tage", - "distributedUptimeStatusPageNotSetUp": "", - "distributedUptimeStatusContactAdmin": "", - "distributedUptimeStatusPageNotPublic": "", - "distributedUptimeStatusPageDeleteDialog": "", - "distributedUptimeStatusPageDeleteConfirm": "", - "distributedUptimeStatusPageDeleteDescription": "", - "distributedUptimeStatusDevices": "", - "distributedUptimeStatusUpt": "", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "", - "incidentsTableNoIncidents": "", - "incidentsTablePaginationLabel": "", - "incidentsTableMonitorName": "", - "incidentsTableStatus": "", - "incidentsTableDateTime": "", - "incidentsTableStatusCode": "", - "incidentsTableMessage": "", - "incidentsOptionsHeader": "", - "incidentsOptionsHeaderFilterBy": "", - "incidentsOptionsHeaderFilterAll": "", - "incidentsOptionsHeaderFilterDown": "", - "incidentsOptionsHeaderFilterCannotResolve": "", - "incidentsOptionsHeaderShow": "", - "incidentsOptionsHeaderLastHour": "", - "incidentsOptionsHeaderLastDay": "", - "incidentsOptionsHeaderLastWeek": "", - "incidentsOptionsPlaceholderAllServers": "", - "infrastructureCreateYour": "", - "infrastructureCreateGeneralSettingsDescription": "", - "infrastructureServerRequirement": "", - "infrastructureCustomizeAlerts": "", - "infrastructureAlertNotificationDescription": "", - "infrastructureCreateMonitor": "", - "infrastructureProtocol": "", - "infrastructureServerUrlLabel": "", - "infrastructureDisplayNameLabel": "", - "infrastructureAuthorizationSecretLabel": "", - "gb": "", - "mb": "", - "mem": "", - "memoryUsage": "", - "cpu": "", - "cpuUsage": "", - "cpuTemperature": "", - "diskUsage": "", - "used": "", - "total": "", - "cores": "", - "frequency": "", - "status": "", - "cpuPhysical": "", - "cpuLogical": "", - "cpuFrequency": "", - "avgCpuTemperature": "", - "memory": "", - "disk": "", - "uptime": "", - "os": "", - "host": "", - "actions": "", - "integrations": "", - "integrationsPrism": "", - "integrationsSlack": "", - "integrationsSlackInfo": "", - "integrationsDiscord": "", - "integrationsDiscordInfo": "", - "integrationsZapier": "", - "integrationsZapierInfo": "", - "commonSave": "", - "createYour": "", - "createMonitor": "", - "pause": "", - "resume": "", - "editing": "", - "url": "", - "access": "", - "timezone": "", - "features": "", - "administrator": "", - "loginHere": "", - "displayName": "", - "urlMonitor": "", - "portToMonitor": "", - "websiteMonitoring": "", - "websiteMonitoringDescription": "", - "pingMonitoring": "", - "pingMonitoringDescription": "", - "dockerContainerMonitoring": "", - "dockerContainerMonitoringDescription": "", - "portMonitoring": "", - "portMonitoringDescription": "", - "createMaintenanceWindow": "", - "createMaintenance": "", - "editMaintenance": "", - "maintenanceWindowName": "", + "distributedUptimeStatusPageNotSetUp": "Eine Statusseite ist nicht eingerichtet.", + "distributedUptimeStatusContactAdmin": "Bitte kontaktiere deinen Administrator", + "distributedUptimeStatusPageNotPublic": "Diese Statusseite ist nicht öffentlich.", + "distributedUptimeStatusPageDeleteDialog": "Möchtest du diese Statusseite löschen?", + "distributedUptimeStatusPageDeleteConfirm": "Ja, Statusseite löschen", + "distributedUptimeStatusPageDeleteDescription": "Sobald sie gelöscht ist, kann deine Statusseite nicht wiederhergestellt werden.", + "distributedUptimeStatusDevices": "Geräte", + "distributedUptimeStatusUpt": "UPT", + "distributedUptimeStatusUptBurned": "UPT Burned", + "distributedUptimeStatusUptLogo": "Upt Logo", + "incidentsTableNoIncidents": "Keine Ereignisse aufgezeichnet", + "incidentsTablePaginationLabel": "Ereignisse", + "incidentsTableMonitorName": "Monitor Name", + "incidentsTableStatus": "Status", + "incidentsTableDateTime": "Datum & Zeit", + "incidentsTableStatusCode": "Status Code", + "incidentsTableMessage": "Nachricht", + "incidentsOptionsHeader": "Ereignisse für", + "incidentsOptionsHeaderFilterBy": "Gefiltert nach:", + "incidentsOptionsHeaderFilterAll": "Alle", + "incidentsOptionsHeaderFilterDown": "offline", + "incidentsOptionsHeaderFilterCannotResolve": "Kann nicht aufgelöst werden", + "incidentsOptionsHeaderShow": "Anzeigen:", + "incidentsOptionsHeaderLastHour": "Letzte Stunde", + "incidentsOptionsHeaderLastDay": "Letzter Tag", + "incidentsOptionsHeaderLastWeek": "Letzte Woche", + "incidentsOptionsPlaceholderAllServers": "Alle Server", + "infrastructureCreateYour": "Erstelle dein", + "infrastructureCreateGeneralSettingsDescription": "Hier kannst du die URL des Hosts auswählen, zusammen mit dem Namen und dem Autorisierungs-token, um eine Verbindung zum Server-Agenten herzustellen.", + "infrastructureServerRequirement": "Der Server, den du überwachst, muss die folgende Software ausführen:", + "infrastructureCustomizeAlerts": "Benachrichtigungen anpassen", + "infrastructureAlertNotificationDescription": "Sende eine Benachrichtigung an Benutzer, wenn Schwellenwerte einen bestimmten Prozentsatz überschreiten.", + "infrastructureCreateMonitor": "Erstelle Infrastrukturmonitor", + "infrastructureProtocol": "Protokoll", + "infrastructureServerUrlLabel": "Server URL", + "infrastructureDisplayNameLabel": "Anzeigename", + "infrastructureAuthorizationSecretLabel": "Autorisierungs-token", + "gb": "GB", + "mb": "MB", + "mem": "Speicher", + "memoryUsage": "Speicher Nutzung", + "cpu": "CPU", + "cpuUsage": "CPU Nutzung", + "cpuTemperature": "CPU Temperatur", + "diskUsage": "Disk benutzt", + "used": "benutzt", + "total": "Total", + "cores": "Kerne", + "frequency": "Frequenz", + "status": "Status", + "cpuPhysical": "CPU (Physical)", + "cpuLogical": "CPU (Logical)", + "cpuFrequency": "CPU Frequenz", + "avgCpuTemperature": "Durchschn. CPU Termperatur", + "memory": "Speicher", + "disk": "Disk", + "uptime": "Uptime", + "os": "OS", + "host": "Host", + "actions": "Aktionen", + "integrations": "Integrationen", + "integrationsPrism": "Verbinde Prism mit deinem favorisierten Gerät", + "integrationsSlack": "Slack", + "integrationsSlackInfo": "Verbinde dich mit Slack und sieh Ereignisse in einem Kanal", + "integrationsDiscord": "Discord", + "integrationsDiscordInfo": "Verbinde dich mit Discord und sieh Ereignisse direkt in einem Kanal", + "integrationsZapier": "Zapier", + "integrationsZapierInfo": "Sende alle Ereignisse an Zapier und sieh sie dann überall", + "commonSave": "Speichern", + "createYour": "Erstelle Deinen", + "createMonitor": "Erstelle Monitor", + "pause": "Pausieren", + "resume": "Weiter", + "editing": "Bearbeiten...", + "url": "URL", + "access": "Zugriff", + "timezone": "Zeitzone", + "features": "Funktionen", + "administrator": "Administrator?", + "loginHere": "Hier anmelden", + "displayName": "Anzeigename", + "urlMonitor": "zu überwachende URL", + "portToMonitor": "zu überwachender Port", + "websiteMonitoring": "Websiten-Überwachung", + "websiteMonitoringDescription": "Verwende HTTP(s), um deine Website oder API-Endpunkt zu überwachen.", + "pingMonitoring": "Ping-Überwachung", + "pingMonitoringDescription": "Überprüfe, ob dein Server verfügbar ist oder nicht.", + "dockerContainerMonitoring": "Docker-Container-Überwachung", + "dockerContainerMonitoringDescription": "Überprüfe, ob dein Docker-Container läuft oder nicht.", + "portMonitoring": "Port-Überwachung", + "portMonitoringDescription": "Überprüfe, ob dein Port offen ist oder nicht.", + "createMaintenanceWindow": "Erstelle Wartungsfenster", + "createMaintenance": "Erstelle Wartungsfenster", + "editMaintenance": "Ändere Wartungsfenster", + "maintenanceWindowName": "Name des Wartungsfensters", "friendlyNameInput": "", - "friendlyNamePlaceholder": "", - "maintenanceRepeat": "", - "maintenance": "", - "duration": "", - "addMonitors": "", - "window": "", - "cancel": "", - "message": "", - "low": "", - "high": "", - "statusCode": "", - "date&Time": "", - "type": "", - "statusPageName": "", - "publicURL": "", - "repeat": "", - "edit": "", - "createA": "", - "remove": "", + "friendlyNamePlaceholder": "Wartung um __ : __ für ___ Minuten", + "maintenanceRepeat": "Wartungsfenster wiederholen", + "maintenance": "Wartungsfenster", + "duration": "Dauer", + "addMonitors": "Monitor hinzufügen", + "window": "Fenster", + "cancel": "Abbrechen", + "message": "Nachricht", + "low": "niedrig", + "high": "hoch", + "statusCode": "Status Code", + "date&Time": "Datum & Zeit", + "type": "Art", + "statusPageName": "Name der Statusseite", + "publicURL": "Öffentliche URL", + "repeat": "Wiederholen", + "edit": "Ändern", + "createA": "Erstelle ein", + "remove": "Löschen", "maintenanceWindowDescription": "", - "startTime": "", - "timeZoneInfo": "", - "monitorsToApply": "", - "nextWindow": "", - "notFoundButton": "", - "pageSpeedConfigureSettingsDescription": "", - "monitorDisplayName": "", - "whenNewIncident": "", - "notifySMS": "", - "notifyEmails": "", - "seperateEmails": "", - "checkFrequency": "", + "startTime": "Anfangszeit", + "timeZoneInfo": "Alle Daten und Zeiten sind in der Zeitzone GMT+0.", + "monitorsToApply": "Monitore, auf die das Wartungsfenster angewendet werden soll", + "nextWindow": "Nächstes Fenster", + "notFoundButton": "Gehe zum Haupt-Dashboard", + "pageSpeedConfigureSettingsDescription": "Hier kannst du die URL des Hosts auswählen, zusammen mit der Art des Monitors.", + "monitorDisplayName": "Anzeigename des Monitors", + "whenNewIncident": "Wenn es ein neues Ereignis gibt,", + "notifySMS": "Benachrichtige per SMS (kommt bald)", + "notifyEmails": "Benachrichtige auch per E-Mail an mehrere Adressen (kommt bald)", + "seperateEmails": "Du kannst mehrere E-Mails mit einem Komma trennen", + "checkFrequency": "Überprüfungsfrequenz", "matchMethod": "", - "expectedValue": "", - "deleteDialogTitle": "", - "deleteDialogDescription": "", + "expectedValue": "Erwarteter Wert", + "deleteDialogTitle": "Möchtest du diesen Monitor wirklich löschen?", + "deleteDialogDescription": "Sobald dieser Monitor gelöscht ist, kann er nicht wiederhergestellt werden.", "pageSpeedMonitor": "", "shown": "", - "ago": "", + "ago": "vor", "companyName": "", "pageSpeedDetailsPerformanceReport": "", "pageSpeedDetailsPerformanceReportCalculator": "", @@ -283,7 +283,7 @@ "statusPageCreateTabsContentFeaturesDescription": "", "showCharts": "", "showUptimePercentage": "", - "removeLogo": "", + "removeLogo": "Entferne Logo", "statusPageStatus": "", "statusPageStatusContactAdmin": "", "statusPageStatusNotPublic": "", @@ -304,129 +304,131 @@ "errorInvalidFieldId": "", "inviteNoTokenFound": "", "pageSpeedWarning": "", - "pageSpeedLearnMoreLink": "", - "pageSpeedAddApiKey": "", - "update": "", - "invalidFileFormat": "", - "invalidFileSize": "", + "pageSpeedLearnMoreLink": "Hier klicken", + "pageSpeedAddApiKey": "um einen API key einzutragen", + "update": "Update", + "invalidFileFormat": "Nicht unterstützes Dateiformat", + "invalidFileSize": "Dateigröße zu groß!", "ClickUpload": "", - "DragandDrop": "", - "MaxSize": "", - "SupportedFormats": "", - "FirstName": "", - "LastName": "", + "DragandDrop": "drag and drop", + "MaxSize": "Maximalgröße", + "SupportedFormats": "Unterstützte Formate", + "FirstName": "Vorname", + "LastName": "Zuname", "EmailDescriptionText": "", - "YourPhoto": "", + "YourPhoto": "Profilphoto", "PhotoDescriptionText": "", - "save": "", + "save": "Speichern", "DeleteDescriptionText": "", "DeleteAccountWarning": "", - "DeleteWarningTitle": "", + "DeleteWarningTitle": "Account wirklich löschen?", "bulkImport": { "title": "", "selectFileTips": "", "selectFileDescription": "", - "selectFile": "", + "selectFile": "Datei auswählen", "parsingFailed": "", - "uploadSuccess": "", - "validationFailed": "", - "noFileSelected": "", - "fallbackPage": "" + "uploadSuccess": "Monitore erfolgreich erstellt!", + "validationFailed": "Prüfung fehlgeschlagen", + "noFileSelected": "Keine Datei ausgewählt", + "fallbackPage": "", + "invalidFileType": "Ungültiger Dateityp", + "uploadFailed": "Upload fehlgeschlagen" }, - "DeleteAccountTitle": "", - "DeleteAccountButton": "", - "publicLink": "", - "maskedPageSpeedKeyPlaceholder": "", - "reset": "", - "ignoreTLSError": "", - "tlsErrorIgnored": "", - "ignoreTLSErrorDescription": "", - "createNew": "", + "DeleteAccountTitle": "Account löschen", + "DeleteAccountButton": "Account löschen", + "publicLink": "Öffentlicher Link", + "maskedPageSpeedKeyPlaceholder": "*************************************", + "reset": "Reset", + "ignoreTLSError": "TLS/SSL Fehler ignorieren", + "tlsErrorIgnored": "TLS/SSL Fehler ignoriert", + "ignoreTLSErrorDescription": "TLS/SSL Fehler ignorieren und Verfügbarkeit weiter prüfen", + "createNew": "Neu anlegen", "greeting": { - "prepend": "", + "prepend": "Hallo", "append": "", "overview": "" }, "roles": { - "superAdmin": "", - "admin": "", - "teamMember": "", - "demoUser": "" + "superAdmin": "Super admin", + "admin": "Admin", + "teamMember": "Team Mitglied", + "demoUser": "Demo user" }, "teamPanel": { - "teamMembers": "", + "teamMembers": "Team Mitglied", "filter": { - "all": "", - "member": "" + "all": "Alle", + "member": "Mitglied" }, - "inviteTeamMember": "", - "inviteNewTeamMember": "", + "inviteTeamMember": "Team Mirglied einladen", + "inviteNewTeamMember": "Neues Team Mirglied einladen", "inviteDescription": "", - "email": "", - "selectRole": "", - "inviteLink": "", - "cancel": "", - "noMembers": "", - "getToken": "", - "emailToken": "", + "email": "Email", + "selectRole": "Rolle auswählen", + "inviteLink": "Einladungslink", + "cancel": "Abbrechen", + "noMembers": "Es gibt keine Team Mitglieder mit dieser Rolle", + "getToken": "Token holen", + "emailToken": "E-mail token", "table": { - "name": "", - "email": "", - "role": "", - "created": "" + "name": "Name", + "email": "Email", + "role": "Rolle", + "created": "Angelegt" } }, "monitorState": { - "paused": "", + "paused": "Pausiert", "resumed": "", - "active": "" + "active": "Aktiv" }, "menu": { - "uptime": "", + "uptime": "Uptime", "pagespeed": "", "infrastructure": "", "incidents": "", - "statusPages": "", - "maintenance": "", + "statusPages": "Statusseite", + "maintenance": "Wartung", "integrations": "", - "settings": "", - "support": "", - "discussions": "", - "docs": "", - "changelog": "", - "profile": "", - "password": "", - "team": "", - "logOut": "", - "notifications": "", - "logs": "" + "settings": "Einstellungen", + "support": "Support", + "discussions": "Diskussionen", + "docs": "Anleitungen", + "changelog": "Änderungen", + "profile": "Profil", + "password": "Passwort", + "team": "Team", + "logOut": "Abmelden", + "notifications": "Benachrichtigungen", + "logs": "Logs" }, "settingsEmailUser": "", "state": "", - "statusBreadCrumbsStatusPages": "", - "statusBreadCrumbsDetails": "", - "commonSaving": "", + "statusBreadCrumbsStatusPages": "Statusseite", + "statusBreadCrumbsDetails": "Details", + "commonSaving": "Speichere...", "navControls": "", - "incidentsPageTitle": "", + "incidentsPageTitle": "Ereignisse", "passwordPanel": { - "passwordChangedSuccess": "", - "passwordInputIncorrect": "", - "currentPassword": "", - "enterCurrentPassword": "", - "newPassword": "", - "enterNewPassword": "", - "confirmNewPassword": "", - "passwordRequirements": "", - "saving": "" + "passwordChangedSuccess": "Dein Passwort wurde erfolgreich geändert.", + "passwordInputIncorrect": "Die Passworteingabe war nicht korrekt.", + "currentPassword": "Aktuelles Passwort", + "enterCurrentPassword": "Aktuelles Passwort eingeben", + "newPassword": "Neues Passwort", + "enterNewPassword": "Neues Passwort eingeben", + "confirmNewPassword": "Neues Passwort wiederholen", + "passwordRequirements": "Das Passwort muss mindestens 8 Zeichen, einen Großbuchstaben, eine Zahl und ein Sonderzeichen enthalten.", + "saving": "Speichere..." }, - "emailSent": "", - "failedToSendEmail": "", - "settingsTestEmailSuccess": "", - "settingsTestEmailFailed": "", - "settingsTestEmailFailedWithReason": "", - "settingsTestEmailUnknownError": "", + "emailSent": "Email erfolgreich gesendet", + "failedToSendEmail": "Email konnte nicht gesendet werden", + "settingsTestEmailSuccess": "Test-Email erfolgreich gesendet", + "settingsTestEmailFailed": "Test-Email konnte nicht gesendet werden", + "settingsTestEmailFailedWithReason": "Email konnte nicht gesendet werden: {{reason}}", + "settingsTestEmailUnknownError": "Unbekannter Fehler", "statusMsg": { - "paused": "", + "paused": "Monitoring pausiert", "up": "", "down": "", "pending": "" @@ -438,183 +440,188 @@ "port": "" }, "common": { - "appName": "", - "monitoringAgentName": "", + "appName": "Checkmate", + "monitoringAgentName": "Capture", "buttons": { "toggleTheme": "" }, "toasts": { - "networkError": "", - "checkConnection": "", - "unknownError": "" + "networkError": "Netzwerkfehler", + "checkConnection": "Bitte Verbindung prüfen", + "unknownError": "Unbekannter Fehler" } }, "auth": { "common": { "navigation": { - "continue": "", - "back": "" + "continue": "Weiter", + "back": "Zurück" }, "inputs": { "email": { - "label": "", - "placeholder": "", + "label": "Email", + "placeholder": "jordan.ellis@domain.com", "errors": { - "empty": "", + "empty": "Email adresse eingeben um fortzufahren", "invalid": "" } }, "password": { - "label": "", + "label": "Passwort", "rules": { "length": { - "beginning": "", - "highlighted": "" + "beginning": "Muß mindestens", + "highlighted": "8 Zeichen lang" }, "special": { - "beginning": "", - "highlighted": "" + "beginning": "Muß mindestens", + "highlighted": "ein Sonderzeichen enthalten" }, "number": { - "beginning": "", - "highlighted": "" + "beginning": "Muß mindestens", + "highlighted": "eine Zahl enthalten" }, "uppercase": { - "beginning": "", - "highlighted": "" + "beginning": "Muß mindestens", + "highlighted": "einen Großbuchstaben enthalten" }, "lowercase": { - "beginning": "", - "highlighted": "" + "beginning": "Muß mindestens", + "highlighted": "einen Kleinbuchstaben enthalten" }, "match": { "beginning": "", - "highlighted": "" + "highlighted": "muß übereinstimmen" } }, "errors": { - "empty": "", - "length": "", - "uppercase": "", - "lowercase": "", - "number": "", - "special": "", - "incorrect": "" + "empty": "Bitte Passwort eingeben", + "length": "Passwort muss mindestens 8 Zeichen lang sein", + "uppercase": "Passwort muss mindestens einen Großbuchstaben enthalten", + "lowercase": "Passwort muss mindestens einen Kleinbuchstaben enthalten", + "number": "Passwort muss mindestens eine Zahl enthalten", + "special": "Passwort muss mindestens ein Sonderzeichen enthalten", + "incorrect": "Das Passwort stimmt nicht mit dem gespeicherten überein" } }, "passwordConfirm": { - "label": "", - "placeholder": "", + "label": "Passwort bestätigen", + "placeholder": "Passwort nochmal eingeben", "errors": { - "empty": "", - "different": "" + "empty": "Passwort nochmal eingeben", + "different": "Die Passwörter stimmen nicht überein" } }, "firstName": { - "label": "", - "placeholder": "", + "label": "Name", + "placeholder": "Jordan", "errors": { - "empty": "", - "length": "", - "pattern": "" + "empty": "Bitte Name eingeben", + "length": "Name darf nicht mehr als 50 Zeichen lang sein", + "pattern": "Name darf nur aus Buchstaben, Leerstellen, Apostroph und Bindestrichen bestehen" } }, "lastName": { - "label": "", - "placeholder": "", + "label": "Zuname", + "placeholder": "Ellis", "errors": { - "empty": "", - "length": "", - "pattern": "" + "empty": "Bitte Zuname eingeben", + "length": "Zuname darf nicht länger als 50 Zeichen sein", + "pattern": "Zuname darf nur aus Buchstaben, Leerstellen, Apostroph und Bindestrichen bestehen" } } }, "errors": { - "validation": "" + "validation": "Fehler bei der prüfung der Daten" }, "fields": { "password": { "errors": { - "incorrect": "" + "incorrect": "Das Passwort stimmt nicht mit dem gespeicherten überein" + } + }, + "role": { + "errors": { + "min": "" } } } }, "login": { - "heading": "", + "heading": "Anmelden", "subheadings": { - "stepOne": "", - "stepTwo": "" + "stepOne": "Email eingeben", + "stepTwo": "Passwort eingeben" }, "links": { - "forgotPassword": "", - "register": "", - "forgotPasswordLink": "", - "registerLink": "" + "forgotPassword": "Passwort vergessen?", + "register": "Noch keinen Account?", + "forgotPasswordLink": "Passwort zurücksetzen", + "registerLink": "Hier resistrieren" }, "toasts": { - "success": "", - "incorrectPassword": "" + "success": "Willkommen zurück. Erfolgreich angemeldet.", + "incorrectPassword": "Falsches Passwort" }, "errors": { "password": { - "incorrect": "" + "incorrect": "Das Passwort stimmt nicht mit dem gespeicherten überein" } } }, "registration": { "heading": { - "superAdmin": "", - "user": "" + "superAdmin": "Super Admin anlegen", + "user": "Anmelden" }, "subheadings": { "stepOne": "", - "stepTwo": "", - "stepThree": "" + "stepTwo": "Email eingeben", + "stepThree": "Passwort eingeben" }, "description": { - "superAdmin": "", + "superAdmin": "Super admin account anlegen um zu beginnen", "user": "" }, "gettingStartedButton": { - "superAdmin": "", - "user": "" + "superAdmin": "Super admin account anlegen", + "user": "Normalen Benutzer anlegen" }, "termsAndPolicies": "", "links": { - "login": "" + "login": "Du hast bereits einen Benutzer? Anmelden" }, "toasts": { - "success": "" + "success": "Willkommen! Benutzer erfolgreich angelegt" } }, "forgotPassword": { - "heading": "", + "heading": "Passwort vergessen?", "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "", - "stepFour": "" + "stepOne": "Kein Problem. Wir schicken Dir Infos zum zurücksetzen.", + "stepTwo": "Link zum Password zurücksetzen an geschickt. ", + "stepThree": "Das neue Passwort darf nicht gleich wie frühere Passwörter sein.", + "stepFour": "Das Passwort wurde zurückgesetzt. Unten klicken zum Anmelden." }, "buttons": { - "openEmail": "", - "resetPassword": "" + "openEmail": "Email app öffnen", + "resetPassword": "Passwort zurücksetzen" }, "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" + "passwordKey": "Password key icon", + "email": "Email icon", + "lock": "Lock icon", + "passwordConfirm": "Password confirm icon" }, "links": { - "login": "", - "resend": "" + "login": "Zurück zum Anmelden", + "resend": "Keine Email bekommen? Klicken um nochmal zu senden" }, "toasts": { - "sent": "", - "emailNotFound": "", - "redirect": "", - "success": "", + "sent": "Anleitung an gesendet.", + "emailNotFound": "Email nicht gefunden.", + "redirect": "Umleitung in ...", + "success": "Passwort erfolgreich zurückgesetzt.", "error": "" } } @@ -622,83 +629,86 @@ "errorPages": { "serverUnreachable": { "toasts": { - "reconnected": "", - "stillUnreachable": "" + "reconnected": "Verbindung zum Server wiederhergestellt.", + "stillUnreachable": "Server immer noch nicht erreichbar. Bitte später erneut versuchen." }, - "alertBox": "", + "alertBox": "Server Verbindungsfehler", "description": "", "retryButton": { - "default": "", - "processing": "" + "default": "Verbindung erneut versuchen", + "processing": "Verbinde..." } } }, "createNotifications": { "title": "", "nameSettings": { - "title": "", + "title": "Name", "description": "", - "nameLabel": "", - "namePlaceholder": "" + "nameLabel": "Name", + "namePlaceholder": "z.B. Slack Benachrichtigung" }, "typeSettings": { - "title": "", + "title": "Typ", "description": "", - "typeLabel": "" + "typeLabel": "Typ" }, "emailSettings": { - "title": "", - "description": "", - "emailLabel": "", - "emailPlaceholder": "" + "title": "Email", + "description": "Empfänger Email adresse", + "emailLabel": "Email addresse", + "emailPlaceholder": "z.B. john@example.com" }, "slackSettings": { - "title": "", - "description": "", - "webhookLabel": "", + "title": "Slack", + "description": "Slack webhook hier konfigurieren", + "webhookLabel": "Slack webhook URL", "webhookPlaceholder": "" }, "pagerdutySettings": { - "title": "", - "description": "", - "integrationKeyLabel": "", - "integrationKeyPlaceholder": "" + "title": "PagerDuty", + "description": "PagerDuty hier konfigurieren", + "integrationKeyLabel": "Integration key", + "integrationKeyPlaceholder": "1234567890" }, "discordSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" + "title": "Discord", + "description": "Discord webhook hier konfigurieren", + "webhookLabel": "Discord Webhook URL", + "webhookPlaceholder": "https://your-server.com/webhook" }, "webhookSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - } + "title": "Webhook", + "description": "Webhook hier konfigurieren", + "webhookLabel": "Webhook URL", + "webhookPlaceholder": "https://your-server.com/webhook" + }, + "testNotification": "", + "dialogDeleteTitle": "", + "dialogDeleteConfirm": "" }, "notificationConfig": { - "title": "", + "title": "Benachrichtigungen", "description": "" }, "monitorStatus": { "checkingEvery": "", "withCaptureAgent": "", - "up": "", - "down": "", - "paused": "" + "up": "hoch", + "down": "runter", + "paused": "pausiert" }, "advancedMatching": "", - "sendTestNotifications": "", - "testNotificationsDisabled": "", - "selectAll": "", + "sendTestNotifications": "Test Benachrichtigung senden", + "selectAll": "Alle auswählen", "showAdminLoginLink": "", "logsPage": { - "title": "", + "title": "Logs", "description": "", "tabs": { - "queue": "", - "logs": "" + "queue": "Warteschlange", + "logs": "Server logs", + "diagnostics": "" }, "toast": { "fetchLogsSuccess": "" @@ -706,94 +716,98 @@ "logLevelSelect": { "title": "", "values": { - "all": "", - "info": "", - "warn": "", - "error": "", - "debug": "" + "all": "Alle", + "info": "Info", + "warn": "Warnung", + "error": "Fehler", + "debug": "Debug" } } }, "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", + "title": "Warteschlange", + "refreshButton": "Auffrischen", + "flushButton": "Warteschlange leeren", "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "", - "activeHeader": "", + "title": "Aufgaben in der Warteschlange", + "idHeader": "Monitor ID", + "urlHeader": "URL", + "typeHeader": "Typ", + "activeHeader": "Aktiv", "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "", - "lastRunTookHeader": "" + "runCountHeader": "Anzahl Läufe", + "failCountHeader": "Anzahl Fehler", + "lastRunHeader": "Letzter Lauf um", + "lastFinishedAtHeader": "Zuletzt abgeschlossen um", + "lastRunTookHeader": "Letzter Lauf dauerte" }, "metricsTable": { "title": "", "metricHeader": "", - "valueHeader": "" + "valueHeader": "Wert" }, "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", - "failedAtHeader": "", - "failReasonHeader": "" + "title": "Anzahl Fehler", + "monitorIdHeader": "Monitor ID", + "monitorUrlHeader": "Monitor URL", + "failCountHeader": "Anzahl Fehler", + "failedAtHeader": "Zuletzt fehlgeschlagen um", + "failReasonHeader": "Fehlergrund" } }, "export": { - "title": "", - "success": "", - "failed": "" + "title": "Monitore exportieren", + "success": "Monitore erfolgreich exportiert", + "failed": "Fehler beim exportieren der Monitore" }, "monitorActions": { - "title": "", - "import": "", - "export": "" + "title": "Export/Import", + "import": "Monitore importieren", + "export": "Monitore exportieren", + "deleteSuccess": "", + "deleteFailed": "", + "details": "" }, "settingsPage": { "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "" + "labelDevelopedBy": "Entwickelt von Bluewave Labs", + "labelVersion": "Version", + "title": "Über" }, "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" + "buttonAddMonitors": "Demo Monitor hinzufügen", + "description": "Beispiel Monitor zur Demonstration hinzufügen.", + "title": "Demo Monitore" }, "emailSettings": { - "buttonSendTestEmail": "", + "buttonSendTestEmail": "Test-Email senden", "description": "", "descriptionTransport": "", "labelAddress": "", "labelConnectionHost": "", "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", + "labelIgnoreTLS": "STARTTLS abschalten: TLS nicht nutzen, selbst wenn der Server es unterstützt", + "labelPassword": "Email password - Password für Anmeldung", + "labelPasswordSet": "Passwort erstellt. Reset drücken um es zu ändern.", "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", + "labelPort": "Email port - Port zum verbinden", + "labelRejectUnauthorized": "Ungültige Zertifikate ablehnen. Verbindungen mit self-signed oder nicht vertrauenswürdigen Zertifikaten ablehnen.", "labelRequireTLS": "", - "labelSecure": "", + "labelSecure": "SSL verwenden (empfohlen): Verbindung mit SSL/TLS verschlüsseln", "labelTLSServername": "", "labelUser": "", "linkTransport": "", - "placeholderUser": "", - "title": "" + "placeholderUser": "Leer lassen, wenn nicht benötigt", + "title": "Email", + "toastEmailRequiredFieldsError": "" }, "pageSpeedSettings": { "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "" + "labelApiKeySet": "API key erstellt. Reset drücken um ihn zu ändern.", + "labelApiKey": "PageSpeed API key", + "title": "Google PageSpeed API key" }, - "saveButtonLabel": "", + "saveButtonLabel": "Speichern", "statsSettings": { "clearAllStatsButton": "", "clearAllStatsDescription": "", @@ -802,35 +816,154 @@ "clearAllStatsDialogTitle": "", "description": "", "labelTTL": "", - "labelTTLOptional": "", + "labelTTLOptional": "0 für unendlich", "title": "" }, "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", - "dialogDescription": "", - "dialogTitle": "", - "title": "" + "buttonRemoveAllMonitors": "Alle Monitore löschen", + "description": "Alle Monitore löschen", + "dialogConfirm": "Ja, alle Monitore löschen", + "dialogDescription": "Gelöschte Monitore können nicht wiederhergestellt werden.", + "dialogTitle": "Wirklich alle Monitore löschen?", + "title": "System zurücksetzen" }, "timezoneSettings": { "description": "", - "label": "", - "title": "" + "label": "Zeitzone", + "title": "Zeitzone" }, - "title": "", + "title": "Einstellungen", "uiSettings": { "description": "", - "labelLanguage": "", + "labelLanguage": "Sprache", "labelTheme": "", "title": "" }, "urlSettings": { "description": "", "label": "", - "selectDisabled": "", - "selectEnabled": "", + "selectDisabled": "Inaktiv", + "selectEnabled": "Aktiv", "title": "" } - } + }, + "statusPageCreate": { + "buttonSave": "Speichern" + }, + "incidentsOptionsHeaderFilterResolved": "", + "settingsSave": "Speichern", + "statusPageCreateAppearanceTitle": "", + "confirmPassword": "Passwort bestätigen", + "monitorHooks": { + "failureAddDemoMonitors": "Demo Monitor konnte nicht hinzugefügt werden", + "successAddDemoMonitors": "Demo Monitor erfolgreich hinzugefügt" + }, + "settingsAppearance": "", + "settingsDisplayTimezone": "", + "settingsGeneralSettings": "", + "incidentsOptionsHeaderTotalIncidents": "", + "statusPage": { + "deleteSuccess": "", + "deleteFailed": "", + "createSuccess": "", + "updateSuccess": "", + "generalSettings": "", + "contents": "", + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "testNotificationsDisabled": "", + "incidentsTableResolvedAt": "", + "incidentsTableActionResolve": "", + "checkHooks": { + "failureResolveOne": "", + "failureResolveAll": "", + "failureResolveMonitor": "" + }, + "checkFormError": "", + "diagnosticsPage": { + "diagnosticDescription": "", + "statsDescription": "", + "gauges": { + "heapAllocationTitle": "", + "heapAllocationSubtitle": "", + "heapUsageTitle": "", + "heapUsageSubtitle": "", + "heapUtilizationTitle": "", + "heapUtilizationSubtitle": "", + "instantCpuUsageTitle": "", + "instantCpuUsageSubtitle": "" + }, + "stats": { + "eventLoopDelayTitle": "", + "uptimeTitle": "", + "usedHeapSizeTitle": "", + "totalHeapSizeTitle": "", + "osMemoryLimitTitle": "" + } + }, + "pageSpeedLighthouseAPI": "", + "time": { + "threeMinutes": "", + "fiveMinutes": "", + "tenMinutes": "", + "twentyMinutes": "", + "oneHour": "", + "oneDay": "", + "oneWeek": "" + }, + "general": { + "noOptionsFound": "" + }, + "infrastructureMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "maintenanceWindow": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "pageSpeed": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "uptimeMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "editUserPage": { + "form": { + "email": "", + "firstName": "", + "lastName": "", + "role": "", + "save": "" + }, + "table": { + "actionHeader": "", + "roleHeader": "" + }, + "title": "", + "toast": { + "successUserUpdate": "", + "validationErrors": "" + } + }, + "incidentsPageActionResolveMonitor": "", + "incidentsPageActionResolveAll": "" } diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b88b1d8a1..78d0f2aed 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1,29 +1,540 @@ { + "submit": "Submit", + "title": "Title", + "distributedStatusHeaderText": "Real-time, real-device coverage", + "distributedStatusSubHeaderText": "Powered by millions devices worldwide, view a system performance by global region, country or city", + "settingsDisabled": "Disabled", + "settingsSuccessSaved": "Settings saved successfully", + "settingsFailedToSave": "Failed to save settings", + "settingsStatsCleared": "Stats cleared successfully", + "settingsFailedToClearStats": "Failed to clear stats", + "settingsMonitorsDeleted": "Successfully deleted all monitors", + "settingsFailedToDeleteMonitors": "Failed to delete all monitors", + "starPromptTitle": "Star Checkmate", + "starPromptDescription": "See the latest releases and help grow the community on GitHub", + "https": "HTTPS", + "http": "HTTP", + "monitor": "monitor", + "aboutus": "About Us", + "now": "Now", + "delete": "Delete", + "configure": "Configure", + "responseTime": "Response time", + "ms": "ms", + "bar": "Bar", + "area": "Area", + "country": "COUNTRY", + "city": "CITY", + "response": "RESPONSE", + "monitorStatusUp": "Monitor {name} ({url}) is now UP and responding", + "monitorStatusDown": "Monitor {name} ({url}) is DOWN and not responding", + "webhookSendSuccess": "Webhook notification sent successfully", + "webhookSendError": "Error sending webhook notification to {platform}", + "webhookUnsupportedPlatform": "Unsupported platform: {platform}", + "distributedRightCategoryTitle": "Monitor", + "distributedStatusServerMonitors": "Server Monitors", + "distributedStatusServerMonitorsDescription": "Monitor status of related servers", + "distributedUptimeCreateSelectURL": "Here you can select the URL of the host, together with the type of monitor.", + "distributedUptimeCreateChecks": "Checks to perform", + "distributedUptimeCreateChecksDescription": "You can always add or remove checks after adding your site.", + "distributedUptimeCreateIncidentNotification": "Incident notifications", + "distributedUptimeCreateIncidentDescription": "When there is an incident, notify users.", + "distributedUptimeCreateAdvancedSettings": "Advanced settings", + "distributedUptimeDetailsNoMonitorHistory": "There is no check history for this monitor yet.", + "distributedUptimeDetailsStatusHeaderUptime": "Uptime:", + "distributedUptimeDetailsStatusHeaderLastUpdate": "Last updated", + "notifications": { + "enableNotifications": "Enable {{platform}} notifications", + "testNotification": "Test notification", + "addOrEditNotifications": "Add or edit notifications", + "slack": { + "label": "Slack", + "description": "To enable Slack notifications, create a Slack app and enable incoming webhooks. After that, simply provide the webhook URL here.", + "webhookLabel": "Webhook URL", + "webhookPlaceholder": "https://hooks.slack.com/services/...", + "webhookRequired": "Slack webhook URL is required" + }, + "discord": { + "label": "Discord", + "description": "To send data to a Discord channel from Checkmate via Discord notifications using webhooks, you can use Discord's incoming Webhooks feature.", + "webhookLabel": "Discord Webhook URL", + "webhookPlaceholder": "https://discord.com/api/webhooks/...", + "webhookRequired": "Discord webhook URL is required" + }, + "telegram": { + "label": "Telegram", + "description": "To enable Telegram notifications, create a Telegram bot using BotFather, an official bot for creating and managing Telegram bots. Then, get the API token and chat ID and write them down here.", + "tokenLabel": "Your bot token", + "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", + "chatIdLabel": "Your Chat ID", + "chatIdPlaceholder": "-1001234567890", + "fieldsRequired": "Telegram token and chat ID are required" + }, + "webhook": { + "label": "Webhooks", + "description": "You can set up a custom webhook to receive notifications when incidents occur.", + "urlLabel": "Webhook URL", + "urlPlaceholder": "https://your-server.com/webhook", + "urlRequired": "Webhook URL is required" + }, + "testNotificationDevelop": "Test notification 2", + "integrationButton": "Notification Integration", + "testSuccess": "Test notification sent successfully!", + "testFailed": "Failed to send test notification", + "unsupportedType": "Unsupported notification type", + "networkError": "Network error occurred", + "fallback": { + "title": "A notification channel is used to:", + "checks": [ + "Alert teams about downtime or performance issues", + "Let engineers know when incidents happen", + "Keep administrators informed of system changes" + ], + "actionButton": "Let's create your first notification channel!" + }, + "createButton": "Create notification channel", + "createTitle": "Notification channel", + "create": { + "success": "Notification created successfully", + "failed": "Failed to create notification" + }, + "fetch": { + "success": "Notifications fetched successfully", + "failed": "Failed to fetch notifications" + }, + "delete": { + "success": "Notification deleted successfully", + "failed": "Failed to delete notification" + }, + "edit": { + "success": "Notification updated successfully", + "failed": "Failed to update notification" + }, + "test": { + "success": "Test notification sent successfully", + "failed": "Failed to send test notification" + } + }, + "testLocale": "testLocale", + "add": "Add", + "monitors": "monitors", + "distributedUptimeStatusCreateStatusPage": "status page", + "distributedUptimeStatusCreateStatusPageAccess": "Access", + "distributedUptimeStatusCreateStatusPageReady": "If your status page is ready, you can mark it as published.", + "distributedUptimeStatusBasicInfoHeader": "Basic Information", + "distributedUptimeStatusBasicInfoDescription": "Define company name and the subdomain that your status page points to.", + "distributedUptimeStatusLogoHeader": "Logo", + "distributedUptimeStatusLogoDescription": "Upload a logo for your status page", + "distributedUptimeStatusLogoUploadButton": "Upload logo", + "distributedUptimeStatusStandardMonitorsHeader": "Standard Monitors", + "distributedUptimeStatusStandardMonitorsDescription": "Attach standard monitors to your status page.", + "distributedUptimeStatusCreateYour": "Create your", + "distributedUptimeStatusEditYour": "Edit your", + "distributedUptimeStatusPublishedLabel": "Published and visible to the public", + "distributedUptimeStatusCompanyNameLabel": "Company name", + "distributedUptimeStatusPageAddressLabel": "Your status page address", + "distributedUptimeStatus30Days": "30 days", + "distributedUptimeStatus60Days": "60 days", + "distributedUptimeStatus90Days": "90 days", + "distributedUptimeStatusPageNotSetUp": "A status page is not set up.", + "distributedUptimeStatusContactAdmin": "Please contact your administrator", + "distributedUptimeStatusPageNotPublic": "This status page is not public.", + "distributedUptimeStatusPageDeleteDialog": "Do you want to delete this status page?", + "distributedUptimeStatusPageDeleteConfirm": "Yes, delete status page", + "distributedUptimeStatusPageDeleteDescription": "Once deleted, your status page cannot be retrieved.", + "distributedUptimeStatusDevices": "Devices", + "distributedUptimeStatusUpt": "UPT", + "distributedUptimeStatusUptBurned": "UPT Burned", + "distributedUptimeStatusUptLogo": "Upt Logo", + "incidentsTableNoIncidents": "No incidents recorded", + "incidentsTablePaginationLabel": "incidents", + "incidentsTableMonitorName": "Monitor Name", + "incidentsTableStatus": "Status", + "incidentsTableDateTime": "Date & Time", + "incidentsTableStatusCode": "Status Code", + "incidentsTableMessage": "Message", + "incidentsOptionsHeader": "Incidents for:", + "incidentsOptionsHeaderFilterBy": "Filter by:", + "incidentsOptionsHeaderFilterAll": "All", + "incidentsOptionsHeaderFilterDown": "Down", + "incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve", + "incidentsOptionsHeaderShow": "Show:", + "incidentsOptionsHeaderLastHour": "Last hour", + "incidentsOptionsHeaderLastDay": "Last day", + "incidentsOptionsHeaderLastWeek": "Last week", + "incidentsOptionsPlaceholderAllServers": "All servers", + "infrastructureCreateYour": "Create your", + "infrastructureCreateGeneralSettingsDescription": "Here you can select the URL of the host, together with the friendly name and authorization secret to connect to the server agent.", + "infrastructureServerRequirement": "The server you are monitoring must be running the", + "infrastructureCustomizeAlerts": "Customize alerts", + "infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.", + "infrastructureCreateMonitor": "Create Infrastructure Monitor", + "infrastructureProtocol": "Protocol", + "infrastructureServerUrlLabel": "Server URL", + "infrastructureDisplayNameLabel": "Display name", + "infrastructureAuthorizationSecretLabel": "Authorization secret", + "gb": "GB", + "mb": "MB", + "mem": "Mem", + "memoryUsage": "Memory usage", + "cpu": "CPU", + "cpuUsage": "CPU usage", + "cpuTemperature": "CPU Temperature", + "diskUsage": "Disk Usage", + "used": "Used", + "total": "Total", + "cores": "Cores", + "frequency": "Frequency", + "status": "Status", + "cpuPhysical": "CPU (Physical)", + "cpuLogical": "CPU (Logical)", + "cpuFrequency": "CPU Frequency", + "avgCpuTemperature": "Average CPU Temperature", + "memory": "Memory", + "disk": "Disk", + "uptime": "Uptime", + "os": "OS", + "host": "Host", + "actions": "Actions", + "integrations": "Integrations", + "integrationsPrism": "Connect Prism to your favorite service.", + "integrationsSlack": "Slack", + "integrationsSlackInfo": "Connect with Slack and see incidents in a channel", + "integrationsDiscord": "Discord", + "integrationsDiscordInfo": "Connect with Discord and view incidents directly in a channel", + "integrationsZapier": "Zapier", + "integrationsZapierInfo": "Send all incidents to Zapier, and then see them everywhere", + "commonSave": "Save", + "createYour": "Create your", + "createMonitor": "Create monitor", + "pause": "Pause", + "resume": "Resume", + "editing": "Editing...", + "url": "URL", + "access": "Access", + "timezone": "Timezone", + "features": "Features", + "administrator": "Administrator?", + "loginHere": "Login here", + "displayName": "Display name", + "urlMonitor": "URL to monitor", + "portToMonitor": "Port to monitor", + "websiteMonitoring": "Website monitoring", + "websiteMonitoringDescription": "Use HTTP(s) to monitor your website or API endpoint.", + "pingMonitoring": "Ping monitoring", + "pingMonitoringDescription": "Check whether your server is available or not.", + "dockerContainerMonitoring": "Docker container monitoring", + "dockerContainerMonitoringDescription": "Check whether your Docker container is running or not.", + "portMonitoring": "Port monitoring", + "portMonitoringDescription": "Check whether your port is open or not.", + "createMaintenanceWindow": "Create maintenance window", + "createMaintenance": "Create maintenance", + "editMaintenance": "Edit maintenance", + "maintenanceWindowName": "Maintenance Window Name", + "friendlyNameInput": "Friendly name", + "friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes", + "maintenanceRepeat": "Maintenance Repeat", + "maintenance": "maintenance", + "duration": "Duration", + "addMonitors": "Add monitors", + "window": "window", + "cancel": "Cancel", + "message": "Message", + "low": "low", + "high": "high", + "statusCode": "Status code", + "date&Time": "Date & Time", + "type": "Type", + "statusPageName": "Status page name", + "publicURL": "Public URL", + "repeat": "Repeat", + "edit": "Edit", + "createA": "Create a", + "remove": "Remove", + "maintenanceWindowDescription": "Your pings won't be sent during this time frame", + "startTime": "Start time", + "timeZoneInfo": "All dates and times are in GMT+0 time zone.", + "monitorsToApply": "Monitors to apply maintenance window to", + "nextWindow": "Next window", + "notFoundButton": "Go to the main dashboard", + "pageSpeedConfigureSettingsDescription": "Here you can select the URL of the host, together with the type of monitor.", + "monitorDisplayName": "Monitor display name", + "whenNewIncident": "When there is a new incident,", + "notifySMS": "Notify via SMS (coming soon)", + "notifyEmails": "Also notify via email to multiple addresses (coming soon)", + "seperateEmails": "You can separate multiple emails with a comma", + "checkFrequency": "Check frequency", + "matchMethod": "Match Method", + "expectedValue": "Expected value", + "deleteDialogTitle": "Do you really want to delete this monitor?", + "deleteDialogDescription": "Once deleted, this monitor cannot be retrieved.", + "pageSpeedMonitor": "PageSpeed monitor", + "shown": "Shown", + "ago": "ago", + "companyName": "Company name", + "pageSpeedDetailsPerformanceReport": "Values are estimated and may vary.", + "pageSpeedDetailsPerformanceReportCalculator": "See calculator", + "checkingEvery": "Checking every", + "statusPageCreateSettings": "If your status page is ready, you can mark it as published.", + "basicInformation": "Basic Information", + "statusPageCreateBasicInfoDescription": "Define company name and the subdomain that your status page points to.", + "statusPageCreateSelectTimeZoneDescription": "Select the timezone that your status page will be displayed in.", + "statusPageCreateAppearanceDescription": "Define the default look and feel of your public status page.", + "statusPageCreateSettingsCheckboxLabel": "Published and visible to the public", + "statusPageCreateBasicInfoStatusPageAddress": "Your status page address", + "statusPageCreateTabsContent": "Status page servers", + "statusPageCreateTabsContentDescription": "You can add any number of servers that you monitor to your status page. You can also reorder them for the best viewing experience.", + "statusPageCreateTabsContentFeaturesDescription": "Show more details on the status page", + "showCharts": "Show charts", + "showUptimePercentage": "Show uptime percentage", + "removeLogo": "Remove Logo", + "statusPageStatus": "A public status page is not set up.", + "statusPageStatusContactAdmin": "Please contact to your administrator", + "statusPageStatusNotPublic": "This status page is not public.", + "statusPageStatusNoPage": "There's no status page here.", + "statusPageStatusServiceStatus": "Service status", + "deleteStatusPage": "Do you want to delete this status page?", + "deleteStatusPageConfirm": "Yes, delete status page", + "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", + "uptimeCreate": "The expected value is used to match against response result, and the match determines the status.", + "uptimeCreateJsonPath": "This expression will be evaluated against the reponse JSON data and the result will be used to match against the expected value. See", + "uptimeCreateJsonPathQuery": "for query language documentation.", + "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", + "infrastructureEditYour": "Edit your", + "infrastructureEditMonitor": "Save Infrastructure Monitor", + "infrastructureMonitorCreated": "Infrastructure monitor created successfully!", + "infrastructureMonitorUpdated": "Infrastructure monitor updated successfully!", + "errorInvalidTypeId": "Invalid notification type provided", + "errorInvalidFieldId": "Invalid field ID provided", + "inviteNoTokenFound": "No invite token found", + "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", + "pageSpeedLearnMoreLink": "Click here", + "pageSpeedAddApiKey": "to add your API key.", + "update": "Update", + "invalidFileFormat": "Unsupported file format!", + "invalidFileSize": "File size is too large!", "ClickUpload": "Click to upload", - "DeleteAccountButton": "Remove account", - "DeleteAccountTitle": "Remove account", - "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", - "DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.", - "DeleteWarningTitle": "Really remove this account?", "DragandDrop": "drag and drop", - "EmailDescriptionText": "This is your current email address — it cannot be changed.", + "MaxSize": "Maximum Size", + "SupportedFormats": "Supported formats", "FirstName": "First name", "LastName": "Last name", - "MaxSize": "Maximum Size", - "PhotoDescriptionText": "This photo will be displayed in your profile page.", - "SupportedFormats": "Supported formats", + "EmailDescriptionText": "This is your current email address — it cannot be changed.", "YourPhoto": "Profile photo", - "aboutus": "About Us", - "access": "Access", - "actions": "Actions", - "add": "Add", - "addMonitors": "Add monitors", - "administrator": "Administrator?", - "advancedMatching": "Advanced matching", - "ago": "ago", - "area": "Area", + "PhotoDescriptionText": "This photo will be displayed in your profile page.", + "save": "Save", + "DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.", + "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", + "DeleteWarningTitle": "Really remove this account?", + "bulkImport": { + "title": "Bulk Import", + "selectFileTips": "Select CSV file to upload", + "selectFileDescription": "You can download our or sample", + "selectFile": "Select File", + "parsingFailed": "Parsing failed", + "uploadSuccess": "Monitors created successfully!", + "validationFailed": "Validation failed", + "noFileSelected": "No file selected", + "fallbackPage": "Import a file to upload a list of servers in bulk", + "invalidFileType": "Invalid file type", + "uploadFailed": "Upload failed" + }, + "DeleteAccountTitle": "Remove account", + "DeleteAccountButton": "Remove account", + "publicLink": "Public link", + "maskedPageSpeedKeyPlaceholder": "*************************************", + "reset": "Reset", + "ignoreTLSError": "Ignore TLS/SSL error", + "tlsErrorIgnored": "TLS/SSL errors ignored", + "ignoreTLSErrorDescription": "Ignore TLS/SSL errors and continue checking the website's availability", + "createNew": "Create new", + "greeting": { + "prepend": "Hey there", + "append": "The afternoon is your playground—let's make it epic!", + "overview": "Here's an overview of your {{type}} monitors." + }, + "roles": { + "superAdmin": "Super admin", + "admin": "Admin", + "teamMember": "Team member", + "demoUser": "Demo user" + }, + "teamPanel": { + "teamMembers": "Team members", + "filter": { + "all": "All", + "member": "Member" + }, + "inviteTeamMember": "Invite a team member", + "inviteNewTeamMember": "Invite new team member", + "inviteDescription": "When you add a new team member, they will get access to all monitors.", + "email": "Email", + "selectRole": "Select role", + "inviteLink": "Invite link", + "cancel": "Cancel", + "noMembers": "There are no team members with this role", + "getToken": "Get token", + "emailToken": "E-mail token", + "table": { + "name": "Name", + "email": "Email", + "role": "Role", + "created": "Created" + } + }, + "monitorState": { + "paused": "Paused", + "resumed": "Resumed", + "active": "Active" + }, + "menu": { + "uptime": "Uptime", + "pagespeed": "Pagespeed", + "infrastructure": "Infrastructure", + "incidents": "Incidents", + "statusPages": "Status pages", + "maintenance": "Maintenance", + "integrations": "Integrations", + "settings": "Settings", + "support": "Support", + "discussions": "Discussions", + "docs": "Docs", + "changelog": "Changelog", + "profile": "Profile", + "password": "Password", + "team": "Team", + "logOut": "Log out", + "notifications": "Notifications", + "logs": "Logs" + }, + "settingsEmailUser": "Email user - Username for authentication, overrides email address if specified", + "state": "State", + "statusBreadCrumbsStatusPages": "Status Pages", + "statusBreadCrumbsDetails": "Details", + "commonSaving": "Saving...", + "navControls": "Controls", + "incidentsPageTitle": "Incidents", + "passwordPanel": { + "passwordChangedSuccess": "Your password was changed successfully.", + "passwordInputIncorrect": "Your password input was incorrect.", + "currentPassword": "Current password", + "enterCurrentPassword": "Enter your current password", + "newPassword": "New password", + "enterNewPassword": "Enter your new password", + "confirmNewPassword": "Confirm new password", + "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character.", + "saving": "Saving..." + }, + "emailSent": "Email sent successfully", + "failedToSendEmail": "Failed to send email", + "settingsTestEmailSuccess": "Test email sent successfully", + "settingsTestEmailFailed": "Failed to send test email", + "settingsTestEmailFailedWithReason": "Failed to send test email: {{reason}}", + "settingsTestEmailUnknownError": "Unknown error", + "statusMsg": { + "paused": "Monitoring is paused.", + "up": "Your site is up.", + "down": "Your site is down.", + "pending": "Pending..." + }, + "uptimeGeneralInstructions": { + "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", + "docker": "Enter the Docker ID of your container. Docker IDs must be the full 64 char Docker ID. You can run docker inspect to get the full container ID.", + "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard." + }, + "common": { + "appName": "Checkmate", + "monitoringAgentName": "Capture", + "buttons": { + "toggleTheme": "Toggles light & dark" + }, + "toasts": { + "networkError": "Network error", + "checkConnection": "Please check your connection", + "unknownError": "Unknown error" + } + }, "auth": { "common": { + "navigation": { + "continue": "Continue", + "back": "Back" + }, + "inputs": { + "email": { + "label": "Email", + "placeholder": "jordan.ellis@domain.com", + "errors": { + "empty": "To continue, please enter your email address", + "invalid": "Please recheck validity of entered email address" + } + }, + "password": { + "label": "Password", + "rules": { + "length": { + "beginning": "Must be at least", + "highlighted": "8 characters long" + }, + "special": { + "beginning": "Must contain at least", + "highlighted": "one special character" + }, + "number": { + "beginning": "Must contain at least", + "highlighted": "one number" + }, + "uppercase": { + "beginning": "Must contain at least", + "highlighted": "one upper character" + }, + "lowercase": { + "beginning": "Must contain at least", + "highlighted": "one lower character" + }, + "match": { + "beginning": "Confirm password and password", + "highlighted": "must match" + } + }, + "errors": { + "empty": "Please enter your password", + "length": "Password must be at least 8 characters long", + "uppercase": "Password must contain at least 1 uppercase letter", + "lowercase": "Password must contain at least 1 lowercase letter", + "number": "Password must contain at least 1 number", + "special": "Password must contain at least 1 special character", + "incorrect": "The password you provided does not match our records" + } + }, + "passwordConfirm": { + "label": "Confirm password", + "placeholder": "Re-enter password to confirm", + "errors": { + "empty": "Please enter your password again for confirmation (helps with typos)", + "different": "Entered passwords don't match, so one of them is probably mistyped" + } + }, + "firstName": { + "label": "Name", + "placeholder": "Jordan", + "errors": { + "empty": "Please enter your name", + "length": "Name must be less than 50 characters", + "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" + } + }, + "lastName": { + "label": "Surname", + "placeholder": "Ellis", + "errors": { + "empty": "Please enter your surname", + "length": "Surname must be less than 50 characters", + "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" + } + } + }, "errors": { "validation": "Error validating data." }, @@ -38,139 +549,40 @@ "min": "At least one role is required" } } - }, - "inputs": { - "email": { - "errors": { - "empty": "To continue, please enter your email address", - "invalid": "Please recheck validity of entered email address" - }, - "label": "Email", - "placeholder": "jordan.ellis@domain.com" - }, - "firstName": { - "errors": { - "empty": "Please enter your name", - "length": "Name must be less than 50 characters", - "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" - }, - "label": "Name", - "placeholder": "Jordan" - }, - "lastName": { - "errors": { - "empty": "Please enter your surname", - "length": "Surname must be less than 50 characters", - "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" - }, - "label": "Surname", - "placeholder": "Ellis" - }, - "password": { - "errors": { - "empty": "Please enter your password", - "incorrect": "The password you provided does not match our records", - "length": "Password must be at least 8 characters long", - "lowercase": "Password must contain at least 1 lowercase letter", - "number": "Password must contain at least 1 number", - "special": "Password must contain at least 1 special character", - "uppercase": "Password must contain at least 1 uppercase letter" - }, - "label": "Password", - "rules": { - "length": { - "beginning": "Must be at least", - "highlighted": "8 characters long" - }, - "lowercase": { - "beginning": "Must contain at least", - "highlighted": "one lower character" - }, - "match": { - "beginning": "Confirm password and password", - "highlighted": "must match" - }, - "number": { - "beginning": "Must contain at least", - "highlighted": "one number" - }, - "special": { - "beginning": "Must contain at least", - "highlighted": "one special character" - }, - "uppercase": { - "beginning": "Must contain at least", - "highlighted": "one upper character" - } - } - }, - "passwordConfirm": { - "errors": { - "different": "Entered passwords don't match, so one of them is probably mistyped", - "empty": "Please enter your password again for confirmation (helps with typos)" - }, - "label": "Confirm password", - "placeholder": "Re-enter password to confirm" - } - }, - "navigation": { - "back": "Back", - "continue": "Continue" - } - }, - "forgotPassword": { - "buttons": { - "openEmail": "Open email app", - "resetPassword": "Reset password" - }, - "heading": "Forgot password?", - "imageAlts": { - "email": "Email icon", - "lock": "Lock icon", - "passwordConfirm": "Password confirm icon", - "passwordKey": "Password key icon" - }, - "links": { - "login": "Go back to Log In", - "resend": "Didn't receive the email? Click to resend" - }, - "subheadings": { - "stepFour": "Your password has been successfully reset. Click below to log in magically.", - "stepOne": "No worries, we'll send you reset instructions.", - "stepThree": "Your new password must be different from previously used passwords.", - "stepTwo": "We sent a password reset link to " - }, - "toasts": { - "emailNotFound": "Email not found.", - "error": "Unable to reset password. Please try again later or contact support.", - "redirect": "Redirecting in ...", - "sent": "Instructions sent to .", - "success": "Your password was reset successfully." } }, "login": { - "errors": { - "password": { - "incorrect": "The password you provided does not match our records" - } - }, "heading": "Log In", - "links": { - "forgotPassword": "Forgot password?", - "forgotPasswordLink": "Reset password", - "register": "Do not have an account?", - "registerLink": "Register here" - }, "subheadings": { "stepOne": "Enter your email", "stepTwo": "Enter your password" }, + "links": { + "forgotPassword": "Forgot password?", + "register": "Do not have an account?", + "forgotPasswordLink": "Reset password", + "registerLink": "Register here" + }, "toasts": { - "incorrectPassword": "Incorrect password", - "success": "Welcome back! You're successfully logged in." + "success": "Welcome back! You're successfully logged in.", + "incorrectPassword": "Incorrect password" + }, + "errors": { + "password": { + "incorrect": "The password you provided does not match our records" + } } }, "registration": { + "heading": { + "superAdmin": "Create super admin", + "user": "Sign Up" + }, + "subheadings": { + "stepOne": "Enter your personal details", + "stepTwo": "Enter your email", + "stepThree": "Create your password" + }, "description": { "superAdmin": "Create your super admin account to get started", "user": "Sign up as a user and ask super admin for access to your monitors" @@ -179,604 +591,187 @@ "superAdmin": "Create super admin account", "user": "Sign up regular user" }, - "heading": { - "superAdmin": "Create super admin", - "user": "Sign Up" - }, + "termsAndPolicies": "By creating an account, you agree to our Terms of Service and Privacy Policy.", "links": { "login": "Already have an account? Log In" }, - "subheadings": { - "stepOne": "Enter your personal details", - "stepThree": "Create your password", - "stepTwo": "Enter your email" - }, - "termsAndPolicies": "By creating an account, you agree to our Terms of Service and Privacy Policy.", "toasts": { "success": "Welcome! Your account was created successfully." } + }, + "forgotPassword": { + "heading": "Forgot password?", + "subheadings": { + "stepOne": "No worries, we'll send you reset instructions.", + "stepTwo": "We sent a password reset link to ", + "stepThree": "Your new password must be different from previously used passwords.", + "stepFour": "Your password has been successfully reset. Click below to log in magically." + }, + "buttons": { + "openEmail": "Open email app", + "resetPassword": "Reset password" + }, + "imageAlts": { + "passwordKey": "Password key icon", + "email": "Email icon", + "lock": "Lock icon", + "passwordConfirm": "Password confirm icon" + }, + "links": { + "login": "Go back to Log In", + "resend": "Didn't receive the email? Click to resend" + }, + "toasts": { + "sent": "Instructions sent to .", + "emailNotFound": "Email not found.", + "redirect": "Redirecting in ...", + "success": "Your password was reset successfully.", + "error": "Unable to reset password. Please try again later or contact support." + } } }, - "avgCpuTemperature": "Average CPU Temperature", - "bar": "Bar", - "basicInformation": "Basic Information", - "bulkImport": { - "fallbackPage": "Import a file to upload a list of servers in bulk", - "invalidFileType": "Invalid file type", - "noFileSelected": "No file selected", - "parsingFailed": "Parsing failed", - "selectFile": "Select File", - "selectFileDescription": "You can download our or sample", - "selectFileTips": "Select CSV file to upload", - "title": "Bulk Import", - "uploadFailed": "Upload failed", - "uploadSuccess": "Monitors created successfully!", - "validationFailed": "Validation failed" - }, - "cancel": "Cancel", - "checkFormError": "Please check the form for errors.", - "checkFrequency": "Check frequency", - "checkHooks": { - "failureResolveAll": "Failed to resolve all incidents.", - "failureResolveMonitor": "Failed to resolve monitor incidents.", - "failureResolveOne": "Failed to resolve incident." - }, - "checkingEvery": "Checking every", - "city": "CITY", - "common": { - "appName": "Checkmate", - "buttons": { - "toggleTheme": "Toggles light & dark" - }, - "monitoringAgentName": "Capture", - "toasts": { - "checkConnection": "Please check your connection", - "networkError": "Network error", - "unknownError": "Unknown error" - } - }, - "commonSave": "Save", - "commonSaving": "Saving...", - "companyName": "Company name", - "configure": "Configure", - "confirmPassword": "Confirm password", - "cores": "Cores", - "country": "COUNTRY", - "cpu": "CPU", - "cpuFrequency": "CPU Frequency", - "cpuLogical": "CPU (Logical)", - "cpuPhysical": "CPU (Physical)", - "cpuTemperature": "CPU Temperature", - "cpuUsage": "CPU usage", - "createA": "Create a", - "createMaintenance": "Create maintenance", - "createMaintenanceWindow": "Create maintenance window", - "createMonitor": "Create monitor", - "createNew": "Create new", - "createNotifications": { - "dialogDeleteConfirm": "Delete", - "dialogDeleteTitle": "Are you sure you want to delete this notification?", - "discordSettings": { - "description": "Configure your Discord webhook here", - "title": "Discord", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "emailSettings": { - "description": "Destination email addresses.", - "emailLabel": "Email address", - "emailPlaceholder": "e.g. john@example.com", - "title": "Email" - }, - "nameSettings": { - "description": "A descriptive name for your integration.", - "nameLabel": "Name", - "namePlaceholder": "e.g. Slack notifications", - "title": "Name" - }, - "pagerdutySettings": { - "description": "Configure your PagerDuty integration here", - "integrationKeyLabel": "Integration key", - "integrationKeyPlaceholder": "1234567890", - "title": "PagerDuty" - }, - "slackSettings": { - "description": "Configure your Slack webhook here", - "title": "Slack", - "webhookLabel": "Slack webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/..." - }, - "testNotification": "Test notification", - "title": "Create notification channel", - "typeSettings": { - "description": "Select the type of notification channel you want to create.", - "title": "Type", - "typeLabel": "Type" - }, - "webhookSettings": { - "description": "Configure your webhook here", - "title": "Webhook", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - } - }, - "createYour": "Create your", - "date&Time": "Date & Time", - "delete": "Delete", - "deleteDialogDescription": "Once deleted, this monitor cannot be retrieved.", - "deleteDialogTitle": "Do you really want to delete this monitor?", - "deleteStatusPage": "Do you want to delete this status page?", - "deleteStatusPageConfirm": "Yes, delete status page", - "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", - "diagnosticsPage": { - "diagnosticDescription": "System diagnostics", - "gauges": { - "heapAllocationSubtitle": "% of available memory", - "heapAllocationTitle": "Heap allocation", - "heapUsageSubtitle": "% of available memory", - "heapUsageTitle": "Heap usage", - "heapUtilizationSubtitle": "% of allocated", - "heapUtilizationTitle": "Heap utilization", - "instantCpuUsageSubtitle": "% of 1s used by CPU", - "instantCpuUsageTitle": "Instant CPU usage" - }, - "stats": { - "eventLoopDelayTitle": "Event loop delay", - "osMemoryLimitTitle": "OS Memory Limit", - "totalHeapSizeTitle": "Total heap size", - "uptimeTitle": "Uptime", - "usedHeapSizeTitle": "Used heap size" - }, - "statsDescription": "System statistics" - }, - "disk": "Disk", - "diskUsage": "Disk Usage", - "displayName": "Display name", - "distributedRightCategoryTitle": "Monitor", - "distributedStatusHeaderText": "Real-time, real-device coverage", - "distributedStatusServerMonitors": "Server Monitors", - "distributedStatusServerMonitorsDescription": "Monitor status of related servers", - "distributedStatusSubHeaderText": "Powered by millions devices worldwide, view a system performance by global region, country or city", - "distributedUptimeCreateAdvancedSettings": "Advanced settings", - "distributedUptimeCreateChecks": "Checks to perform", - "distributedUptimeCreateChecksDescription": "You can always add or remove checks after adding your site.", - "distributedUptimeCreateIncidentDescription": "When there is an incident, notify users.", - "distributedUptimeCreateIncidentNotification": "Incident notifications", - "distributedUptimeCreateSelectURL": "Here you can select the URL of the host, together with the type of monitor.", - "distributedUptimeDetailsNoMonitorHistory": "There is no check history for this monitor yet.", - "distributedUptimeDetailsStatusHeaderLastUpdate": "Last updated", - "distributedUptimeDetailsStatusHeaderUptime": "Uptime:", - "distributedUptimeStatus30Days": "30 days", - "distributedUptimeStatus60Days": "60 days", - "distributedUptimeStatus90Days": "90 days", - "distributedUptimeStatusBasicInfoDescription": "Define company name and the subdomain that your status page points to.", - "distributedUptimeStatusBasicInfoHeader": "Basic Information", - "distributedUptimeStatusCompanyNameLabel": "Company name", - "distributedUptimeStatusContactAdmin": "Please contact your administrator", - "distributedUptimeStatusCreateStatusPage": "status page", - "distributedUptimeStatusCreateStatusPageAccess": "Access", - "distributedUptimeStatusCreateStatusPageReady": "If your status page is ready, you can mark it as published.", - "distributedUptimeStatusCreateYour": "Create your", - "distributedUptimeStatusDevices": "Devices", - "distributedUptimeStatusEditYour": "Edit your", - "distributedUptimeStatusLogoDescription": "Upload a logo for your status page", - "distributedUptimeStatusLogoHeader": "Logo", - "distributedUptimeStatusLogoUploadButton": "Upload logo", - "distributedUptimeStatusPageAddressLabel": "Your status page address", - "distributedUptimeStatusPageDeleteConfirm": "Yes, delete status page", - "distributedUptimeStatusPageDeleteDescription": "Once deleted, your status page cannot be retrieved.", - "distributedUptimeStatusPageDeleteDialog": "Do you want to delete this status page?", - "distributedUptimeStatusPageNotPublic": "This status page is not public.", - "distributedUptimeStatusPageNotSetUp": "A status page is not set up.", - "distributedUptimeStatusPublishedLabel": "Published and visible to the public", - "distributedUptimeStatusStandardMonitorsDescription": "Attach standard monitors to your status page.", - "distributedUptimeStatusStandardMonitorsHeader": "Standard Monitors", - "distributedUptimeStatusUpt": "UPT", - "distributedUptimeStatusUptBurned": "UPT Burned", - "distributedUptimeStatusUptLogo": "Upt Logo", - "dockerContainerMonitoring": "Docker container monitoring", - "dockerContainerMonitoringDescription": "Check whether your Docker container is running or not.", - "duration": "Duration", - "edit": "Edit", - "editMaintenance": "Edit maintenance", - "editUserPage": { - "form": { - "email": "Email", - "firstName": "First name", - "lastName": "Last name", - "role": "Roles", - "save": "Save" - }, - "table": { - "actionHeader": "Action", - "roleHeader": "Role" - }, - "title": "Edit user", - "toast": { - "successUserUpdate": "User updated successfully", - "validationErrors": "Validation errors" - } - }, - "editing": "Editing...", - "emailSent": "Email sent successfully", - "errorInvalidFieldId": "Invalid field ID provided", - "errorInvalidTypeId": "Invalid notification type provided", "errorPages": { "serverUnreachable": { + "toasts": { + "reconnected": "Successfully reconnected to the server.", + "stillUnreachable": "Server is still unreachable. Please try again later." + }, "alertBox": "Server Connection Error", "description": "We're unable to connect to the server. Please check your internet connection or verify your deployment configuration if the problem persists.", "retryButton": { "default": "Retry connection", "processing": "Connecting..." - }, - "toasts": { - "reconnected": "Successfully reconnected to the server.", - "stillUnreachable": "Server is still unreachable. Please try again later." } } }, - "expectedValue": "Expected value", - "export": { - "failed": "Failed to export monitors", - "success": "Monitors exported successfully!", - "title": "Export Monitors" + "createNotifications": { + "title": "Create notification channel", + "nameSettings": { + "title": "Name", + "description": "A descriptive name for your integration.", + "nameLabel": "Name", + "namePlaceholder": "e.g. Slack notifications" + }, + "typeSettings": { + "title": "Type", + "description": "Select the type of notification channel you want to create.", + "typeLabel": "Type" + }, + "emailSettings": { + "title": "Email", + "description": "Destination email addresses.", + "emailLabel": "Email address", + "emailPlaceholder": "e.g. john@example.com" + }, + "slackSettings": { + "title": "Slack", + "description": "Configure your Slack webhook here", + "webhookLabel": "Slack webhook URL", + "webhookPlaceholder": "https://hooks.slack.com/services/..." + }, + "pagerdutySettings": { + "title": "PagerDuty", + "description": "Configure your PagerDuty integration here", + "integrationKeyLabel": "Integration key", + "integrationKeyPlaceholder": "1234567890" + }, + "discordSettings": { + "title": "Discord", + "description": "Configure your Discord webhook here", + "webhookLabel": "Discord Webhook URL", + "webhookPlaceholder": "https://your-server.com/webhook" + }, + "webhookSettings": { + "title": "Webhook", + "description": "Configure your webhook here", + "webhookLabel": "Webhook URL", + "webhookPlaceholder": "https://your-server.com/webhook" + }, + "testNotification": "Test notification", + "dialogDeleteTitle": "Are you sure you want to delete this notification?", + "dialogDeleteConfirm": "Delete" }, - "failedToSendEmail": "Failed to send email", - "features": "Features", - "frequency": "Frequency", - "friendlyNameInput": "Friendly name", - "friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes", - "gb": "GB", - "general": { - "noOptionsFound": "No {{unit}} found" + "notificationConfig": { + "title": "Notifications", + "description": "Select the notifications channels you want to use" }, - "greeting": { - "append": "The afternoon is your playground—let's make it epic!", - "overview": "Here's an overview of your {{type}} monitors.", - "prepend": "Hey there" + "monitorStatus": { + "checkingEvery": "Checking every {{interval}}", + "withCaptureAgent": "with Capture agent {{version}}", + "up": "up", + "down": "down", + "paused": "paused" }, - "high": "high", - "host": "Host", - "http": "HTTP", - "https": "HTTPS", - "ignoreTLSError": "Ignore TLS/SSL error", - "ignoreTLSErrorDescription": "Ignore TLS/SSL errors and continue checking the website's availability", - "incidentsOptionsHeader": "Incidents for:", - "incidentsOptionsHeaderFilterAll": "All", - "incidentsOptionsHeaderFilterBy": "Filter by:", - "incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve", - "incidentsOptionsHeaderFilterDown": "Down", - "incidentsOptionsHeaderFilterResolved": "Resolved", - "incidentsOptionsHeaderLastDay": "Last day", - "incidentsOptionsHeaderLastHour": "Last hour", - "incidentsOptionsHeaderLastWeek": "Last week", - "incidentsOptionsHeaderShow": "Show:", - "incidentsOptionsHeaderTotalIncidents": "Total Incidents", - "incidentsOptionsPlaceholderAllServers": "All servers", - "incidentsPageActionResolveMonitor": "Resolve monitor incidents", - "incidentsPageActionResolveAll": "Resolve all incidents", - "incidentsPageTitle": "Incidents", - "incidentsTableActionResolve": "Resolve", - "incidentsTableDateTime": "Date & Time", - "incidentsTableMessage": "Message", - "incidentsTableMonitorName": "Monitor Name", - "incidentsTableNoIncidents": "No incidents recorded", - "incidentsTablePaginationLabel": "incidents", - "incidentsTableResolvedAt": "Resolved at", - "incidentsTableStatus": "Status", - "incidentsTableStatusCode": "Status Code", - "infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.", - "infrastructureAuthorizationSecretLabel": "Authorization secret", - "infrastructureCreateGeneralSettingsDescription": "Here you can select the URL of the host, together with the friendly name and authorization secret to connect to the server agent.", - "infrastructureCreateMonitor": "Create Infrastructure Monitor", - "infrastructureCreateYour": "Create your", - "infrastructureCustomizeAlerts": "Customize alerts", - "infrastructureDisplayNameLabel": "Display name", - "infrastructureEditMonitor": "Save Infrastructure Monitor", - "infrastructureEditYour": "Edit your", - "infrastructureMonitor": { - "fallback": { - "checks": [ - "Track the performance of your servers", - "Identify bottlenecks and optimize usage", - "Ensure reliability with real-time monitoring" - ], - "title": "An infrastructure monitor is used to:", - "actionButton": "Let's create your first infrastructure monitor!" - } - }, - "infrastructureMonitorCreated": "Infrastructure monitor created successfully!", - "infrastructureMonitorUpdated": "Infrastructure monitor updated successfully!", - "infrastructureProtocol": "Protocol", - "infrastructureServerRequirement": "The server you are monitoring must be running the", - "infrastructureServerUrlLabel": "Server URL", - "integrations": "Integrations", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "Connect with Discord and view incidents directly in a channel", - "integrationsPrism": "Connect Prism to your favorite service.", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "Connect with Slack and see incidents in a channel", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "Send all incidents to Zapier, and then see them everywhere", - "invalidFileFormat": "Unsupported file format!", - "invalidFileSize": "File size is too large!", - "inviteNoTokenFound": "No invite token found", - "loginHere": "Login here", + "advancedMatching": "Advanced matching", + "sendTestNotifications": "Send test notifications", + "selectAll": "Select all", + "showAdminLoginLink": "Show \"Administrator? Login Here\" link on the status page", "logsPage": { + "title": "Logs", "description": "System logs - last 1000 lines", + "tabs": { + "queue": "Job queue", + "logs": "Server logs", + "diagnostics": "Diagnostics" + }, + "toast": { + "fetchLogsSuccess": "Logs fetched successfully" + }, "logLevelSelect": { "title": "Log level", "values": { "all": "All", - "debug": "Debug", - "error": "Error", "info": "Info", - "warn": "Warn" + "warn": "Warn", + "error": "Error", + "debug": "Debug" } - }, - "tabs": { - "diagnostics": "Diagnostics", - "logs": "Server logs", - "queue": "Job queue" - }, - "title": "Logs", - "toast": { - "fetchLogsSuccess": "Logs fetched successfully" } }, - "low": "low", - "maintenance": "maintenance", - "maintenanceRepeat": "Maintenance Repeat", - "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", - "maintenanceWindow": { - "fallback": { - "checks": [ - "Mark your maintenance periods", - "Eliminate any misunderstandings", - "Stop sending alerts in maintenance windows" - ], - "title": "A maintenance window is used to:", - "actionButton": "Let's create your first maintenance window!" - } - }, - "maintenanceWindowDescription": "Your pings won't be sent during this time frame", - "maintenanceWindowName": "Maintenance Window Name", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "matchMethod": "Match Method", - "mb": "MB", - "mem": "Mem", - "memory": "Memory", - "memoryUsage": "Memory usage", - "menu": { - "changelog": "Changelog", - "discussions": "Discussions", - "docs": "Docs", - "incidents": "Incidents", - "infrastructure": "Infrastructure", - "integrations": "Integrations", - "logOut": "Log out", - "logs": "Logs", - "maintenance": "Maintenance", - "notifications": "Notifications", - "pagespeed": "Pagespeed", - "password": "Password", - "profile": "Profile", - "settings": "Settings", - "statusPages": "Status pages", - "support": "Support", - "team": "Team", - "uptime": "Uptime" - }, - "message": "Message", - "monitor": "monitor", - "monitorActions": { - "deleteFailed": "Failed to delete monitor", - "deleteSuccess": "Monitor deleted successfully", - "details": "Details", - "export": "Export Monitors", - "import": "Import Monitors", - "title": "Export/Import" - }, - "monitorDisplayName": "Monitor display name", - "monitorHooks": { - "failureAddDemoMonitors": "Failed to add demo monitors", - "successAddDemoMonitors": "Successfully added demo monitors" - }, - "monitorState": { - "active": "Active", - "paused": "Paused", - "resumed": "Resumed" - }, - "monitorStatus": { - "checkingEvery": "Checking every {{interval}}", - "down": "down", - "paused": "paused", - "up": "up", - "withCaptureAgent": "with Capture agent {{version}}" - }, - "monitorStatusDown": "Monitor {name} ({url}) is DOWN and not responding", - "monitorStatusUp": "Monitor {name} ({url}) is now UP and responding", - "monitors": "monitors", - "monitorsToApply": "Monitors to apply maintenance window to", - "ms": "ms", - "navControls": "Controls", - "nextWindow": "Next window", - "notFoundButton": "Go to the main dashboard", - "notificationConfig": { - "description": "Select the notifications channels you want to use", - "title": "Notifications" - }, - "notifications": { - "addOrEditNotifications": "Add or edit notifications", - "create": { - "failed": "Failed to create notification", - "success": "Notification created successfully" - }, - "createButton": "Create notification channel", - "createTitle": "Notification channel", - "delete": { - "failed": "Failed to delete notification", - "success": "Notification deleted successfully" - }, - "discord": { - "description": "To send data to a Discord channel from Checkmate via Discord notifications using webhooks, you can use Discord's incoming Webhooks feature.", - "label": "Discord", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "Discord webhook URL is required" - }, - "edit": { - "failed": "Failed to update notification", - "success": "Notification updated successfully" - }, - "enableNotifications": "Enable {{platform}} notifications", - "fallback": { - "checks": [ - "Alert teams about downtime or performance issues", - "Let engineers know when incidents happen", - "Keep administrators informed of system changes" - ], - "title": "A notification channel is used to:", - "actionButton": "Let's create your first notification channel!" - }, - "fetch": { - "failed": "Failed to fetch notifications", - "success": "Notifications fetched successfully" - }, - "integrationButton": "Notification Integration", - "networkError": "Network error occurred", - "slack": { - "description": "To enable Slack notifications, create a Slack app and enable incoming webhooks. After that, simply provide the webhook URL here.", - "label": "Slack", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "Slack webhook URL is required" - }, - "telegram": { - "chatIdLabel": "Your Chat ID", - "chatIdPlaceholder": "-1001234567890", - "description": "To enable Telegram notifications, create a Telegram bot using BotFather, an official bot for creating and managing Telegram bots. Then, get the API token and chat ID and write them down here.", - "fieldsRequired": "Telegram token and chat ID are required", - "label": "Telegram", - "tokenLabel": "Your bot token", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" - }, - "test": { - "failed": "Failed to send test notification", - "success": "Test notification sent successfully" - }, - "testFailed": "Failed to send test notification", - "testNotification": "Test notification", - "testNotificationDevelop": "Test notification 2", - "testSuccess": "Test notification sent successfully!", - "unsupportedType": "Unsupported notification type", - "webhook": { - "description": "You can set up a custom webhook to receive notifications when incidents occur.", - "label": "Webhooks", - "urlLabel": "Webhook URL", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "Webhook URL is required" - } - }, - "notifyEmails": "Also notify via email to multiple addresses (coming soon)", - "notifySMS": "Notify via SMS (coming soon)", - "now": "Now", - "os": "OS", - "pageSpeed": { - "fallback": { - "checks": [ - "Report on the user experience of a page", - "Help analyze webpage speed", - "Give suggestions on how the page can be improved" - ], - "title": "A PageSpeed monitor is used to:", - "actionButton": "Let's create your first PageSpeed monitor!" - } - }, - "pageSpeedAddApiKey": "to add your API key.", - "pageSpeedConfigureSettingsDescription": "Here you can select the URL of the host, together with the type of monitor.", - "pageSpeedDetailsPerformanceReport": "Values are estimated and may vary.", - "pageSpeedDetailsPerformanceReportCalculator": "See calculator", - "pageSpeedLearnMoreLink": "Click here", - "pageSpeedLighthouseAPI": "Use the Lighthouse PageSpeed API to monitor your website", - "pageSpeedMonitor": "PageSpeed monitor", - "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", - "passwordPanel": { - "confirmNewPassword": "Confirm new password", - "currentPassword": "Current password", - "enterCurrentPassword": "Enter your current password", - "enterNewPassword": "Enter your new password", - "newPassword": "New password", - "passwordChangedSuccess": "Your password was changed successfully.", - "passwordInputIncorrect": "Your password input was incorrect.", - "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character.", - "saving": "Saving..." - }, - "pause": "Pause", - "pingMonitoring": "Ping monitoring", - "pingMonitoringDescription": "Check whether your server is available or not.", - "portMonitoring": "Port monitoring", - "portMonitoringDescription": "Check whether your port is open or not.", - "portToMonitor": "Port to monitor", - "publicLink": "Public link", - "publicURL": "Public URL", "queuePage": { - "failedJobTable": { - "failCountHeader": "Fail count", - "failReasonHeader": "Fail reason", - "failedAtHeader": "Last failed at", - "monitorIdHeader": "Monitor ID", - "monitorUrlHeader": "Monitor URL", - "title": "Failed jobs" - }, + "title": "Queue", + "refreshButton": "Refresh", "flushButton": "Flush queue", "jobTable": { - "activeHeader": "Active", - "failCountHeader": "Fail count", + "title": "Jobs currently in queue", "idHeader": "Monitor ID", - "lastFinishedAtHeader": "Last finished at", - "lastRunHeader": "Last run at", - "lastRunTookHeader": "Last run took", + "urlHeader": "URL", + "typeHeader": "Type", + "activeHeader": "Active", "lockedAtHeader": "Locked at", "runCountHeader": "Run count", - "title": "Jobs currently in queue", - "typeHeader": "Type", - "urlHeader": "URL" + "failCountHeader": "Fail count", + "lastRunHeader": "Last run at", + "lastFinishedAtHeader": "Last finished at", + "lastRunTookHeader": "Last run took" }, "metricsTable": { - "metricHeader": "Metric", "title": "Queue metrics", + "metricHeader": "Metric", "valueHeader": "Value" }, - "refreshButton": "Refresh", - "title": "Queue" + "failedJobTable": { + "title": "Failed jobs", + "monitorIdHeader": "Monitor ID", + "monitorUrlHeader": "Monitor URL", + "failCountHeader": "Fail count", + "failedAtHeader": "Last failed at", + "failReasonHeader": "Fail reason" + } }, - "remove": "Remove", - "removeLogo": "Remove Logo", - "repeat": "Repeat", - "reset": "Reset", - "response": "RESPONSE", - "responseTime": "Response time", - "resume": "Resume", - "roles": { - "admin": "Admin", - "demoUser": "Demo user", - "superAdmin": "Super admin", - "teamMember": "Team member" + "export": { + "title": "Export Monitors", + "success": "Monitors exported successfully!", + "failed": "Failed to export monitors" + }, + "monitorActions": { + "title": "Export/Import", + "import": "Import Monitors", + "export": "Export Monitors", + "deleteSuccess": "Monitor deleted successfully", + "deleteFailed": "Failed to delete monitor", + "details": "Details" }, - "save": "Save", - "selectAll": "Select all", - "sendTestNotifications": "Send test notifications", - "seperateEmails": "You can separate multiple emails with a comma", - "settingsAppearance": "Appearance", - "settingsDisabled": "Disabled", - "settingsDisplayTimezone": "Display timezone", - "settingsEmailUser": "Email user - Username for authentication, overrides email address if specified", - "settingsFailedToClearStats": "Failed to clear stats", - "settingsFailedToDeleteMonitors": "Failed to delete all monitors", - "settingsFailedToSave": "Failed to save settings", - "settingsGeneralSettings": "General settings", - "settingsMonitorsDeleted": "Successfully deleted all monitors", "settingsPage": { "aboutSettings": { "labelDevelopedBy": "Developed by Bluewave Labs", @@ -812,8 +807,8 @@ }, "pageSpeedSettings": { "description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.", - "labelApiKey": "PageSpeed API key", "labelApiKeySet": "API key is set. Click Reset to change it.", + "labelApiKey": "PageSpeed API key", "title": "Google PageSpeed API key" }, "saveButtonLabel": "Save", @@ -856,58 +851,21 @@ "title": "Monitor IP/URL on Status Page" } }, - "settingsSave": "Save", - "settingsStatsCleared": "Stats cleared successfully", - "settingsSuccessSaved": "Settings saved successfully", - "settingsTestEmailFailed": "Failed to send test email", - "settingsTestEmailFailedWithReason": "Failed to send test email: {{reason}}", - "settingsTestEmailSuccess": "Test email sent successfully", - "settingsTestEmailUnknownError": "Unknown error", - "showAdminLoginLink": "Show \"Administrator? Login Here\" link on the status page", - "showCharts": "Show charts", - "showUptimePercentage": "Show uptime percentage", - "shown": "Shown", - "starPromptDescription": "See the latest releases and help grow the community on GitHub", - "starPromptTitle": "Star Checkmate", - "startTime": "Start time", - "state": "State", - "status": "Status", - "statusBreadCrumbsDetails": "Details", - "statusBreadCrumbsStatusPages": "Status Pages", - "statusCode": "Status code", - "statusMsg": { - "down": "Your site is down.", - "paused": "Monitoring is paused.", - "pending": "Pending...", - "up": "Your site is up." - }, - "statusPage": { - "contents": "Contents", - "createSuccess": "Status page created successfully", - "deleteFailed": "Failed to delete status page", - "deleteSuccess": "Status page deleted successfully", - "generalSettings": "General settings", - "updateSuccess": "Status page updated successfully" - }, "statusPageCreate": { "buttonSave": "Save" }, - "statusPageCreateAppearanceDescription": "Define the default look and feel of your public status page.", + "incidentsOptionsHeaderFilterResolved": "Resolved", + "settingsSave": "Save", "statusPageCreateAppearanceTitle": "Appearance", - "statusPageCreateBasicInfoDescription": "Define company name and the subdomain that your status page points to.", - "statusPageCreateBasicInfoStatusPageAddress": "Your status page address", - "statusPageCreateSelectTimeZoneDescription": "Select the timezone that your status page will be displayed in.", - "statusPageCreateSettings": "If your status page is ready, you can mark it as published.", - "statusPageCreateSettingsCheckboxLabel": "Published and visible to the public", - "statusPageCreateTabsContent": "Status page servers", - "statusPageCreateTabsContentDescription": "You can add any number of servers that you monitor to your status page. You can also reorder them for the best viewing experience.", - "statusPageCreateTabsContentFeaturesDescription": "Show more details on the status page", - "statusPageName": "Status page name", - "statusPageStatus": "A public status page is not set up.", - "statusPageStatusContactAdmin": "Please contact to your administrator", - "statusPageStatusNoPage": "There's no status page here.", - "statusPageStatusNotPublic": "This status page is not public.", - "statusPageStatusServiceStatus": "Service status", + "confirmPassword": "Confirm password", + "monitorHooks": { + "failureAddDemoMonitors": "Failed to add demo monitors", + "successAddDemoMonitors": "Successfully added demo monitors" + }, + "settingsAppearance": "Appearance", + "settingsDisplayTimezone": "Display timezone", + "settingsGeneralSettings": "General settings", + "incidentsOptionsHeaderTotalIncidents": "Total Incidents", "statusPage": { "deleteSuccess": "Status page deleted successfully", "deleteFailed": "Failed to delete status page", @@ -925,57 +883,81 @@ "actionButton": "Let's create your first status page!" } }, - "submit": "Submit", - "teamPanel": { - "cancel": "Cancel", - "email": "Email", - "emailToken": "E-mail token", - "filter": { - "all": "All", - "member": "Member" - }, - "getToken": "Get token", - "inviteDescription": "When you add a new team member, they will get access to all monitors.", - "inviteLink": "Invite link", - "inviteNewTeamMember": "Invite new team member", - "inviteTeamMember": "Invite a team member", - "noMembers": "There are no team members with this role", - "selectRole": "Select role", - "table": { - "created": "Created", - "email": "Email", - "name": "Name", - "role": "Role" - }, - "teamMembers": "Team members" - }, - "testLocale": "testLocale", "testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button", - "time": { - "fiveMinutes": "5 minutes", - "oneDay": "1 day", - "oneHour": "1 hour", - "oneWeek": "1 week", - "tenMinutes": "10 minutes", - "threeMinutes": "3 minutes", - "twentyMinutes": "20 minutes" + "incidentsTableResolvedAt": "Resolved at", + "incidentsTableActionResolve": "Resolve", + "checkHooks": { + "failureResolveOne": "Failed to resolve incident.", + "failureResolveAll": "Failed to resolve all incidents.", + "failureResolveMonitor": "Failed to resolve monitor incidents." }, - "timeZoneInfo": "All dates and times are in GMT+0 time zone.", - "timezone": "Timezone", - "title": "Title", - "tlsErrorIgnored": "TLS/SSL errors ignored", - "total": "Total", - "type": "Type", - "update": "Update", - "uptime": "Uptime", - "uptimeCreate": "The expected value is used to match against response result, and the match determines the status.", - "uptimeCreateJsonPath": "This expression will be evaluated against the reponse JSON data and the result will be used to match against the expected value. See", - "uptimeCreateJsonPathQuery": "for query language documentation.", - "uptimeGeneralInstructions": { - "docker": "Enter the Docker ID of your container. Docker IDs must be the full 64 char Docker ID. You can run docker inspect to get the full container ID.", - "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", - "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", - "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard." + "checkFormError": "Please check the form for errors.", + "diagnosticsPage": { + "diagnosticDescription": "System diagnostics", + "statsDescription": "System statistics", + "gauges": { + "heapAllocationTitle": "Heap allocation", + "heapAllocationSubtitle": "% of available memory", + "heapUsageTitle": "Heap usage", + "heapUsageSubtitle": "% of available memory", + "heapUtilizationTitle": "Heap utilization", + "heapUtilizationSubtitle": "% of allocated", + "instantCpuUsageTitle": "Instant CPU usage", + "instantCpuUsageSubtitle": "% of 1s used by CPU" + }, + "stats": { + "eventLoopDelayTitle": "Event loop delay", + "uptimeTitle": "Uptime", + "usedHeapSizeTitle": "Used heap size", + "totalHeapSizeTitle": "Total heap size", + "osMemoryLimitTitle": "OS Memory Limit" + } + }, + "pageSpeedLighthouseAPI": "Use the Lighthouse PageSpeed API to monitor your website", + "time": { + "threeMinutes": "3 minutes", + "fiveMinutes": "5 minutes", + "tenMinutes": "10 minutes", + "twentyMinutes": "20 minutes", + "oneHour": "1 hour", + "oneDay": "1 day", + "oneWeek": "1 week" + }, + "general": { + "noOptionsFound": "No {{unit}} found" + }, + "infrastructureMonitor": { + "fallback": { + "checks": [ + "Track the performance of your servers", + "Identify bottlenecks and optimize usage", + "Ensure reliability with real-time monitoring" + ], + "title": "An infrastructure monitor is used to:", + "actionButton": "Let's create your first infrastructure monitor!" + } + }, + "maintenanceWindow": { + "fallback": { + "checks": [ + "Mark your maintenance periods", + "Eliminate any misunderstandings", + "Stop sending alerts in maintenance windows" + ], + "title": "A maintenance window is used to:", + "actionButton": "Let's create your first maintenance window!" + } + }, + "pageSpeed": { + "fallback": { + "checks": [ + "Report on the user experience of a page", + "Help analyze webpage speed", + "Give suggestions on how the page can be improved" + ], + "title": "A PageSpeed monitor is used to:", + "actionButton": "Let's create your first PageSpeed monitor!" + } }, "uptimeMonitor": { "fallback": { @@ -989,14 +971,24 @@ "actionButton": "Let's create your first uptime monitor!" } }, - "url": "URL", - "urlMonitor": "URL to monitor", - "used": "Used", - "webhookSendError": "Error sending webhook notification to {platform}", - "webhookSendSuccess": "Webhook notification sent successfully", - "webhookUnsupportedPlatform": "Unsupported platform: {platform}", - "websiteMonitoring": "Website monitoring", - "websiteMonitoringDescription": "Use HTTP(s) to monitor your website or API endpoint.", - "whenNewIncident": "When there is a new incident,", - "window": "window" + "editUserPage": { + "form": { + "email": "Email", + "firstName": "First name", + "lastName": "Last name", + "role": "Roles", + "save": "Save" + }, + "table": { + "actionHeader": "Action", + "roleHeader": "Role" + }, + "title": "Edit user", + "toast": { + "successUserUpdate": "User updated successfully", + "validationErrors": "Validation errors" + } + }, + "incidentsPageActionResolveMonitor": "Resolve monitor incidents", + "incidentsPageActionResolveAll": "Resolve all incidents" } diff --git a/client/src/locales/es.json b/client/src/locales/es.json new file mode 100644 index 000000000..6ecebadf5 --- /dev/null +++ b/client/src/locales/es.json @@ -0,0 +1,969 @@ +{ + "submit": "Enviar", + "title": "Titulo", + "distributedStatusHeaderText": "Cobertura en tiempo real, para dispositivos reales", + "distributedStatusSubHeaderText": "Potenciado por millones de dispositivos alrededor del mundo, vea el rendimiento del sistema por región global, país o ciudad", + "settingsDisabled": "Deshabilitado", + "settingsSuccessSaved": "Ajustes guardados correctamente", + "settingsFailedToSave": "Fallo al guardar los ajustes", + "settingsStatsCleared": "Estadísticas eliminadas correctamente", + "settingsFailedToClearStats": "Fallo al eliminar las estadísticas", + "settingsMonitorsDeleted": "Todos los monitores eliminados correctamente", + "settingsFailedToDeleteMonitors": "Fallo al eliminar todos los monitores", + "starPromptTitle": "", + "starPromptDescription": "Vea las últimas versiones y ayude a incrementar la comunidad en Github", + "https": "HTTPS", + "http": "HTTP", + "monitor": "monitor", + "aboutus": "Acerca de", + "now": "Ahora", + "delete": "Eliminar", + "configure": "Configurar", + "responseTime": "Tiempo de Respuesta", + "ms": "ms", + "bar": "Barra", + "area": "Área", + "country": "PAIS", + "city": "CIUDAD", + "response": "RESPUESTA", + "monitorStatusUp": "El monitor {name} ({url}) está ahora activo y respondiendo", + "monitorStatusDown": "El monitor {name} ({url}) está inactivo y no está respondiendo", + "webhookSendSuccess": "Notificación webhook enviada correctamente", + "webhookSendError": "Error al enviar notificación webhook a {platform}", + "webhookUnsupportedPlatform": "Plataforma no soportada: {platform}", + "distributedRightCategoryTitle": "Monitor", + "distributedStatusServerMonitors": "Monitores de Servidores", + "distributedStatusServerMonitorsDescription": "Monitoree el estado de los servidores relacionados", + "distributedUptimeCreateSelectURL": "Aquí puede seleccionar la URL del anfitrión, junto con el tipo de monitor.", + "distributedUptimeCreateChecks": "Verificaciones a realizar", + "distributedUptimeCreateChecksDescription": "Usted siempre puede agregar o eliminar verificaciones luego de añadir su sitio.", + "distributedUptimeCreateIncidentNotification": "Notificaciones de incidentes", + "distributedUptimeCreateIncidentDescription": "Cuando ocurra un incidente, notificar a usuarios.", + "distributedUptimeCreateAdvancedSettings": "Ajustes avanzados", + "distributedUptimeDetailsNoMonitorHistory": "No hay historial de verificaciones para este monitor aún.", + "distributedUptimeDetailsStatusHeaderUptime": "Tiempo de funcionamiento:", + "distributedUptimeDetailsStatusHeaderLastUpdate": "Actualizado por última vez", + "notifications": { + "enableNotifications": "Habilitar notificaciones para {{platform}}", + "testNotification": "Notificación de prueba", + "addOrEditNotifications": "Agregar o eliminar notificaciones", + "slack": { + "label": "Slack", + "description": "Para habilitar notificaciones de Slack, cree una aplicación de Slack y habilite los webhooks entrantes. Luego de eso, simplemente introduzca la URL del webhook aquí.", + "webhookLabel": "URL del webhook", + "webhookPlaceholder": "", + "webhookRequired": "" + }, + "discord": { + "label": "Discord", + "description": "Para enviar datos a un canal de Discord desde Checkmate a través de notificaciones de Discord utilizando webhooks, puede utilizar la característica de webhooks entrantes de Discord.", + "webhookLabel": "URL de Webhooks de Discord", + "webhookPlaceholder": "", + "webhookRequired": "" + }, + "telegram": { + "label": "Telegram", + "description": "Para habilitar las notificaciones de Telegram, cree un bot de Telegram utilizando BotFather, un bot oficial para crear y administrar bots de Telegram. Entonces, obtenga una clave API y un ID de chat y escríbalos aquí.", + "tokenLabel": "La clave de su bot", + "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", + "chatIdLabel": "El ID de su Chat", + "chatIdPlaceholder": "-1001234567890", + "fieldsRequired": "" + }, + "webhook": { + "label": "Webhooks", + "description": "Puede establecer un webhook personalizado para recibir notificaciones cuando ocurra un incidente.", + "urlLabel": "URL del webhook", + "urlPlaceholder": "https://your-server.com/webhook", + "urlRequired": "" + }, + "testNotificationDevelop": "Notificación de prueba 2", + "integrationButton": "", + "testSuccess": "", + "testFailed": "", + "unsupportedType": "", + "networkError": "", + "fallback": { + "title": "", + "checks": [""], + "actionButton": "" + }, + "createButton": "", + "createTitle": "", + "create": { + "success": "", + "failed": "" + }, + "fetch": { + "success": "", + "failed": "" + }, + "delete": { + "success": "", + "failed": "" + }, + "edit": { + "success": "", + "failed": "" + }, + "test": { + "success": "", + "failed": "" + } + }, + "testLocale": "testLocale", + "add": "Agregar", + "monitors": "monitores", + "distributedUptimeStatusCreateStatusPage": "página de estado", + "distributedUptimeStatusCreateStatusPageAccess": "", + "distributedUptimeStatusCreateStatusPageReady": "", + "distributedUptimeStatusBasicInfoHeader": "", + "distributedUptimeStatusBasicInfoDescription": "", + "distributedUptimeStatusLogoHeader": "", + "distributedUptimeStatusLogoDescription": "", + "distributedUptimeStatusLogoUploadButton": "", + "distributedUptimeStatusStandardMonitorsHeader": "", + "distributedUptimeStatusStandardMonitorsDescription": "", + "distributedUptimeStatusCreateYour": "", + "distributedUptimeStatusEditYour": "", + "distributedUptimeStatusPublishedLabel": "", + "distributedUptimeStatusCompanyNameLabel": "", + "distributedUptimeStatusPageAddressLabel": "", + "distributedUptimeStatus30Days": "", + "distributedUptimeStatus60Days": "", + "distributedUptimeStatus90Days": "", + "distributedUptimeStatusPageNotSetUp": "", + "distributedUptimeStatusContactAdmin": "", + "distributedUptimeStatusPageNotPublic": "", + "distributedUptimeStatusPageDeleteDialog": "", + "distributedUptimeStatusPageDeleteConfirm": "", + "distributedUptimeStatusPageDeleteDescription": "", + "distributedUptimeStatusDevices": "", + "distributedUptimeStatusUpt": "", + "distributedUptimeStatusUptBurned": "", + "distributedUptimeStatusUptLogo": "", + "incidentsTableNoIncidents": "", + "incidentsTablePaginationLabel": "", + "incidentsTableMonitorName": "", + "incidentsTableStatus": "", + "incidentsTableDateTime": "", + "incidentsTableStatusCode": "", + "incidentsTableMessage": "", + "incidentsOptionsHeader": "", + "incidentsOptionsHeaderFilterBy": "", + "incidentsOptionsHeaderFilterAll": "", + "incidentsOptionsHeaderFilterDown": "", + "incidentsOptionsHeaderFilterCannotResolve": "", + "incidentsOptionsHeaderShow": "", + "incidentsOptionsHeaderLastHour": "", + "incidentsOptionsHeaderLastDay": "", + "incidentsOptionsHeaderLastWeek": "", + "incidentsOptionsPlaceholderAllServers": "", + "infrastructureCreateYour": "", + "infrastructureCreateGeneralSettingsDescription": "", + "infrastructureServerRequirement": "", + "infrastructureCustomizeAlerts": "", + "infrastructureAlertNotificationDescription": "", + "infrastructureCreateMonitor": "", + "infrastructureProtocol": "", + "infrastructureServerUrlLabel": "", + "infrastructureDisplayNameLabel": "", + "infrastructureAuthorizationSecretLabel": "", + "gb": "", + "mb": "", + "mem": "", + "memoryUsage": "", + "cpu": "", + "cpuUsage": "", + "cpuTemperature": "", + "diskUsage": "", + "used": "", + "total": "", + "cores": "", + "frequency": "", + "status": "", + "cpuPhysical": "", + "cpuLogical": "", + "cpuFrequency": "", + "avgCpuTemperature": "", + "memory": "", + "disk": "", + "uptime": "", + "os": "", + "host": "", + "actions": "", + "integrations": "", + "integrationsPrism": "", + "integrationsSlack": "", + "integrationsSlackInfo": "", + "integrationsDiscord": "", + "integrationsDiscordInfo": "", + "integrationsZapier": "", + "integrationsZapierInfo": "", + "commonSave": "", + "createYour": "", + "createMonitor": "", + "pause": "", + "resume": "", + "editing": "", + "url": "", + "access": "", + "timezone": "", + "features": "", + "administrator": "", + "loginHere": "", + "displayName": "", + "urlMonitor": "", + "portToMonitor": "", + "websiteMonitoring": "", + "websiteMonitoringDescription": "", + "pingMonitoring": "", + "pingMonitoringDescription": "", + "dockerContainerMonitoring": "", + "dockerContainerMonitoringDescription": "", + "portMonitoring": "", + "portMonitoringDescription": "", + "createMaintenanceWindow": "", + "createMaintenance": "", + "editMaintenance": "", + "maintenanceWindowName": "", + "friendlyNameInput": "", + "friendlyNamePlaceholder": "", + "maintenanceRepeat": "", + "maintenance": "", + "duration": "", + "addMonitors": "", + "window": "", + "cancel": "", + "message": "", + "low": "", + "high": "", + "statusCode": "", + "date&Time": "", + "type": "", + "statusPageName": "", + "publicURL": "", + "repeat": "", + "edit": "", + "createA": "", + "remove": "", + "maintenanceWindowDescription": "", + "startTime": "", + "timeZoneInfo": "", + "monitorsToApply": "", + "nextWindow": "", + "notFoundButton": "", + "pageSpeedConfigureSettingsDescription": "", + "monitorDisplayName": "", + "whenNewIncident": "", + "notifySMS": "", + "notifyEmails": "", + "seperateEmails": "", + "checkFrequency": "", + "matchMethod": "", + "expectedValue": "", + "deleteDialogTitle": "", + "deleteDialogDescription": "", + "pageSpeedMonitor": "", + "shown": "", + "ago": "", + "companyName": "", + "pageSpeedDetailsPerformanceReport": "", + "pageSpeedDetailsPerformanceReportCalculator": "", + "checkingEvery": "", + "statusPageCreateSettings": "", + "basicInformation": "", + "statusPageCreateBasicInfoDescription": "", + "statusPageCreateSelectTimeZoneDescription": "", + "statusPageCreateAppearanceDescription": "", + "statusPageCreateSettingsCheckboxLabel": "", + "statusPageCreateBasicInfoStatusPageAddress": "", + "statusPageCreateTabsContent": "", + "statusPageCreateTabsContentDescription": "", + "statusPageCreateTabsContentFeaturesDescription": "", + "showCharts": "", + "showUptimePercentage": "", + "removeLogo": "", + "statusPageStatus": "", + "statusPageStatusContactAdmin": "", + "statusPageStatusNotPublic": "", + "statusPageStatusNoPage": "", + "statusPageStatusServiceStatus": "", + "deleteStatusPage": "", + "deleteStatusPageConfirm": "", + "deleteStatusPageDescription": "", + "uptimeCreate": "", + "uptimeCreateJsonPath": "", + "uptimeCreateJsonPathQuery": "", + "maintenanceTableActionMenuDialogTitle": "", + "infrastructureEditYour": "", + "infrastructureEditMonitor": "", + "infrastructureMonitorCreated": "", + "infrastructureMonitorUpdated": "", + "errorInvalidTypeId": "", + "errorInvalidFieldId": "", + "inviteNoTokenFound": "", + "pageSpeedWarning": "", + "pageSpeedLearnMoreLink": "", + "pageSpeedAddApiKey": "", + "update": "", + "invalidFileFormat": "", + "invalidFileSize": "", + "ClickUpload": "", + "DragandDrop": "", + "MaxSize": "", + "SupportedFormats": "", + "FirstName": "", + "LastName": "", + "EmailDescriptionText": "", + "YourPhoto": "", + "PhotoDescriptionText": "", + "save": "", + "DeleteDescriptionText": "", + "DeleteAccountWarning": "", + "DeleteWarningTitle": "", + "bulkImport": { + "title": "", + "selectFileTips": "", + "selectFileDescription": "", + "selectFile": "", + "parsingFailed": "", + "uploadSuccess": "", + "validationFailed": "", + "noFileSelected": "", + "fallbackPage": "", + "invalidFileType": "", + "uploadFailed": "" + }, + "DeleteAccountTitle": "", + "DeleteAccountButton": "", + "publicLink": "", + "maskedPageSpeedKeyPlaceholder": "", + "reset": "", + "ignoreTLSError": "", + "tlsErrorIgnored": "", + "ignoreTLSErrorDescription": "", + "createNew": "", + "greeting": { + "prepend": "", + "append": "", + "overview": "" + }, + "roles": { + "superAdmin": "", + "admin": "", + "teamMember": "", + "demoUser": "" + }, + "teamPanel": { + "teamMembers": "", + "filter": { + "all": "", + "member": "" + }, + "inviteTeamMember": "", + "inviteNewTeamMember": "", + "inviteDescription": "", + "email": "", + "selectRole": "", + "inviteLink": "", + "cancel": "", + "noMembers": "", + "getToken": "", + "emailToken": "", + "table": { + "name": "", + "email": "", + "role": "", + "created": "" + } + }, + "monitorState": { + "paused": "", + "resumed": "", + "active": "" + }, + "menu": { + "uptime": "", + "pagespeed": "", + "infrastructure": "", + "incidents": "", + "statusPages": "", + "maintenance": "", + "integrations": "", + "settings": "", + "support": "", + "discussions": "", + "docs": "", + "changelog": "", + "profile": "", + "password": "", + "team": "", + "logOut": "", + "notifications": "", + "logs": "" + }, + "settingsEmailUser": "", + "state": "", + "statusBreadCrumbsStatusPages": "", + "statusBreadCrumbsDetails": "", + "commonSaving": "", + "navControls": "", + "incidentsPageTitle": "", + "passwordPanel": { + "passwordChangedSuccess": "", + "passwordInputIncorrect": "", + "currentPassword": "", + "enterCurrentPassword": "", + "newPassword": "", + "enterNewPassword": "", + "confirmNewPassword": "", + "passwordRequirements": "", + "saving": "" + }, + "emailSent": "", + "failedToSendEmail": "", + "settingsTestEmailSuccess": "", + "settingsTestEmailFailed": "", + "settingsTestEmailFailedWithReason": "", + "settingsTestEmailUnknownError": "", + "statusMsg": { + "paused": "", + "up": "", + "down": "", + "pending": "" + }, + "uptimeGeneralInstructions": { + "http": "", + "ping": "", + "docker": "", + "port": "" + }, + "common": { + "appName": "", + "monitoringAgentName": "", + "buttons": { + "toggleTheme": "" + }, + "toasts": { + "networkError": "", + "checkConnection": "", + "unknownError": "" + } + }, + "auth": { + "common": { + "navigation": { + "continue": "", + "back": "" + }, + "inputs": { + "email": { + "label": "", + "placeholder": "", + "errors": { + "empty": "", + "invalid": "" + } + }, + "password": { + "label": "", + "rules": { + "length": { + "beginning": "", + "highlighted": "" + }, + "special": { + "beginning": "", + "highlighted": "" + }, + "number": { + "beginning": "", + "highlighted": "" + }, + "uppercase": { + "beginning": "", + "highlighted": "" + }, + "lowercase": { + "beginning": "", + "highlighted": "" + }, + "match": { + "beginning": "", + "highlighted": "" + } + }, + "errors": { + "empty": "", + "length": "", + "uppercase": "", + "lowercase": "", + "number": "", + "special": "", + "incorrect": "" + } + }, + "passwordConfirm": { + "label": "", + "placeholder": "", + "errors": { + "empty": "", + "different": "" + } + }, + "firstName": { + "label": "", + "placeholder": "", + "errors": { + "empty": "", + "length": "", + "pattern": "" + } + }, + "lastName": { + "label": "", + "placeholder": "", + "errors": { + "empty": "", + "length": "", + "pattern": "" + } + } + }, + "errors": { + "validation": "" + }, + "fields": { + "password": { + "errors": { + "incorrect": "" + } + }, + "role": { + "errors": { + "min": "" + } + } + } + }, + "login": { + "heading": "", + "subheadings": { + "stepOne": "", + "stepTwo": "" + }, + "links": { + "forgotPassword": "", + "register": "", + "forgotPasswordLink": "", + "registerLink": "" + }, + "toasts": { + "success": "", + "incorrectPassword": "" + }, + "errors": { + "password": { + "incorrect": "" + } + } + }, + "registration": { + "heading": { + "superAdmin": "", + "user": "" + }, + "subheadings": { + "stepOne": "", + "stepTwo": "", + "stepThree": "" + }, + "description": { + "superAdmin": "", + "user": "" + }, + "gettingStartedButton": { + "superAdmin": "", + "user": "" + }, + "termsAndPolicies": "", + "links": { + "login": "" + }, + "toasts": { + "success": "" + } + }, + "forgotPassword": { + "heading": "", + "subheadings": { + "stepOne": "", + "stepTwo": "", + "stepThree": "", + "stepFour": "" + }, + "buttons": { + "openEmail": "", + "resetPassword": "" + }, + "imageAlts": { + "passwordKey": "", + "email": "", + "lock": "", + "passwordConfirm": "" + }, + "links": { + "login": "", + "resend": "" + }, + "toasts": { + "sent": "", + "emailNotFound": "", + "redirect": "", + "success": "", + "error": "" + } + } + }, + "errorPages": { + "serverUnreachable": { + "toasts": { + "reconnected": "", + "stillUnreachable": "" + }, + "alertBox": "", + "description": "", + "retryButton": { + "default": "", + "processing": "" + } + } + }, + "createNotifications": { + "title": "", + "nameSettings": { + "title": "", + "description": "", + "nameLabel": "", + "namePlaceholder": "" + }, + "typeSettings": { + "title": "", + "description": "", + "typeLabel": "" + }, + "emailSettings": { + "title": "", + "description": "", + "emailLabel": "", + "emailPlaceholder": "" + }, + "slackSettings": { + "title": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "" + }, + "pagerdutySettings": { + "title": "", + "description": "", + "integrationKeyLabel": "", + "integrationKeyPlaceholder": "" + }, + "discordSettings": { + "title": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "" + }, + "webhookSettings": { + "title": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "" + }, + "testNotification": "", + "dialogDeleteTitle": "", + "dialogDeleteConfirm": "" + }, + "notificationConfig": { + "title": "", + "description": "" + }, + "monitorStatus": { + "checkingEvery": "", + "withCaptureAgent": "", + "up": "", + "down": "", + "paused": "" + }, + "advancedMatching": "", + "sendTestNotifications": "", + "selectAll": "", + "showAdminLoginLink": "", + "logsPage": { + "title": "", + "description": "", + "tabs": { + "queue": "", + "logs": "", + "diagnostics": "" + }, + "toast": { + "fetchLogsSuccess": "" + }, + "logLevelSelect": { + "title": "", + "values": { + "all": "", + "info": "", + "warn": "", + "error": "", + "debug": "" + } + } + }, + "queuePage": { + "title": "", + "refreshButton": "", + "flushButton": "", + "jobTable": { + "title": "", + "idHeader": "", + "urlHeader": "", + "typeHeader": "", + "activeHeader": "", + "lockedAtHeader": "", + "runCountHeader": "", + "failCountHeader": "", + "lastRunHeader": "", + "lastFinishedAtHeader": "", + "lastRunTookHeader": "" + }, + "metricsTable": { + "title": "", + "metricHeader": "", + "valueHeader": "" + }, + "failedJobTable": { + "title": "", + "monitorIdHeader": "", + "monitorUrlHeader": "", + "failCountHeader": "", + "failedAtHeader": "", + "failReasonHeader": "" + } + }, + "export": { + "title": "", + "success": "", + "failed": "" + }, + "monitorActions": { + "title": "", + "import": "", + "export": "", + "deleteSuccess": "", + "deleteFailed": "", + "details": "" + }, + "settingsPage": { + "aboutSettings": { + "labelDevelopedBy": "", + "labelVersion": "", + "title": "" + }, + "demoMonitorsSettings": { + "buttonAddMonitors": "", + "description": "", + "title": "" + }, + "emailSettings": { + "buttonSendTestEmail": "", + "description": "", + "descriptionTransport": "", + "labelAddress": "", + "labelConnectionHost": "", + "labelHost": "", + "labelIgnoreTLS": "", + "labelPassword": "", + "labelPasswordSet": "", + "labelPool": "", + "labelPort": "", + "labelRejectUnauthorized": "", + "labelRequireTLS": "", + "labelSecure": "", + "labelTLSServername": "", + "labelUser": "", + "linkTransport": "", + "placeholderUser": "", + "title": "", + "toastEmailRequiredFieldsError": "" + }, + "pageSpeedSettings": { + "description": "", + "labelApiKeySet": "", + "labelApiKey": "", + "title": "" + }, + "saveButtonLabel": "", + "statsSettings": { + "clearAllStatsButton": "", + "clearAllStatsDescription": "", + "clearAllStatsDialogConfirm": "", + "clearAllStatsDialogDescription": "", + "clearAllStatsDialogTitle": "", + "description": "", + "labelTTL": "", + "labelTTLOptional": "", + "title": "" + }, + "systemResetSettings": { + "buttonRemoveAllMonitors": "", + "description": "", + "dialogConfirm": "", + "dialogDescription": "", + "dialogTitle": "", + "title": "" + }, + "timezoneSettings": { + "description": "", + "label": "", + "title": "" + }, + "title": "", + "uiSettings": { + "description": "", + "labelLanguage": "", + "labelTheme": "", + "title": "" + }, + "urlSettings": { + "description": "", + "label": "", + "selectDisabled": "", + "selectEnabled": "", + "title": "" + } + }, + "statusPageCreate": { + "buttonSave": "" + }, + "incidentsOptionsHeaderFilterResolved": "", + "settingsSave": "", + "statusPageCreateAppearanceTitle": "", + "confirmPassword": "", + "monitorHooks": { + "failureAddDemoMonitors": "", + "successAddDemoMonitors": "" + }, + "settingsAppearance": "", + "settingsDisplayTimezone": "", + "settingsGeneralSettings": "", + "incidentsOptionsHeaderTotalIncidents": "", + "statusPage": { + "deleteSuccess": "", + "deleteFailed": "", + "createSuccess": "", + "updateSuccess": "", + "generalSettings": "", + "contents": "", + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "testNotificationsDisabled": "", + "incidentsTableResolvedAt": "", + "incidentsTableActionResolve": "", + "checkHooks": { + "failureResolveOne": "", + "failureResolveAll": "", + "failureResolveMonitor": "" + }, + "checkFormError": "", + "diagnosticsPage": { + "diagnosticDescription": "", + "statsDescription": "", + "gauges": { + "heapAllocationTitle": "", + "heapAllocationSubtitle": "", + "heapUsageTitle": "", + "heapUsageSubtitle": "", + "heapUtilizationTitle": "", + "heapUtilizationSubtitle": "", + "instantCpuUsageTitle": "", + "instantCpuUsageSubtitle": "" + }, + "stats": { + "eventLoopDelayTitle": "", + "uptimeTitle": "", + "usedHeapSizeTitle": "", + "totalHeapSizeTitle": "", + "osMemoryLimitTitle": "" + } + }, + "pageSpeedLighthouseAPI": "", + "time": { + "threeMinutes": "", + "fiveMinutes": "", + "tenMinutes": "", + "twentyMinutes": "", + "oneHour": "", + "oneDay": "", + "oneWeek": "" + }, + "general": { + "noOptionsFound": "" + }, + "infrastructureMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "maintenanceWindow": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "pageSpeed": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "uptimeMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "editUserPage": { + "form": { + "email": "", + "firstName": "", + "lastName": "", + "role": "", + "save": "" + }, + "table": { + "actionHeader": "", + "roleHeader": "" + }, + "title": "", + "toast": { + "successUserUpdate": "", + "validationErrors": "" + } + }, + "incidentsPageActionResolveMonitor": "", + "incidentsPageActionResolveAll": "" +} diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json new file mode 100644 index 000000000..2198e2798 --- /dev/null +++ b/client/src/locales/fi.json @@ -0,0 +1,969 @@ +{ + "submit": "Lähetä", + "title": "Otsikko", + "distributedStatusHeaderText": "Reaaliaikainen kattavuus oikeilla laitteilla", + "distributedStatusSubHeaderText": "Miljoonien laitteiden voimin ympäri maailman näet järjestelmän suorituskyvyn maailman alueittain, maittain tai kaupungeittain", + "settingsDisabled": "Poistettu käytöstä", + "settingsSuccessSaved": "", + "settingsFailedToSave": "", + "settingsStatsCleared": "", + "settingsFailedToClearStats": "", + "settingsMonitorsDeleted": "", + "settingsFailedToDeleteMonitors": "", + "starPromptTitle": "", + "starPromptDescription": "", + "https": "HTTPS", + "http": "HTTP", + "monitor": "Seurain", + "aboutus": "Tietoja meistä", + "now": "Nyt", + "delete": "Poista", + "configure": "Määritä", + "responseTime": "Vasteaika", + "ms": "ms", + "bar": "Palkki", + "area": "Alue", + "country": "MAA", + "city": "KAUPUNKI", + "response": "Vastaus", + "monitorStatusUp": "Valvontakohde {name} ({url}) on nyt toiminnassa ja vastaa", + "monitorStatusDown": "Valvontakohde {name} ({url}) ei ole toiminnassa, eikä vastaa", + "webhookSendSuccess": "Webhook-ilmoitus lähetettiin onnistuneesti", + "webhookSendError": "Virhe lähetettäessä webhook-ilmoitusta palveluun {platform}", + "webhookUnsupportedPlatform": "Ei tuettu palvelu: {platform}", + "distributedRightCategoryTitle": "Valvonta", + "distributedStatusServerMonitors": "Palvelin valvonta", + "distributedStatusServerMonitorsDescription": "Valvo palvelimien tilaa", + "distributedUptimeCreateSelectURL": "Tässä voit valita isäntäkohteen URL-osoitteen ja valvontatyypin.", + "distributedUptimeCreateChecks": "Suoritettavat tarkistukset", + "distributedUptimeCreateChecksDescription": "Voit aina lisätä tai poistaa tarkistuksia sivuston lisäämisen jälkeen.", + "distributedUptimeCreateIncidentNotification": "Häiriöilmoitukset", + "distributedUptimeCreateIncidentDescription": "Jos ilmenee häiriö, ilmoita käyttäjille.", + "distributedUptimeCreateAdvancedSettings": "Lisäasetukset", + "distributedUptimeDetailsNoMonitorHistory": "Tälle valvontakohteelle ei ole vielä tarkistushistoriaa.", + "distributedUptimeDetailsStatusHeaderUptime": "", + "distributedUptimeDetailsStatusHeaderLastUpdate": "Viimeksi päivitetty", + "notifications": { + "enableNotifications": "", + "testNotification": "Testi-ilmoitus", + "addOrEditNotifications": "Lisää tai muokkaa ilmoituksia", + "slack": { + "label": "Slack", + "description": "", + "webhookLabel": "Webhookin URL", + "webhookPlaceholder": "", + "webhookRequired": "" + }, + "discord": { + "label": "Discord", + "description": "", + "webhookLabel": "Discord Webhookin URL-osoite", + "webhookPlaceholder": "https://discord.com/api/webhooks/...", + "webhookRequired": "" + }, + "telegram": { + "label": "Telegram", + "description": "", + "tokenLabel": "", + "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", + "chatIdLabel": "", + "chatIdPlaceholder": "-1001234567890", + "fieldsRequired": "" + }, + "webhook": { + "label": "Webhookit", + "description": "", + "urlLabel": "Webhookin URL", + "urlPlaceholder": "https://your-server.com/webhook", + "urlRequired": "" + }, + "testNotificationDevelop": "Testi-ilmoitus 2", + "integrationButton": "", + "testSuccess": "Testi ilmoituksen lähetys onnistui!", + "testFailed": "Testi ilmoituksen lähetys epäonnistui", + "unsupportedType": "", + "networkError": "", + "fallback": { + "title": "", + "checks": [""], + "actionButton": "" + }, + "createButton": "", + "createTitle": "", + "create": { + "success": "", + "failed": "" + }, + "fetch": { + "success": "", + "failed": "" + }, + "delete": { + "success": "", + "failed": "" + }, + "edit": { + "success": "", + "failed": "" + }, + "test": { + "success": "", + "failed": "" + } + }, + "testLocale": "", + "add": "Lisää", + "monitors": "Seuraimet", + "distributedUptimeStatusCreateStatusPage": "", + "distributedUptimeStatusCreateStatusPageAccess": "", + "distributedUptimeStatusCreateStatusPageReady": "", + "distributedUptimeStatusBasicInfoHeader": "", + "distributedUptimeStatusBasicInfoDescription": "", + "distributedUptimeStatusLogoHeader": "Logo", + "distributedUptimeStatusLogoDescription": "", + "distributedUptimeStatusLogoUploadButton": "", + "distributedUptimeStatusStandardMonitorsHeader": "", + "distributedUptimeStatusStandardMonitorsDescription": "", + "distributedUptimeStatusCreateYour": "", + "distributedUptimeStatusEditYour": "", + "distributedUptimeStatusPublishedLabel": "", + "distributedUptimeStatusCompanyNameLabel": "Yrityksen nimi", + "distributedUptimeStatusPageAddressLabel": "", + "distributedUptimeStatus30Days": "30 päivää", + "distributedUptimeStatus60Days": "60 päivää", + "distributedUptimeStatus90Days": "90 päivää", + "distributedUptimeStatusPageNotSetUp": "", + "distributedUptimeStatusContactAdmin": "Ota yhteys järjestelmävalvojaasi", + "distributedUptimeStatusPageNotPublic": "", + "distributedUptimeStatusPageDeleteDialog": "", + "distributedUptimeStatusPageDeleteConfirm": "", + "distributedUptimeStatusPageDeleteDescription": "", + "distributedUptimeStatusDevices": "", + "distributedUptimeStatusUpt": "", + "distributedUptimeStatusUptBurned": "", + "distributedUptimeStatusUptLogo": "UPT logo", + "incidentsTableNoIncidents": "", + "incidentsTablePaginationLabel": "", + "incidentsTableMonitorName": "", + "incidentsTableStatus": "Tila", + "incidentsTableDateTime": "Päivämäärä ja aika", + "incidentsTableStatusCode": "", + "incidentsTableMessage": "Viesti", + "incidentsOptionsHeader": "", + "incidentsOptionsHeaderFilterBy": "", + "incidentsOptionsHeaderFilterAll": "Kaikki", + "incidentsOptionsHeaderFilterDown": "Alhaalla", + "incidentsOptionsHeaderFilterCannotResolve": "", + "incidentsOptionsHeaderShow": "Näytä:", + "incidentsOptionsHeaderLastHour": "Viime tunti", + "incidentsOptionsHeaderLastDay": "Viime päivä", + "incidentsOptionsHeaderLastWeek": "Viime viikko", + "incidentsOptionsPlaceholderAllServers": "Kaikki palvelimet", + "infrastructureCreateYour": "", + "infrastructureCreateGeneralSettingsDescription": "", + "infrastructureServerRequirement": "", + "infrastructureCustomizeAlerts": "", + "infrastructureAlertNotificationDescription": "", + "infrastructureCreateMonitor": "", + "infrastructureProtocol": "Protokolla", + "infrastructureServerUrlLabel": "Palvelimen URL-osoite", + "infrastructureDisplayNameLabel": "", + "infrastructureAuthorizationSecretLabel": "", + "gb": "", + "mb": "", + "mem": "", + "memoryUsage": "Muistinkäyttö", + "cpu": "", + "cpuUsage": "", + "cpuTemperature": "", + "diskUsage": "", + "used": "", + "total": "", + "cores": "", + "frequency": "", + "status": "Tila", + "cpuPhysical": "", + "cpuLogical": "", + "cpuFrequency": "", + "avgCpuTemperature": "", + "memory": "Muisti", + "disk": "", + "uptime": "", + "os": "", + "host": "", + "actions": "", + "integrations": "", + "integrationsPrism": "", + "integrationsSlack": "Slack", + "integrationsSlackInfo": "", + "integrationsDiscord": "", + "integrationsDiscordInfo": "", + "integrationsZapier": "Zapier", + "integrationsZapierInfo": "", + "commonSave": "Tallenna", + "createYour": "", + "createMonitor": "", + "pause": "", + "resume": "", + "editing": "", + "url": "", + "access": "", + "timezone": "Aikavyöhyke", + "features": "", + "administrator": "", + "loginHere": "", + "displayName": "", + "urlMonitor": "", + "portToMonitor": "", + "websiteMonitoring": "", + "websiteMonitoringDescription": "", + "pingMonitoring": "", + "pingMonitoringDescription": "", + "dockerContainerMonitoring": "", + "dockerContainerMonitoringDescription": "", + "portMonitoring": "", + "portMonitoringDescription": "", + "createMaintenanceWindow": "", + "createMaintenance": "", + "editMaintenance": "", + "maintenanceWindowName": "", + "friendlyNameInput": "", + "friendlyNamePlaceholder": "", + "maintenanceRepeat": "", + "maintenance": "", + "duration": "", + "addMonitors": "", + "window": "", + "cancel": "Peruuttaa", + "message": "Viesti", + "low": "", + "high": "", + "statusCode": "Tilakoodi", + "date&Time": "Päivämäärä ja aika", + "type": "", + "statusPageName": "", + "publicURL": "Julkinen URL-osoite", + "repeat": "", + "edit": "Muokkaa", + "createA": "", + "remove": "", + "maintenanceWindowDescription": "", + "startTime": "", + "timeZoneInfo": "", + "monitorsToApply": "", + "nextWindow": "", + "notFoundButton": "", + "pageSpeedConfigureSettingsDescription": "", + "monitorDisplayName": "", + "whenNewIncident": "", + "notifySMS": "Ilmoita tekstiviestillä (tulossa pian)", + "notifyEmails": "", + "seperateEmails": "", + "checkFrequency": "", + "matchMethod": "", + "expectedValue": "", + "deleteDialogTitle": "", + "deleteDialogDescription": "", + "pageSpeedMonitor": "", + "shown": "", + "ago": "sitten", + "companyName": "Yrityksen nimi", + "pageSpeedDetailsPerformanceReport": "", + "pageSpeedDetailsPerformanceReportCalculator": "", + "checkingEvery": "", + "statusPageCreateSettings": "", + "basicInformation": "", + "statusPageCreateBasicInfoDescription": "", + "statusPageCreateSelectTimeZoneDescription": "", + "statusPageCreateAppearanceDescription": "", + "statusPageCreateSettingsCheckboxLabel": "", + "statusPageCreateBasicInfoStatusPageAddress": "", + "statusPageCreateTabsContent": "", + "statusPageCreateTabsContentDescription": "", + "statusPageCreateTabsContentFeaturesDescription": "", + "showCharts": "Näytä kaaviot", + "showUptimePercentage": "", + "removeLogo": "Poista logo", + "statusPageStatus": "", + "statusPageStatusContactAdmin": "Ota yhteyttä järjestelmänvalvojaan", + "statusPageStatusNotPublic": "", + "statusPageStatusNoPage": "", + "statusPageStatusServiceStatus": "", + "deleteStatusPage": "", + "deleteStatusPageConfirm": "", + "deleteStatusPageDescription": "", + "uptimeCreate": "", + "uptimeCreateJsonPath": "", + "uptimeCreateJsonPathQuery": "", + "maintenanceTableActionMenuDialogTitle": "", + "infrastructureEditYour": "Muokkaa omaa", + "infrastructureEditMonitor": "", + "infrastructureMonitorCreated": "", + "infrastructureMonitorUpdated": "", + "errorInvalidTypeId": "", + "errorInvalidFieldId": "", + "inviteNoTokenFound": "", + "pageSpeedWarning": "", + "pageSpeedLearnMoreLink": "", + "pageSpeedAddApiKey": "", + "update": "Päivitä", + "invalidFileFormat": "", + "invalidFileSize": "Tiedoston koko on liian suuri!", + "ClickUpload": "", + "DragandDrop": "raahaa ja pudota", + "MaxSize": "Enimmäiskoko", + "SupportedFormats": "Tuetut muodot", + "FirstName": "Etunimi", + "LastName": "Sukunimi", + "EmailDescriptionText": "Tämä on nykyinen sähköpostiosoitteesi — sitä ei voi vaihtaa.", + "YourPhoto": "Profiilikuva", + "PhotoDescriptionText": "", + "save": "Tallenna", + "DeleteDescriptionText": "", + "DeleteAccountWarning": "", + "DeleteWarningTitle": "Haluatko todella poistaa tämän tilin?", + "bulkImport": { + "title": "", + "selectFileTips": "", + "selectFileDescription": "", + "selectFile": "Valitse tiedosto", + "parsingFailed": "", + "uploadSuccess": "", + "validationFailed": "", + "noFileSelected": "Ei valittua tiedostoa", + "fallbackPage": "", + "invalidFileType": "", + "uploadFailed": "" + }, + "DeleteAccountTitle": "Poista tili", + "DeleteAccountButton": "Poista tili", + "publicLink": "Julkinen linkki", + "maskedPageSpeedKeyPlaceholder": "*************************************", + "reset": "Palauta", + "ignoreTLSError": "", + "tlsErrorIgnored": "", + "ignoreTLSErrorDescription": "", + "createNew": "Luo uusi", + "greeting": { + "prepend": "Hei", + "append": "", + "overview": "" + }, + "roles": { + "superAdmin": "Pääylläpitäjä", + "admin": "Ylläpitäjä", + "teamMember": "Tiimin jäsen", + "demoUser": "Demokäyttäjä" + }, + "teamPanel": { + "teamMembers": "Tiimin jäsenet", + "filter": { + "all": "Kaikki", + "member": "Jäsen" + }, + "inviteTeamMember": "", + "inviteNewTeamMember": "", + "inviteDescription": "", + "email": "Sähköposti", + "selectRole": "Valitse rooli", + "inviteLink": "Kutsulinkki", + "cancel": "Peruuttaa", + "noMembers": "", + "getToken": "Hae tunniste", + "emailToken": "Sähköpostitunnus", + "table": { + "name": "Nimi", + "email": "Sähköposti", + "role": "Rooli", + "created": "Luotu" + } + }, + "monitorState": { + "paused": "Tauota", + "resumed": "Jatka", + "active": "" + }, + "menu": { + "uptime": "", + "pagespeed": "", + "infrastructure": "Infrastruktuuri", + "incidents": "", + "statusPages": "Tilasivut", + "maintenance": "Huolto", + "integrations": "", + "settings": "Asetukset", + "support": "Tuki", + "discussions": "Keskustelut", + "docs": "Dokumentit", + "changelog": "Muutosloki", + "profile": "Profiili", + "password": "Salasana", + "team": "Tiimi", + "logOut": "Kirjaudu ulos", + "notifications": "", + "logs": "" + }, + "settingsEmailUser": "", + "state": "Tila", + "statusBreadCrumbsStatusPages": "Tilasivut", + "statusBreadCrumbsDetails": "Tiedot", + "commonSaving": "Tallennetaan...", + "navControls": "Ohjaimet", + "incidentsPageTitle": "Häiriöt", + "passwordPanel": { + "passwordChangedSuccess": "Salasanasi vaihdettiin onnistuneesti.", + "passwordInputIncorrect": "", + "currentPassword": "Nykyinen salasana", + "enterCurrentPassword": "Kirjoita nykyinen salasanasi", + "newPassword": "Uusi salasana", + "enterNewPassword": "Kirjoita uusi salasana", + "confirmNewPassword": "Vahvista uusi salasana", + "passwordRequirements": "Uuden salasanan on oltava vähintään 8 merkkiä pitkä ja sen on sisältävä vähintään yksi iso kirjain, yksi pieni kirjain, yksi numero sekä yksi erikoismerkki.", + "saving": "Tallennetaan..." + }, + "emailSent": "Sähköpostin lähetys onnistui", + "failedToSendEmail": "Sähköpostin lähetys epäonnistui", + "settingsTestEmailSuccess": "Testisähköpostin lähetys onnistui", + "settingsTestEmailFailed": "Testisähköpostin lähetys epäonnistui", + "settingsTestEmailFailedWithReason": "Testisähköpostin lähetys epäonnistui: {{reason}}", + "settingsTestEmailUnknownError": "Tuntematon virhe", + "statusMsg": { + "paused": "Valvonta on keskeytetty.", + "up": "Sivusi on ylhäällä.", + "down": "Sivusi on alhaalla.", + "pending": "Odottaa..." + }, + "uptimeGeneralInstructions": { + "http": "", + "ping": "", + "docker": "", + "port": "" + }, + "common": { + "appName": "", + "monitoringAgentName": "", + "buttons": { + "toggleTheme": "" + }, + "toasts": { + "networkError": "", + "checkConnection": "", + "unknownError": "" + } + }, + "auth": { + "common": { + "navigation": { + "continue": "", + "back": "" + }, + "inputs": { + "email": { + "label": "", + "placeholder": "", + "errors": { + "empty": "", + "invalid": "" + } + }, + "password": { + "label": "", + "rules": { + "length": { + "beginning": "", + "highlighted": "" + }, + "special": { + "beginning": "", + "highlighted": "" + }, + "number": { + "beginning": "", + "highlighted": "" + }, + "uppercase": { + "beginning": "", + "highlighted": "" + }, + "lowercase": { + "beginning": "", + "highlighted": "" + }, + "match": { + "beginning": "", + "highlighted": "" + } + }, + "errors": { + "empty": "", + "length": "", + "uppercase": "", + "lowercase": "", + "number": "", + "special": "", + "incorrect": "" + } + }, + "passwordConfirm": { + "label": "", + "placeholder": "", + "errors": { + "empty": "", + "different": "" + } + }, + "firstName": { + "label": "", + "placeholder": "", + "errors": { + "empty": "", + "length": "", + "pattern": "" + } + }, + "lastName": { + "label": "", + "placeholder": "", + "errors": { + "empty": "", + "length": "", + "pattern": "" + } + } + }, + "errors": { + "validation": "" + }, + "fields": { + "password": { + "errors": { + "incorrect": "" + } + }, + "role": { + "errors": { + "min": "" + } + } + } + }, + "login": { + "heading": "", + "subheadings": { + "stepOne": "", + "stepTwo": "" + }, + "links": { + "forgotPassword": "", + "register": "", + "forgotPasswordLink": "", + "registerLink": "" + }, + "toasts": { + "success": "", + "incorrectPassword": "" + }, + "errors": { + "password": { + "incorrect": "" + } + } + }, + "registration": { + "heading": { + "superAdmin": "", + "user": "" + }, + "subheadings": { + "stepOne": "", + "stepTwo": "", + "stepThree": "" + }, + "description": { + "superAdmin": "", + "user": "" + }, + "gettingStartedButton": { + "superAdmin": "", + "user": "" + }, + "termsAndPolicies": "", + "links": { + "login": "" + }, + "toasts": { + "success": "" + } + }, + "forgotPassword": { + "heading": "", + "subheadings": { + "stepOne": "", + "stepTwo": "", + "stepThree": "", + "stepFour": "" + }, + "buttons": { + "openEmail": "", + "resetPassword": "" + }, + "imageAlts": { + "passwordKey": "", + "email": "", + "lock": "", + "passwordConfirm": "" + }, + "links": { + "login": "", + "resend": "" + }, + "toasts": { + "sent": "", + "emailNotFound": "", + "redirect": "", + "success": "", + "error": "" + } + } + }, + "errorPages": { + "serverUnreachable": { + "toasts": { + "reconnected": "", + "stillUnreachable": "" + }, + "alertBox": "", + "description": "", + "retryButton": { + "default": "", + "processing": "" + } + } + }, + "createNotifications": { + "title": "", + "nameSettings": { + "title": "", + "description": "", + "nameLabel": "", + "namePlaceholder": "" + }, + "typeSettings": { + "title": "", + "description": "", + "typeLabel": "" + }, + "emailSettings": { + "title": "", + "description": "", + "emailLabel": "", + "emailPlaceholder": "" + }, + "slackSettings": { + "title": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "" + }, + "pagerdutySettings": { + "title": "", + "description": "", + "integrationKeyLabel": "", + "integrationKeyPlaceholder": "" + }, + "discordSettings": { + "title": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "" + }, + "webhookSettings": { + "title": "", + "description": "", + "webhookLabel": "", + "webhookPlaceholder": "" + }, + "testNotification": "", + "dialogDeleteTitle": "", + "dialogDeleteConfirm": "" + }, + "notificationConfig": { + "title": "", + "description": "" + }, + "monitorStatus": { + "checkingEvery": "", + "withCaptureAgent": "", + "up": "", + "down": "", + "paused": "" + }, + "advancedMatching": "", + "sendTestNotifications": "", + "selectAll": "", + "showAdminLoginLink": "", + "logsPage": { + "title": "", + "description": "", + "tabs": { + "queue": "", + "logs": "", + "diagnostics": "" + }, + "toast": { + "fetchLogsSuccess": "" + }, + "logLevelSelect": { + "title": "", + "values": { + "all": "", + "info": "", + "warn": "", + "error": "", + "debug": "" + } + } + }, + "queuePage": { + "title": "", + "refreshButton": "", + "flushButton": "", + "jobTable": { + "title": "", + "idHeader": "", + "urlHeader": "", + "typeHeader": "", + "activeHeader": "", + "lockedAtHeader": "", + "runCountHeader": "", + "failCountHeader": "", + "lastRunHeader": "", + "lastFinishedAtHeader": "", + "lastRunTookHeader": "" + }, + "metricsTable": { + "title": "", + "metricHeader": "", + "valueHeader": "" + }, + "failedJobTable": { + "title": "", + "monitorIdHeader": "", + "monitorUrlHeader": "", + "failCountHeader": "", + "failedAtHeader": "", + "failReasonHeader": "" + } + }, + "export": { + "title": "", + "success": "", + "failed": "" + }, + "monitorActions": { + "title": "", + "import": "", + "export": "", + "deleteSuccess": "", + "deleteFailed": "", + "details": "" + }, + "settingsPage": { + "aboutSettings": { + "labelDevelopedBy": "", + "labelVersion": "", + "title": "" + }, + "demoMonitorsSettings": { + "buttonAddMonitors": "", + "description": "", + "title": "" + }, + "emailSettings": { + "buttonSendTestEmail": "", + "description": "", + "descriptionTransport": "", + "labelAddress": "", + "labelConnectionHost": "", + "labelHost": "", + "labelIgnoreTLS": "", + "labelPassword": "", + "labelPasswordSet": "", + "labelPool": "", + "labelPort": "", + "labelRejectUnauthorized": "", + "labelRequireTLS": "", + "labelSecure": "", + "labelTLSServername": "", + "labelUser": "", + "linkTransport": "", + "placeholderUser": "", + "title": "", + "toastEmailRequiredFieldsError": "" + }, + "pageSpeedSettings": { + "description": "", + "labelApiKeySet": "", + "labelApiKey": "", + "title": "" + }, + "saveButtonLabel": "", + "statsSettings": { + "clearAllStatsButton": "", + "clearAllStatsDescription": "", + "clearAllStatsDialogConfirm": "", + "clearAllStatsDialogDescription": "", + "clearAllStatsDialogTitle": "", + "description": "", + "labelTTL": "", + "labelTTLOptional": "", + "title": "" + }, + "systemResetSettings": { + "buttonRemoveAllMonitors": "", + "description": "", + "dialogConfirm": "", + "dialogDescription": "", + "dialogTitle": "", + "title": "" + }, + "timezoneSettings": { + "description": "", + "label": "", + "title": "" + }, + "title": "", + "uiSettings": { + "description": "", + "labelLanguage": "", + "labelTheme": "", + "title": "" + }, + "urlSettings": { + "description": "", + "label": "", + "selectDisabled": "", + "selectEnabled": "", + "title": "" + } + }, + "statusPageCreate": { + "buttonSave": "" + }, + "incidentsOptionsHeaderFilterResolved": "", + "settingsSave": "", + "statusPageCreateAppearanceTitle": "", + "confirmPassword": "", + "monitorHooks": { + "failureAddDemoMonitors": "", + "successAddDemoMonitors": "" + }, + "settingsAppearance": "", + "settingsDisplayTimezone": "", + "settingsGeneralSettings": "", + "incidentsOptionsHeaderTotalIncidents": "", + "statusPage": { + "deleteSuccess": "", + "deleteFailed": "", + "createSuccess": "", + "updateSuccess": "", + "generalSettings": "", + "contents": "", + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "testNotificationsDisabled": "", + "incidentsTableResolvedAt": "", + "incidentsTableActionResolve": "", + "checkHooks": { + "failureResolveOne": "", + "failureResolveAll": "", + "failureResolveMonitor": "" + }, + "checkFormError": "", + "diagnosticsPage": { + "diagnosticDescription": "", + "statsDescription": "", + "gauges": { + "heapAllocationTitle": "", + "heapAllocationSubtitle": "", + "heapUsageTitle": "", + "heapUsageSubtitle": "", + "heapUtilizationTitle": "", + "heapUtilizationSubtitle": "", + "instantCpuUsageTitle": "", + "instantCpuUsageSubtitle": "" + }, + "stats": { + "eventLoopDelayTitle": "", + "uptimeTitle": "", + "usedHeapSizeTitle": "", + "totalHeapSizeTitle": "", + "osMemoryLimitTitle": "" + } + }, + "pageSpeedLighthouseAPI": "", + "time": { + "threeMinutes": "", + "fiveMinutes": "", + "tenMinutes": "", + "twentyMinutes": "", + "oneHour": "", + "oneDay": "", + "oneWeek": "" + }, + "general": { + "noOptionsFound": "" + }, + "infrastructureMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "maintenanceWindow": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "pageSpeed": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "uptimeMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "editUserPage": { + "form": { + "email": "", + "firstName": "", + "lastName": "", + "role": "", + "save": "" + }, + "table": { + "actionHeader": "", + "roleHeader": "" + }, + "title": "", + "toast": { + "successUserUpdate": "", + "validationErrors": "" + } + }, + "incidentsPageActionResolveMonitor": "", + "incidentsPageActionResolveAll": "" +} diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json new file mode 100644 index 000000000..c6e263c4d --- /dev/null +++ b/client/src/locales/fr.json @@ -0,0 +1,973 @@ +{ + "submit": "Envoyer", + "title": "Titre", + "distributedStatusHeaderText": "Couverture en temps réel, sur des appareils en temps réel", + "distributedStatusSubHeaderText": "Alimenté par des millions d'appareils à travers le monde, consultez les performances du système par région, pays ou ville", + "settingsDisabled": "Désactivé", + "settingsSuccessSaved": "Les paramètres ont bien été sauvegardés", + "settingsFailedToSave": "Les paramètres n'ont pas pu être sauvegardés", + "settingsStatsCleared": "Les statistiques ont été réinitialisées avec succès", + "settingsFailedToClearStats": "Les statistiques n'ont pas pu être réinitialisées", + "settingsMonitorsDeleted": "Tous les moniteurs ont bien été supprimés", + "settingsFailedToDeleteMonitors": "La suppression de tous les moniteurs a échoué", + "starPromptTitle": "Ajouter Checkmate aux favoris", + "starPromptDescription": "Voir les dernières versions et aider la communauté à grandir sur Github", + "https": "HTTPS", + "http": "HTTP", + "monitor": "Moniteur", + "aboutus": "A propos de nous", + "now": "Maintenant", + "delete": "Supprimer", + "configure": "Configurer", + "responseTime": "Temps de réponse", + "ms": "ms", + "bar": "Graphique", + "area": "Zone", + "country": "PAYS", + "city": "VILLE", + "response": "Réponse", + "monitorStatusUp": "Moniteur {name} ({url}) est désormais EN LIGNE et répond aux requêtes", + "monitorStatusDown": "Moniteur {name} ({url}) est désormais HORS LIGNE et ne répond plus", + "webhookSendSuccess": "La notification webhook a bien été envoyée", + "webhookSendError": "Une erreur s'est produite lors de l'envoi de la notification webhook sur {platform}", + "webhookUnsupportedPlatform": "La plateforme {platform} n'est pas supportée actuellement", + "distributedRightCategoryTitle": "Moniteur", + "distributedStatusServerMonitors": "Monitoring des serveurs", + "distributedStatusServerMonitorsDescription": "Statut des moniteurs relatifs aux serveurs", + "distributedUptimeCreateSelectURL": "Ici vous pouvez sélectionner l'URL de l'hôte et le type de moniteur", + "distributedUptimeCreateChecks": "Vérifications à effectuer", + "distributedUptimeCreateChecksDescription": "Vous pourrez ajouter ou supprimer des vérifications après l'ajout de votre site", + "distributedUptimeCreateIncidentNotification": "Notifications d'incidents", + "distributedUptimeCreateIncidentDescription": "Quand il y a un incident, notifier les utilisateurs", + "distributedUptimeCreateAdvancedSettings": "Paramètres avancés", + "distributedUptimeDetailsNoMonitorHistory": "Il n'y a pas encore d'historique de vérification pour ce moniteur", + "distributedUptimeDetailsStatusHeaderUptime": "Temps en ligne :", + "distributedUptimeDetailsStatusHeaderLastUpdate": "Dernière mise à jour", + "notifications": { + "enableNotifications": "Activer les notifications sur {{platform}}", + "testNotification": "Notification de test", + "addOrEditNotifications": "Ajouter ou éditer des notifications", + "slack": { + "label": "Slack", + "description": "Pour activer les notifications Slack, créez une application Slack et activez les webhooks entrants. Ensuite, indiquer l'URL du webhook ici.", + "webhookLabel": "URL du webhook", + "webhookPlaceholder": "https://hooks.slack.com/services/...", + "webhookRequired": "L'URL du webhook Slack est requise" + }, + "discord": { + "label": "Discord", + "description": "Pour envoyer des données sur un canal Discord depuis Checkmate, utilisez l'intégration Webhook Discord.", + "webhookLabel": "URL du webhook Discord", + "webhookPlaceholder": "https://discord.com/api/webhooks/...", + "webhookRequired": "L'URL du webhook Discord est requise" + }, + "telegram": { + "label": "Telegram", + "description": "Pour activer les notifications Telegram, créez un bot Telegram en utilisant BotFather, le robot officiel pour créer et gérer ses propres robots. Indiquez ensuite votre clé API et l'ID du chat ici.", + "tokenLabel": "Token du robot", + "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", + "chatIdLabel": "Votre Chat ID", + "chatIdPlaceholder": "-1001234567890", + "fieldsRequired": "Le token Telegram et l'ID du chat sont requis." + }, + "webhook": { + "label": "Webhooks", + "description": "Vous pouvez indiquer un webhook personnalisé pour recevoir des notifications lorsqu'un incident se produit.", + "urlLabel": "URL du webhook", + "urlPlaceholder": "https://votre-serveur.fr/webhook", + "urlRequired": "L'URL du webhook est nécessaire" + }, + "testNotificationDevelop": "Notification de test 2", + "integrationButton": "Intégration de notification", + "testSuccess": "La notification de test a été envoyée avec succès !", + "testFailed": "Échec de l'envoi de la notification de test", + "unsupportedType": "Type de notification non supporté", + "networkError": "Une erreur réseau s'est produite", + "fallback": { + "title": "canal de notification", + "checks": [ + "Alertez les équipes en cas de temps d'arrêt ou de problèmes de performances", + "Informez les ingénieurs lorsque des incidents se produisent", + "Tenez les administrateurs informés des changements apportés au système" + ], + "actionButton": "" + }, + "createButton": "Créer un canal de notification", + "createTitle": "Canal de notification", + "create": { + "success": "La notification a été créée avec succès", + "failed": "Une erreur s'est produite lors de la création de la notification" + }, + "fetch": { + "success": "Les notifications ont été récupérées avec succès", + "failed": "Une erreur s'est produite lors de la récupération des notifications" + }, + "delete": { + "success": "Notification supprimée avec succès", + "failed": "Une erreur s'est produite lors de la suppression de la notification" + }, + "edit": { + "success": "Notification modifiée avec succès", + "failed": "Une erreur s'est produite lors de la modification de la notification" + }, + "test": { + "success": "Notification de test envoyée avec succès", + "failed": "Une erreur s'est produite lors de l'envoi de la notification de test" + } + }, + "testLocale": "testLocale", + "add": "Ajouter", + "monitors": "moniteurs", + "distributedUptimeStatusCreateStatusPage": "page de statut", + "distributedUptimeStatusCreateStatusPageAccess": "Accès", + "distributedUptimeStatusCreateStatusPageReady": "Si votre page de statut est prête, vous pouvez l'indiquer en \"Publiée\".", + "distributedUptimeStatusBasicInfoHeader": "Informations de base", + "distributedUptimeStatusBasicInfoDescription": "Définir le nom de la société et le sous domaine sur lequel votre page de statut pointe.", + "distributedUptimeStatusLogoHeader": "Logo", + "distributedUptimeStatusLogoDescription": "Ajoutez un logo pour votre page de statut", + "distributedUptimeStatusLogoUploadButton": "Téléverser un logo", + "distributedUptimeStatusStandardMonitorsHeader": "Moniteurs standards", + "distributedUptimeStatusStandardMonitorsDescription": "Ajouter un moniteur standard à votre page de statut", + "distributedUptimeStatusCreateYour": "Créer votre", + "distributedUptimeStatusEditYour": "Editer votre", + "distributedUptimeStatusPublishedLabel": "Publiée et visible publiquement", + "distributedUptimeStatusCompanyNameLabel": "Nom de l'entreprise", + "distributedUptimeStatusPageAddressLabel": "Adresse de votre page de statut", + "distributedUptimeStatus30Days": "30 jours", + "distributedUptimeStatus60Days": "60 jours", + "distributedUptimeStatus90Days": "90 jours", + "distributedUptimeStatusPageNotSetUp": "Aucune page de statut n'est mise en place.", + "distributedUptimeStatusContactAdmin": "Veuillez contacter votre administrateur", + "distributedUptimeStatusPageNotPublic": "Cette page de statut n'est pas publique.", + "distributedUptimeStatusPageDeleteDialog": "Voulez-vous supprimer cette page de statut ?", + "distributedUptimeStatusPageDeleteConfirm": "Oui, supprimer la page de statut", + "distributedUptimeStatusPageDeleteDescription": "Supprimer votre page de statut est irréversible.", + "distributedUptimeStatusDevices": "Équipements", + "distributedUptimeStatusUpt": "UPT", + "distributedUptimeStatusUptBurned": "", + "distributedUptimeStatusUptLogo": "Logo Upt", + "incidentsTableNoIncidents": "Aucun incident enregistrée", + "incidentsTablePaginationLabel": "incidents", + "incidentsTableMonitorName": "Nom du moniteur", + "incidentsTableStatus": "Statut", + "incidentsTableDateTime": "Date et heure", + "incidentsTableStatusCode": "Code de statut", + "incidentsTableMessage": "Message", + "incidentsOptionsHeader": "Incidents pour :", + "incidentsOptionsHeaderFilterBy": "Filtrer par :", + "incidentsOptionsHeaderFilterAll": "Tout", + "incidentsOptionsHeaderFilterDown": "Hors ligne", + "incidentsOptionsHeaderFilterCannotResolve": "Impossible de résoudre", + "incidentsOptionsHeaderShow": "Voir:", + "incidentsOptionsHeaderLastHour": "Dernière heure", + "incidentsOptionsHeaderLastDay": "Dernier jour", + "incidentsOptionsHeaderLastWeek": "Dernière semaine", + "incidentsOptionsPlaceholderAllServers": "Tous les serveurs", + "infrastructureCreateYour": "Créer votre", + "infrastructureCreateGeneralSettingsDescription": "Ici, vous pouvez sélectionner l'URL de l'hôte, ainsi que le nom familier et le secret d'autorisation pour vous connecter à l'agent sur le serveur.", + "infrastructureServerRequirement": "Le serveur que vous surveillez doit exécuter le", + "infrastructureCustomizeAlerts": "Personnaliser les alertes", + "infrastructureAlertNotificationDescription": "Envoyer une notification aux utilisateurs lorsque le seuil dépasse un pourcentage spécifié.", + "infrastructureCreateMonitor": "Créer un moniteur d'infrastructure", + "infrastructureProtocol": "Protocole", + "infrastructureServerUrlLabel": "URL du serveur", + "infrastructureDisplayNameLabel": "Nom d'affichage", + "infrastructureAuthorizationSecretLabel": "Secret d'autorisation", + "gb": "GB", + "mb": "MB", + "mem": "Mém", + "memoryUsage": "Utilisation de la mémoire", + "cpu": "CPU", + "cpuUsage": "Utilisation CPU", + "cpuTemperature": "Température CPU", + "diskUsage": "Utilisation du disque", + "used": "Utilisé", + "total": "Total", + "cores": "Coeurs", + "frequency": "Fréquence", + "status": "Statut", + "cpuPhysical": "CPU (Physique)", + "cpuLogical": "CPU (Logique)", + "cpuFrequency": "Fréquence CPU", + "avgCpuTemperature": "Température moyenne du CPU", + "memory": "Mémoire", + "disk": "Disque", + "uptime": "Temps en ligne", + "os": "OS", + "host": "Hôte", + "actions": "Actions", + "integrations": "Intégrations", + "integrationsPrism": "Connecter Prism à votre service préféré.", + "integrationsSlack": "Slack", + "integrationsSlackInfo": "Connecter à Slack et voir les incidents dans un canal", + "integrationsDiscord": "Discord", + "integrationsDiscordInfo": "Connecter à Discord et voir les incidents dans un canal", + "integrationsZapier": "Zapier", + "integrationsZapierInfo": "Envoyer tous les incidents à Zapier", + "commonSave": "Sauvegarder", + "createYour": "Créez votre", + "createMonitor": "Ajouter un moniteur", + "pause": "Mettre en pause", + "resume": "Reprendre", + "editing": "Edition...", + "url": "URL", + "access": "Accès", + "timezone": "Fuseau horaire", + "features": "Fonctionnalités", + "administrator": "Administrateur ?", + "loginHere": "Connexion", + "displayName": "Nom d'affichage", + "urlMonitor": "URL à suivre", + "portToMonitor": "Port du moniteur", + "websiteMonitoring": "Monitoring d'un site internet", + "websiteMonitoringDescription": "Utiliser HTTP(s) pour vérifier votre site ou votre API.", + "pingMonitoring": "Monitoring par ping", + "pingMonitoringDescription": "Vérifier si le serveur est disponible ou pas", + "dockerContainerMonitoring": "Monitoring d'un container Docker", + "dockerContainerMonitoringDescription": "Vérifier si votre container Docker fonctionne ou pas.", + "portMonitoring": "Monitoring d'un port", + "portMonitoringDescription": "Vérifier si votre port est ouvert ou non.", + "createMaintenanceWindow": "Créer une fenêtre de maintenance", + "createMaintenance": "Créer une maintenance", + "editMaintenance": "Modifier la maintenance", + "maintenanceWindowName": "Nom de la fenêtre de maintenance", + "friendlyNameInput": "Nom", + "friendlyNamePlaceholder": "Maintenance à __ : __ pendant ___ minutes", + "maintenanceRepeat": "Répéter la maintenance", + "maintenance": "maintenance", + "duration": "Durée", + "addMonitors": "Ajouter des moniteurs", + "window": "fenêtre", + "cancel": "Annuler", + "message": "Message", + "low": "bas", + "high": "haut", + "statusCode": "Code de statut", + "date&Time": "Date et heure", + "type": "Type", + "statusPageName": "Nom de la page de statut", + "publicURL": "URL publique", + "repeat": "Répéter", + "edit": "Modifier", + "createA": "Créer un", + "remove": "Supprimer", + "maintenanceWindowDescription": "Vos pings ne seront pas envoyés pendant cette fenêtre de temps.", + "startTime": "Date de démarrage", + "timeZoneInfo": "Toutes les dates et heures sont dans le fuseau GMT+0", + "monitorsToApply": "Moniteurs sur lesquels appliquer la fenêtre de maintenance", + "nextWindow": "Prochaine fenêtre de maintenance", + "notFoundButton": "Aller au tableau de bord principal", + "pageSpeedConfigureSettingsDescription": "Vous pouvez sélectionner l'URL de hôte et le type de moniteur.", + "monitorDisplayName": "Nom du moniteur", + "whenNewIncident": "Lors d'un nouvel incident,", + "notifySMS": "Notification par SMS (bientôt)", + "notifyEmails": "Envoyer également une notification par email à plusieurs adresses (bientôt).", + "seperateEmails": "Vous pouvez ajouter plusieurs adresses emails en les séparant par une virgule", + "checkFrequency": "Vérifier la fréquence", + "matchMethod": "Méthode de rapprochement", + "expectedValue": "Valeur attendue", + "deleteDialogTitle": "Voulez-vous vraiment supprimer ce moniteur ?", + "deleteDialogDescription": "Une fois supprimé, le moniteur ne peut pas être récupéré.", + "pageSpeedMonitor": "Moniteur PageSpeed", + "shown": "Visibles", + "ago": "depuis", + "companyName": "Nom de la société", + "pageSpeedDetailsPerformanceReport": "Les valeurs sont estimatives et peuvent varier.", + "pageSpeedDetailsPerformanceReportCalculator": "Voir le calculateur", + "checkingEvery": "Vérification tous les", + "statusPageCreateSettings": "Si votre page de statut est prête, vous pouvez la rendre publique.", + "basicInformation": "Informations de base", + "statusPageCreateBasicInfoDescription": "Définir le nom de la société et le sous-domaine vers lequel pointe votre page de statut.", + "statusPageCreateSelectTimeZoneDescription": "Sélectionnez le fuseau horaire qui sera utilisé pour votre page de statut", + "statusPageCreateAppearanceDescription": "Définir le style par défaut de votre page de statut.", + "statusPageCreateSettingsCheckboxLabel": "Publiée et visible au public", + "statusPageCreateBasicInfoStatusPageAddress": "L'adresse de votre page de statut", + "statusPageCreateTabsContent": "Serveurs de votre page de statut", + "statusPageCreateTabsContentDescription": "Vous pouvez ajouter autant de moniteurs à votre page de statut que vous le souhaitez. Vous pouvez également les réordonner pour améliorer l'expérience utilisateur.", + "statusPageCreateTabsContentFeaturesDescription": "Voir plus de détails sur la page de statut", + "showCharts": "Voir les graphiques", + "showUptimePercentage": "Voir le pourcentage de temps en ligne", + "removeLogo": "Supprimer le logo", + "statusPageStatus": "Aucune page de statut publique n'est déployée", + "statusPageStatusContactAdmin": "Merci de contacter votre administrateur", + "statusPageStatusNotPublic": "Cette page de statut n'est pas piblique", + "statusPageStatusNoPage": "Il n'y a aucune page de statut ici", + "statusPageStatusServiceStatus": "Statut des services", + "deleteStatusPage": "Voulez-vous supprimer cette page de statut ?", + "deleteStatusPageConfirm": "Oui, supprimer la page de statut", + "deleteStatusPageDescription": "Une fois supprimée, votre page de statut ne pourra plus être récupérée.", + "uptimeCreate": "La valeur attendue est utilisée pour comparer le résultat de la réponse et la correspondance détermine le statut du moniteur.", + "uptimeCreateJsonPath": "Cette expression sera évaluée par rapport aux données JSON de la réponse et le résultat sera utilisé pour la comparaison avec la valeur attendue. Voir", + "uptimeCreateJsonPathQuery": "pour la documentation sur le langage de requête.", + "maintenanceTableActionMenuDialogTitle": "Voulez-vous vraiment supprimer cette fenêtre de maintenance ?", + "infrastructureEditYour": "Modifier votre", + "infrastructureEditMonitor": "Sauvegarder le moniteur d'infrastructure", + "infrastructureMonitorCreated": "Le moniteur d'infrastructure a été créé avec succès !", + "infrastructureMonitorUpdated": "Le moniteur d'infrastructure a été modifié avec succès !", + "errorInvalidTypeId": "Le type de notification fourni est invalide", + "errorInvalidFieldId": "Le champ ID fourni est invalide", + "inviteNoTokenFound": "Aucun jeton d'invitation fourni", + "pageSpeedWarning": "Attention : vous n'avez pas encore ajouté de clé API Google PageSpeed. Sans celle-ci, le moniteur PageSpeed ne fonctionnera pas.", + "pageSpeedLearnMoreLink": "Cliquer ici", + "pageSpeedAddApiKey": "pour ajouter votre clé API.", + "update": "Mettre à jour", + "invalidFileFormat": "Format de fichier non supporté !", + "invalidFileSize": "Le fichier est trop volumineux !", + "ClickUpload": "Cliquez pour téléverser", + "DragandDrop": "glisser et déposer", + "MaxSize": "Taille maximum", + "SupportedFormats": "Formats supportés", + "FirstName": "Prénom", + "LastName": "Nom", + "EmailDescriptionText": "Il s'agit de votre adresse email actuelle : elle ne peut pas être changée.", + "YourPhoto": "Photo de profil", + "PhotoDescriptionText": "La photo sera affichée sur votre page de profil.", + "save": "Enregistrer", + "DeleteDescriptionText": "Cela supprimera le compte et toutes les données associées du serveur. Cette opération est irréversible.", + "DeleteAccountWarning": "Supprimer votre compte signifie que vous ne pourrez pas vous reconnecter et que toutes vos données seront supprimés. Ceci est irréversible.", + "DeleteWarningTitle": "Vous supprimez vraiment ce compte ?", + "bulkImport": { + "title": "Import en masse", + "selectFileTips": "Sélectionnez un fichier CSV pour l'upload", + "selectFileDescription": "Vous pouvez télécharger notre ou exemple.", + "selectFile": "Sélectionner un fichier", + "parsingFailed": "L'analyse du fichier a rencontré un problème", + "uploadSuccess": "Moniteurs créés avec succès !", + "validationFailed": "Echec de la validation", + "noFileSelected": "Aucun fichier sélectionné", + "fallbackPage": "Importer un fichier pour ajouter une liste de serveurs en masse", + "invalidFileType": "Type de fichier invalide", + "uploadFailed": "Le téléchargement a échoué" + }, + "DeleteAccountTitle": "Supprimer le compte", + "DeleteAccountButton": "Supprimer le compte", + "publicLink": "Lien public", + "maskedPageSpeedKeyPlaceholder": "*************************************", + "reset": "Réinitialiser", + "ignoreTLSError": "Ignorer les erreurs TLS/SSL", + "tlsErrorIgnored": "Erreurs TLS/SSL ignorées", + "ignoreTLSErrorDescription": "Ignorer les erreurs TLS/SSL et continuer à vérifier l'accessibilité du site web", + "createNew": "Créer un nouveau", + "greeting": { + "prepend": "Salut !", + "append": "L'après-midi est votre libre, rendons-le épique !", + "overview": "Voici un aperçu de vos moniteurs {{type}}." + }, + "roles": { + "superAdmin": "Super admin", + "admin": "Admin", + "teamMember": "Membre de l'équipe", + "demoUser": "Utilisateur de démonstration" + }, + "teamPanel": { + "teamMembers": "Membres de l'équipe", + "filter": { + "all": "Tous", + "member": "Membre" + }, + "inviteTeamMember": "Inviter un membre de l'équipe", + "inviteNewTeamMember": "Inviter un nouveau membre d'équipe", + "inviteDescription": "Lorsque vous ajoutez un nouveau membre à l'équipe, il aura accès à tous les moniteurs.", + "email": "Email", + "selectRole": "Sélectionnez un rôle", + "inviteLink": "Lien d'invitation", + "cancel": "Annuler", + "noMembers": "Il n'y a aucun membre d'équipe avec ce rôle", + "getToken": "Récupérer un token", + "emailToken": "Token de l'email", + "table": { + "name": "Nom", + "email": "Email", + "role": "Rôle", + "created": "Créée" + } + }, + "monitorState": { + "paused": "Pause", + "resumed": "Reprendre", + "active": "Actif" + }, + "menu": { + "uptime": "Temps en ligne", + "pagespeed": "Vitesse", + "infrastructure": "Infrastructure", + "incidents": "Incidents", + "statusPages": "Pages de statut", + "maintenance": "Maintenance", + "integrations": "Intégrations", + "settings": "Paramètres", + "support": "Support", + "discussions": "Discussions", + "docs": "Documentation", + "changelog": "Changelog", + "profile": "Profil", + "password": "Mot de passe", + "team": "Équipe", + "logOut": "Déconnexion", + "notifications": "Notifications", + "logs": "Journaux" + }, + "settingsEmailUser": "Utilisateur SMTP - Utilisateur pour l'authentification, écrase l'adresse email si spécifiée", + "state": "État", + "statusBreadCrumbsStatusPages": "Pages de statut", + "statusBreadCrumbsDetails": "Détails", + "commonSaving": "Sauvegarde...", + "navControls": "Contrôle", + "incidentsPageTitle": "Incidents", + "passwordPanel": { + "passwordChangedSuccess": "Votre mot de passe a été modifié avec succès.", + "passwordInputIncorrect": "La saisie de votre mot de passe est incorrecte.", + "currentPassword": "Mot de passe actuel", + "enterCurrentPassword": "Entrez votre mot de passe actuel", + "newPassword": "Nouveau mot de passe", + "enterNewPassword": "Entrez votre nouveau mot de passe", + "confirmNewPassword": "Confirmer le nouveau mot de passe", + "passwordRequirements": "Le nouveau mot de passe doit contenir au moins 8 caractères et au moins une lettre majuscule, une lettre minuscule, un chiffre et un caractère spécial.", + "saving": "Sauvegarde..." + }, + "emailSent": "Email envoyé avec succès", + "failedToSendEmail": "Une erreur s'est produite lors de l'envoi de l'email", + "settingsTestEmailSuccess": "Email de test envoyé avec succès", + "settingsTestEmailFailed": "Une erreur s'est produite lors de l'envoi de l'email de test", + "settingsTestEmailFailedWithReason": "Une erreur s'est produite lors de l'envoi de l'email de test : {{reason}}", + "settingsTestEmailUnknownError": "Erreur inconnue", + "statusMsg": { + "paused": "Le moniteur est en pause.", + "up": "Votre site est en ligne.", + "down": "Votre site est hors-ligne.", + "pending": "En attente..." + }, + "uptimeGeneralInstructions": { + "http": "Entrez l'URL ou l'IP du moniteur (par exemple https://exemple.fr ou 192.168.1.100) et ajoutez un nom familier qui apparaîtra sur le tableau de bord.", + "ping": "Entrez l'adresse IP ou le nom d'hôte à tester (par exemple, 192.168.1.100 ou exemple.fr) et ajoutez un nom familier qui apparaîtra sur le tableau de bord.", + "docker": "Entrer l'ID Docker du container. Les identifiants Docker doivent être les 64 caractères de l'ID Docker. Vous pouvez utiliser la commande docker inspect pour avoir l'ID complet.", + "port": "Entrez l'URL ou l'adresse IP du serveur, le numéro de port et un nom d'affichage familier qui apparaîtra sur le tableau de bord." + }, + "common": { + "appName": "Checkmate", + "monitoringAgentName": "Capture", + "buttons": { + "toggleTheme": "Afficher le mode clair & sombre" + }, + "toasts": { + "networkError": "Erreur de réseau", + "checkConnection": "Merci de vérifier votre connexion", + "unknownError": "Erreur inconnue" + } + }, + "auth": { + "common": { + "navigation": { + "continue": "Continuer", + "back": "Retour" + }, + "inputs": { + "email": { + "label": "Email", + "placeholder": "jean.dupont@domaine.fr", + "errors": { + "empty": "Pour continuer, merci d'indiquer votre adresse email", + "invalid": "Merci de vérifier la validité de l'adresse email saisie" + } + }, + "password": { + "label": "Mot de passe", + "rules": { + "length": { + "beginning": "Doit contenir au moins", + "highlighted": "Longueur de 8 caractères" + }, + "special": { + "beginning": "Doit contenir au moins", + "highlighted": "un caractère spécial" + }, + "number": { + "beginning": "Doit contenir au moins", + "highlighted": "un nombre" + }, + "uppercase": { + "beginning": "Doit contenir au moins", + "highlighted": "un caractère majuscule" + }, + "lowercase": { + "beginning": "Doit contenir au moins", + "highlighted": "un caractère miniscule" + }, + "match": { + "beginning": "Confirmer le mot de passe et le mot de passe", + "highlighted": "doit valoir" + } + }, + "errors": { + "empty": "Merci d'entrer votre mot de passe", + "length": "Le mot de passe doit faire 8 caractères minimum", + "uppercase": "Le mot de passe doit contenir au minimum une lettre majuscule", + "lowercase": "Le mot de passe doit contenir au minimum une lettre minuscule", + "number": "Le mot de passe doit contenir au minimum un chiffre", + "special": "Le mot de passe doit contenir au minimum un caractère spécial", + "incorrect": "Le mot de passe fourni est erroné" + } + }, + "passwordConfirm": { + "label": "Confirmer le mot de passe", + "placeholder": "Entrez à nouveau le mot de passe pour confirmer", + "errors": { + "empty": "Merci d'entrer votre mot de passe à nouveau pour le confirmer (aide avec la typo)", + "different": "Les mots de passe indiqués ne sont pas identiques, l'un d'entre eux est probablement mal écrit" + } + }, + "firstName": { + "label": "Prénom", + "placeholder": "Jean", + "errors": { + "empty": "Merci d'entrer votre prénom", + "length": "Le prénom doit être inférieur à 50 caractères", + "pattern": "Le prénom ne peut contenir que des lettres, des espaces, des apostrophes ou des tirets" + } + }, + "lastName": { + "label": "Nom", + "placeholder": "Dupont", + "errors": { + "empty": "Merci d'entrer votre nom", + "length": "Le nom doit être inférieur à 50 caractères", + "pattern": "Le nom ne peut contenir que des lettres, des espaces, des apostrophes ou des tirets" + } + } + }, + "errors": { + "validation": "Une erreur s'est produite lors de la validation des données." + }, + "fields": { + "password": { + "errors": { + "incorrect": "Le mot de passe fourni est erroné" + } + }, + "role": { + "errors": { + "min": "" + } + } + } + }, + "login": { + "heading": "Connexion", + "subheadings": { + "stepOne": "Entrez votre adresse email", + "stepTwo": "Entrez votre mot de passe" + }, + "links": { + "forgotPassword": "Mot de passe oublié ?", + "register": "Pas encore de compte ?", + "forgotPasswordLink": "Réinitialiser le mot de passe", + "registerLink": "Inscrivez vous ici" + }, + "toasts": { + "success": "Bon retour ! Vous êtes connecté avec succès.", + "incorrectPassword": "Mot de passe incorrect" + }, + "errors": { + "password": { + "incorrect": "Le mot de passe fourni est erroné" + } + } + }, + "registration": { + "heading": { + "superAdmin": "Créer un super admin", + "user": "Inscription" + }, + "subheadings": { + "stepOne": "Entrez votre informations personnelles", + "stepTwo": "Entrez votre adresse email", + "stepThree": "Créez votre mot de passe" + }, + "description": { + "superAdmin": "Créer le compte \"SuperAdmin\" pour commencer", + "user": "inscription comme utilisateur et demander à l'administrateur principal l'accès aux moniteurs" + }, + "gettingStartedButton": { + "superAdmin": "Créer un compte super admin", + "user": "Inscription comme utilisateur normal" + }, + "termsAndPolicies": "En créant un compte, vous comprenez et acceptez les Conditions d'Utilisation et la Politique de Confidentialité.", + "links": { + "login": "Déjà inscrit ? Connexion" + }, + "toasts": { + "success": "Bienvenue ! Votre compte a été créé avec succès." + } + }, + "forgotPassword": { + "heading": "Mot de passe oublié ?", + "subheadings": { + "stepOne": "Pas d'inquiétude, nous allons vous envoyer des informations pour réinitialiser votre mot de passe.", + "stepTwo": "Nous avons envoyer un lien de réinitialisation du mot de passe à l'email ", + "stepThree": "Votre nouveau mot de passe doit être différent des précédents.", + "stepFour": "Votre mot de passe a été réinitialisé avec succès. Cliquez ci-dessous pour vous connecter." + }, + "buttons": { + "openEmail": "Ouvrir votre application email", + "resetPassword": "Réinitialiser le mot de passe" + }, + "imageAlts": { + "passwordKey": "Icône du mot de passe", + "email": "Icône de l'email", + "lock": "Icône de verrouillage", + "passwordConfirm": "Icône de confirmation du mot de passe" + }, + "links": { + "login": "Retourner à la page de connexion", + "resend": "Vous n'avez pas reçu l'email ? Cliquez ici pour le renvoyer" + }, + "toasts": { + "sent": "Instructions envoyées sur l'adresse email .", + "emailNotFound": "L'email n'a pas été trouvé.", + "redirect": "Redirection dans ...", + "success": "Votre mot de passe", + "error": "Une erreur s'est produite lors de la réinitialisation du mot de passe. Réessayez ultérieurement ou contactez le support." + } + } + }, + "errorPages": { + "serverUnreachable": { + "toasts": { + "reconnected": "Connexion rétablie avec le serveur.", + "stillUnreachable": "Le serveur est toujours inaccessible. Réessayez ultérieurement." + }, + "alertBox": "Erreur de connexion au serveur", + "description": "Nous ne parvenons pas à nous connecter au serveur. Veuillez vérifier votre connexion Internet ou vérifier votre configuration de déploiement si le problème persiste.", + "retryButton": { + "default": "Réessayer la connexion", + "processing": "Connexion..." + } + } + }, + "createNotifications": { + "title": "Créer un canal de notification", + "nameSettings": { + "title": "Nom", + "description": "Une description pour votre intégration.", + "nameLabel": "Nom", + "namePlaceholder": "e.g. notifications Slack" + }, + "typeSettings": { + "title": "Type", + "description": "Sélectionnez le type de canal de notification que vous souhaitez créer.", + "typeLabel": "Type" + }, + "emailSettings": { + "title": "Email", + "description": "Adresse email du destinataire", + "emailLabel": "Adresse email", + "emailPlaceholder": "e.g. jean@exemple.fr" + }, + "slackSettings": { + "title": "Slack", + "description": "Configurez votre webhook Slack ici", + "webhookLabel": "URL du webhook Slack", + "webhookPlaceholder": "https://hooks.slack.com/services/..." + }, + "pagerdutySettings": { + "title": "PagerDuty", + "description": "Configurez l'intégration PagerDuty ici", + "integrationKeyLabel": "Clé d'intégration", + "integrationKeyPlaceholder": "1234567890" + }, + "discordSettings": { + "title": "Discord", + "description": "Configurez votre webhook Discord ici", + "webhookLabel": "URL du webhook Discord", + "webhookPlaceholder": "https://your-server.com/webhook" + }, + "webhookSettings": { + "title": "Webhook", + "description": "Configurez votre webhook ici", + "webhookLabel": "URL du webhook", + "webhookPlaceholder": "https://your-server.com/webhook" + }, + "testNotification": "Notification de test", + "dialogDeleteTitle": "Voulez-vous vraiment supprimer cette notification ?", + "dialogDeleteConfirm": "Supprimer" + }, + "notificationConfig": { + "title": "Notifications", + "description": "Sélectionnez les canaux de notification que vous souhaitez utiliser" + }, + "monitorStatus": { + "checkingEvery": "Vérifier toutes les {{interval}}", + "withCaptureAgent": "avec l'agent Capture {{version}}", + "up": "en ligne", + "down": "hors ligne", + "paused": "en pause" + }, + "advancedMatching": "Filtrage avancé", + "sendTestNotifications": "Envoyer une notification de test", + "selectAll": "Sélectionner tout", + "showAdminLoginLink": "Voir le lien \"Administrateur ? Connectez-vous ici\" sur la page de statut", + "logsPage": { + "title": "Journaux", + "description": "Journaux systèmes - 1000 dernières lignes", + "tabs": { + "queue": "File d'attente des tâches", + "logs": "Journaux des serveurs", + "diagnostics": "Diagnostic" + }, + "toast": { + "fetchLogsSuccess": "Journaux récupérés avec succès" + }, + "logLevelSelect": { + "title": "Niveau de journalisation", + "values": { + "all": "Tout", + "info": "Info", + "warn": "Attention", + "error": "Erreur", + "debug": "Debug" + } + } + }, + "queuePage": { + "title": "File d'attente", + "refreshButton": "Rafraîchir", + "flushButton": "Vider la file d'attente", + "jobTable": { + "title": "Tâches actuellement dans la file d'attente", + "idHeader": "ID du moniteur", + "urlHeader": "URL", + "typeHeader": "Type", + "activeHeader": "Actif", + "lockedAtHeader": "Verrouillé le", + "runCountHeader": "Nombre de tentatives", + "failCountHeader": "Nombre d'échecs", + "lastRunHeader": "Dernière tentative le", + "lastFinishedAtHeader": "Dernière finalisation à", + "lastRunTookHeader": "Dernière exécution effectuée" + }, + "metricsTable": { + "title": "Métriques des files", + "metricHeader": "Métrique", + "valueHeader": "Valeur" + }, + "failedJobTable": { + "title": "Tâches en échec", + "monitorIdHeader": "ID du moniteur", + "monitorUrlHeader": "URL du moniteur", + "failCountHeader": "Nombre de tentatives échouées", + "failedAtHeader": "Dernier échec à", + "failReasonHeader": "Raison de l'échec" + } + }, + "export": { + "title": "Export des moniteurs", + "success": "Les moniteurs ont été exportés avec succès !", + "failed": "Une erreur s'est produite lors de l'export des moniteurs" + }, + "monitorActions": { + "title": "Export/Import", + "import": "Importer des moniteurs", + "export": "Exporter des moniteurs", + "deleteSuccess": "Le moniteur a été supprimé avec succès", + "deleteFailed": "Une erreur s'est produite lors de la suppression du moniteur", + "details": "Détails" + }, + "settingsPage": { + "aboutSettings": { + "labelDevelopedBy": "Développé par Bluewave Labs", + "labelVersion": "Version", + "title": "A propos" + }, + "demoMonitorsSettings": { + "buttonAddMonitors": "Ajouter un moniteur de démonstration", + "description": "Ajouter un moniteur à des fins de démonstration.", + "title": "Moniteurs de démonstration" + }, + "emailSettings": { + "buttonSendTestEmail": "Envoyer un mail de test", + "description": "Configurez les paramètres emails pour le système. Il s'agit du système utilisé pour les notifications & alertes.", + "descriptionTransport": "Ceci créé un transport SMTP pour NodeMailer", + "labelAddress": "Adresse email - utilisée pour la connexion", + "labelConnectionHost": "Hôte SMTP - Nom d'hôte à utiliser dans l'introduction HELO/EHLO", + "labelHost": "Hôte SMTP - Nom d'hôte ou IP sur lequel se connecter", + "labelIgnoreTLS": "Désactiver STARTTLS : ne pas utiliser TLS même si le serveur le supporte", + "labelPassword": "Mot de passe SMTP - Mot de passe requis pour l'authentification", + "labelPasswordSet": "Le mot de passe est renseigné. Réinitialisez le pour le changer.", + "labelPool": "Activer le pool de connexion SMTP afin de réutiliser les connexions existantes pour améliorer la performance", + "labelPort": "Port SMTP - Port auquel se connecter", + "labelRejectUnauthorized": "Rejeter les certificats non valides : rejeter les connexions avec des certificats auto-signés ou non fiables", + "labelRequireTLS": "Forcer STARTTLS : nécessite la mise à niveau TLS, échouer si ce n'est pas supporté", + "labelSecure": "Utiliser le SSL (recommandé) : chiffrer la connexion SSL/TLS", + "labelTLSServername": "Nom du serveur TLS - Nom d'hôte facultatif pour la validation TLS lorsque l'hôte est une adresse IP", + "labelUser": "Utilisateur SMTP - Nom d'utilisateur pour l'authentification, remplace l'adresse email si spécifié", + "linkTransport": "Voir les spécifications ici", + "placeholderUser": "Laisser vide si non requis", + "title": "Email", + "toastEmailRequiredFieldsError": "L'adresse email, l'hôte, le port et le mot de passe sont obligatoires." + }, + "pageSpeedSettings": { + "description": "Entrez votre clé API Google PageSpeed pour activer la surveillance Google PageSpeed. Cliquez sur Réinitialiser pour mettre à jour la clé.", + "labelApiKeySet": "La clé API est générée. Cliquez sur \"Réinitialiser\" pour la modifier.", + "labelApiKey": "Clé API PageSpeed", + "title": "Clé API Google PageSpeed" + }, + "saveButtonLabel": "Sauvegarder", + "statsSettings": { + "clearAllStatsButton": "Supprimer toutes les statistiques", + "clearAllStatsDescription": "Supprimer toutes les statistiques. Cette action est irréversible.", + "clearAllStatsDialogConfirm": "Oui, supprimer toutes les statistiques", + "clearAllStatsDialogDescription": "Une fois supprimés, l'historique et les statistiques du moniteur ne peuvent plus être récupérés.", + "clearAllStatsDialogTitle": "Voulez-vous supprimer toutes les statistiques ?", + "description": "Définissez la durée pendant laquelle vous souhaitez conserver les données historiques. Vous pouvez également effacer toutes les données existantes.", + "labelTTL": "Le nombre de jours dont vous souhaitez garder l'historique.", + "labelTTLOptional": "0 pour une durée infinie", + "title": "Historique du moniteur" + }, + "systemResetSettings": { + "buttonRemoveAllMonitors": "Supprimer tous les moniteurs", + "description": "Supprimer toutes les moniteurs du système.", + "dialogConfirm": "Oui, supprimer tous les moniteurs", + "dialogDescription": "Une fois supprimés, les moniteurs ne peuvent plus être récupérés.", + "dialogTitle": "Voulez-vous supprimer tous les moniteurs ?", + "title": "Réinitialisation du système" + }, + "timezoneSettings": { + "description": "Sélectionnez le fuseau horaire utilisé pour afficher les dates et les heures dans l'application.", + "label": "Fuseau horaire d'affichage", + "title": "Fuseau horaire d'affichage" + }, + "title": "Paramètres", + "uiSettings": { + "description": "Changer entre le mode clair et sombre, ou changer le langage de l'interface utilisateur.", + "labelLanguage": "Langage", + "labelTheme": "Thème", + "title": "Apparence" + }, + "urlSettings": { + "description": "Affichez l'adresse IP ou l'URL du moniteur sur la page de statut publique. Si cette option est désactivée, seul le nom du moniteur sera affiché afin de protéger les informations sensibles.", + "label": "Afficher l'IP/URL sur la page de statut", + "selectDisabled": "Désactivé", + "selectEnabled": "Activé", + "title": "IP/URL du moniteur sur la page de statut" + } + }, + "statusPageCreate": { + "buttonSave": "Sauvegarder" + }, + "incidentsOptionsHeaderFilterResolved": "Résolu", + "settingsSave": "Sauvegarder", + "statusPageCreateAppearanceTitle": "Apparence", + "confirmPassword": "Confirmer le mot de passe", + "monitorHooks": { + "failureAddDemoMonitors": "Une erreur s'est produite lors de l'ajout des moniteurs de démonstration", + "successAddDemoMonitors": "Les moniteurs de démonstration ont bien été ajoutés" + }, + "settingsAppearance": "Apparance", + "settingsDisplayTimezone": "Fuseau horaire d'affichage", + "settingsGeneralSettings": "Paramètres généraux", + "incidentsOptionsHeaderTotalIncidents": "Nombre d'incidents", + "statusPage": { + "deleteSuccess": "La page de statut a été supprimée avec succès", + "deleteFailed": "Une erreur s'est produite lors de l'ajout de la page de statut", + "createSuccess": "La page de statut a été ajoutée avec succès", + "updateSuccess": "La page de statut a été modifiée avec succès", + "generalSettings": "Paramètres généraux", + "contents": "Contenus", + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "testNotificationsDisabled": "Il n'y aucune notification paramétrée pour ce moniteur. Vous pouvez en ajouter une en cliquant sur le bouton 'Configurer'", + "incidentsTableResolvedAt": "Résolu le", + "incidentsTableActionResolve": "Résoudre", + "checkHooks": { + "failureResolveOne": "Une erreur s'est produite lors de la résolution de l'incident.", + "failureResolveAll": "Une erreur s'est produite lors de la résolution des incidents.", + "failureResolveMonitor": "" + }, + "checkFormError": "Merci de vérifier les erreurs du formulaire", + "diagnosticsPage": { + "diagnosticDescription": "Diagnostic du système", + "statsDescription": "Statistiques du système", + "gauges": { + "heapAllocationTitle": "Allocation du tas", + "heapAllocationSubtitle": "% de mémoire disponible", + "heapUsageTitle": "Utilisation du tas", + "heapUsageSubtitle": "% de mémoire disponible", + "heapUtilizationTitle": "Utilisation du tas", + "heapUtilizationSubtitle": "% d'allocation", + "instantCpuUsageTitle": "Utilisation instantanée du CPU", + "instantCpuUsageSubtitle": "% de CPU utilisé" + }, + "stats": { + "eventLoopDelayTitle": "Délai de la boucle d'évènements", + "uptimeTitle": "Temps en ligne", + "usedHeapSizeTitle": "Taille du tas utilisé", + "totalHeapSizeTitle": "Taille total du tas", + "osMemoryLimitTitle": "Limite de mémoire de l'OS" + } + }, + "pageSpeedLighthouseAPI": "Utilisez l'API Lighthouse PageSpeed pour suivre votre site web", + "time": { + "threeMinutes": "3 minutes", + "fiveMinutes": "5 minutes", + "tenMinutes": "10 minutes", + "twentyMinutes": "20 minutes", + "oneHour": "1 heure", + "oneDay": "1 jour", + "oneWeek": "1 semaine" + }, + "general": { + "noOptionsFound": "" + }, + "infrastructureMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "maintenanceWindow": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "pageSpeed": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "uptimeMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "editUserPage": { + "form": { + "email": "", + "firstName": "", + "lastName": "", + "role": "", + "save": "" + }, + "table": { + "actionHeader": "", + "roleHeader": "" + }, + "title": "", + "toast": { + "successUserUpdate": "", + "validationErrors": "" + } + }, + "incidentsPageActionResolveMonitor": "", + "incidentsPageActionResolveAll": "" +} diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index b3b8fc4ce..782fdd9e0 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -1,836 +1,973 @@ { - "submit": "", - "title": "", - "distributedStatusHeaderText": "", - "distributedStatusSubHeaderText": "", - "settingsDisabled": "", - "settingsSuccessSaved": "", - "settingsFailedToSave": "", - "settingsStatsCleared": "", - "settingsFailedToClearStats": "", - "settingsFailedToAddDemoMonitors": "", - "settingsMonitorsDeleted": "", - "settingsFailedToDeleteMonitors": "", - "starPromptTitle": "", - "starPromptDescription": "", - "https": "", - "http": "", - "monitor": "", - "aboutus": "", - "now": "", - "delete": "", - "configure": "", - "responseTime": "", - "ms": "", - "bar": "", - "area": "", - "country": "", - "city": "", - "response": "", - "monitorStatusUp": "", - "monitorStatusDown": "", - "webhookSendSuccess": "", - "webhookSendError": "", - "webhookUnsupportedPlatform": "", - "distributedRightCategoryTitle": "", - "distributedStatusServerMonitors": "", - "distributedStatusServerMonitorsDescription": "", - "distributedUptimeCreateSelectURL": "", - "distributedUptimeCreateChecks": "", - "distributedUptimeCreateChecksDescription": "", - "distributedUptimeCreateIncidentNotification": "", - "distributedUptimeCreateIncidentDescription": "", - "distributedUptimeCreateAdvancedSettings": "", - "distributedUptimeDetailsNoMonitorHistory": "", - "distributedUptimeDetailsStatusHeaderUptime": "", - "distributedUptimeDetailsStatusHeaderLastUpdate": "", + "submit": "送信", + "title": "タイトル", + "distributedStatusHeaderText": "リアルタイム、実デバイス対応", + "distributedStatusSubHeaderText": "世界中の数百万台のデバイスを活用し、グローバル地域、国、都市別にシステムパフォーマンスを表示", + "settingsDisabled": "無効", + "settingsSuccessSaved": "設定が正常に保存されました", + "settingsFailedToSave": "設定の保存に失敗しました", + "settingsStatsCleared": "統計が正常にクリアされました", + "settingsFailedToClearStats": "統計のクリアに失敗しました", + "settingsMonitorsDeleted": "すべてのモニターが正常に削除されました", + "settingsFailedToDeleteMonitors": "すべてのモニターの削除に失敗しました", + "starPromptTitle": "Checkmateにスター", + "starPromptDescription": "GitHubで最新リリースを確認し、コミュニティの成長を支援", + "https": "HTTPS", + "http": "HTTP", + "monitor": "モニター", + "aboutus": "私たちについて", + "now": "今", + "delete": "削除", + "configure": "設定", + "responseTime": "レスポンス時間", + "ms": "ミリ秒", + "bar": "バー", + "area": "エリア", + "country": "国", + "city": "都市", + "response": "レスポンス", + "monitorStatusUp": "モニター {name} ({url}) がアップして応答中です", + "monitorStatusDown": "モニター {name} ({url}) がダウンして応答していません", + "webhookSendSuccess": "Webhook通知が正常に送信されました", + "webhookSendError": "{platform}へのWebhook通知送信エラー", + "webhookUnsupportedPlatform": "サポートされていないプラットフォーム: {platform}", + "distributedRightCategoryTitle": "モニター", + "distributedStatusServerMonitors": "サーバーモニター", + "distributedStatusServerMonitorsDescription": "関連サーバーのステータスを監視", + "distributedUptimeCreateSelectURL": "ここでホストのURL、モニタータイプを選択できます。", + "distributedUptimeCreateChecks": "実行するチェック", + "distributedUptimeCreateChecksDescription": "サイト追加後はいつでもチェックを追加・削除できます。", + "distributedUptimeCreateIncidentNotification": "インシデント通知", + "distributedUptimeCreateIncidentDescription": "インシデント発生時、ユーザーに通知。", + "distributedUptimeCreateAdvancedSettings": "詳細設定", + "distributedUptimeDetailsNoMonitorHistory": "このモニターのチェック履歴はまだありません。", + "distributedUptimeDetailsStatusHeaderUptime": "稼働時間:", + "distributedUptimeDetailsStatusHeaderLastUpdate": "最終更新", "notifications": { - "enableNotifications": "", - "testNotification": "", - "addOrEditNotifications": "", + "enableNotifications": "{{platform}}通知を有効化", + "testNotification": "テスト通知", + "addOrEditNotifications": "通知を追加または編集", "slack": { - "label": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "", - "webhookRequired": "" + "label": "Slack", + "description": "Slack通知を有効にするには、Slackアプリを作成し、incoming webhookを有効にします。その後、ここでWebhook URLを提供するだけです。", + "webhookLabel": "Webhook URL", + "webhookPlaceholder": "https://hooks.slack.com/services/...", + "webhookRequired": "Slack Webhook URLが必要です" }, "discord": { - "label": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "", - "webhookRequired": "" + "label": "Discord", + "description": "Webhookを使用してDiscord通知経由でCheckmateからDiscordチャンネルにデータを送信するには、DiscordのIncoming Webhook機能を使用できます。", + "webhookLabel": "Discord Webhook URL", + "webhookPlaceholder": "https://discord.com/api/webhooks/...", + "webhookRequired": "Discord Webhook URLが必要です" }, "telegram": { - "label": "", - "description": "", - "tokenLabel": "", - "tokenPlaceholder": "", - "chatIdLabel": "", - "chatIdPlaceholder": "", - "fieldsRequired": "" + "label": "Telegram", + "description": "Telegram通知を有効にするには、Telegramボットを作成・管理するための公式ボットであるBotFatherを使用してTelegramボットを作成します。次に、APIトークンとチャットIDを取得してここに記入します。", + "tokenLabel": "ボットトークン", + "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", + "chatIdLabel": "チャットID", + "chatIdPlaceholder": "-1001234567890", + "fieldsRequired": "TelegramトークンとチャットIDが必要です" }, "webhook": { - "label": "", - "description": "", - "urlLabel": "", - "urlPlaceholder": "", - "urlRequired": "" + "label": "Webhook", + "description": "インシデント発生時に通知を受信するためのカスタムWebhookを設定できます。", + "urlLabel": "Webhook URL", + "urlPlaceholder": "https://your-server.com/webhook", + "urlRequired": "Webhook URLが必要です" }, - "testNotificationDevelop": "", - "integrationButton": "", - "testSuccess": "", - "testFailed": "", - "unsupportedType": "", - "networkError": "", + "testNotificationDevelop": "テスト通知2", + "integrationButton": "通知統合", + "testSuccess": "テスト通知が正常に送信されました!", + "testFailed": "テスト通知の送信に失敗しました", + "unsupportedType": "サポートされていない通知タイプ", + "networkError": "ネットワークエラーが発生しました", "fallback": { - "title": "", - "checks": [""] + "title": "通知チャンネル", + "checks": [ + "ダウンタイムやパフォーマンス問題についてチームにアラート", + "インシデント発生時にエンジニアに通知", + "システム変更について管理者に情報提供" + ], + "actionButton": "" }, - "createButton": "", - "createTitle": "", + "createButton": "通知チャンネルを作成", + "createTitle": "通知チャンネル", "create": { - "success": "", - "failed": "" + "success": "通知が正常に作成されました", + "failed": "通知の作成に失敗しました" }, "fetch": { - "success": "", - "failed": "" + "success": "通知が正常に取得されました", + "failed": "通知の取得に失敗しました" }, "delete": { - "success": "", - "failed": "" + "success": "通知が正常に削除されました", + "failed": "通知の削除に失敗しました" }, "edit": { - "success": "", - "failed": "" + "success": "通知が正常に更新されました", + "failed": "通知の更新に失敗しました" }, "test": { - "success": "", - "failed": "" + "success": "テスト通知が正常に送信されました", + "failed": "テスト通知の送信に失敗しました" } }, - "testLocale": "", - "add": "", - "monitors": "", - "distributedUptimeStatusCreateStatusPage": "", - "distributedUptimeStatusCreateStatusPageAccess": "", - "distributedUptimeStatusCreateStatusPageReady": "", - "distributedUptimeStatusBasicInfoHeader": "", - "distributedUptimeStatusBasicInfoDescription": "", - "distributedUptimeStatusLogoHeader": "", - "distributedUptimeStatusLogoDescription": "", - "distributedUptimeStatusLogoUploadButton": "", - "distributedUptimeStatusStandardMonitorsHeader": "", - "distributedUptimeStatusStandardMonitorsDescription": "", - "distributedUptimeStatusCreateYour": "", - "distributedUptimeStatusEditYour": "", - "distributedUptimeStatusPublishedLabel": "", - "distributedUptimeStatusCompanyNameLabel": "", - "distributedUptimeStatusPageAddressLabel": "", - "distributedUptimeStatus30Days": "", - "distributedUptimeStatus60Days": "", - "distributedUptimeStatus90Days": "", - "distributedUptimeStatusPageNotSetUp": "", - "distributedUptimeStatusContactAdmin": "", - "distributedUptimeStatusPageNotPublic": "", - "distributedUptimeStatusPageDeleteDialog": "", - "distributedUptimeStatusPageDeleteConfirm": "", - "distributedUptimeStatusPageDeleteDescription": "", - "distributedUptimeStatusDevices": "", - "distributedUptimeStatusUpt": "", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "", - "incidentsTableNoIncidents": "", - "incidentsTablePaginationLabel": "", - "incidentsTableMonitorName": "", - "incidentsTableStatus": "", - "incidentsTableDateTime": "", - "incidentsTableStatusCode": "", - "incidentsTableMessage": "", - "incidentsOptionsHeader": "", - "incidentsOptionsHeaderFilterBy": "", - "incidentsOptionsHeaderFilterAll": "", - "incidentsOptionsHeaderFilterDown": "", - "incidentsOptionsHeaderFilterCannotResolve": "", - "incidentsOptionsHeaderShow": "", - "incidentsOptionsHeaderLastHour": "", - "incidentsOptionsHeaderLastDay": "", - "incidentsOptionsHeaderLastWeek": "", - "incidentsOptionsPlaceholderAllServers": "", - "infrastructureCreateYour": "", - "infrastructureCreateGeneralSettingsDescription": "", - "infrastructureServerRequirement": "", - "infrastructureCustomizeAlerts": "", - "infrastructureAlertNotificationDescription": "", - "infrastructureCreateMonitor": "", - "infrastructureProtocol": "", - "infrastructureServerUrlLabel": "", - "infrastructureDisplayNameLabel": "", - "infrastructureAuthorizationSecretLabel": "", - "gb": "", - "mb": "", - "mem": "", - "memoryUsage": "", - "cpu": "", - "cpuUsage": "", - "cpuTemperature": "", - "diskUsage": "", - "used": "", - "total": "", - "cores": "", - "frequency": "", - "status": "", - "cpuPhysical": "", - "cpuLogical": "", - "cpuFrequency": "", - "avgCpuTemperature": "", - "memory": "", - "disk": "", - "uptime": "", - "os": "", - "host": "", - "actions": "", - "integrations": "", - "integrationsPrism": "", - "integrationsSlack": "", - "integrationsSlackInfo": "", - "integrationsDiscord": "", - "integrationsDiscordInfo": "", - "integrationsZapier": "", - "integrationsZapierInfo": "", - "commonSave": "", - "createYour": "", - "createMonitor": "", - "pause": "", - "resume": "", - "editing": "", - "url": "", - "access": "", - "timezone": "", - "features": "", - "administrator": "", - "loginHere": "", - "displayName": "", - "urlMonitor": "", - "portToMonitor": "", - "websiteMonitoring": "", - "websiteMonitoringDescription": "", - "pingMonitoring": "", - "pingMonitoringDescription": "", - "dockerContainerMonitoring": "", - "dockerContainerMonitoringDescription": "", - "portMonitoring": "", - "portMonitoringDescription": "", - "createMaintenanceWindow": "", - "createMaintenance": "", - "editMaintenance": "", - "maintenanceWindowName": "", - "friendlyNameInput": "", - "friendlyNamePlaceholder": "", - "maintenanceRepeat": "", - "maintenance": "", - "duration": "", - "addMonitors": "", - "window": "", - "cancel": "", - "message": "", - "low": "", - "high": "", - "statusCode": "", - "date&Time": "", - "type": "", - "statusPageName": "", - "publicURL": "", - "repeat": "", - "edit": "", - "createA": "", - "remove": "", - "maintenanceWindowDescription": "", - "startTime": "", - "timeZoneInfo": "", - "monitorsToApply": "", - "nextWindow": "", - "notFoundButton": "", - "pageSpeedConfigureSettingsDescription": "", - "monitorDisplayName": "", - "whenNewIncident": "", - "notifySMS": "", - "notifyEmails": "", - "seperateEmails": "", - "checkFrequency": "", - "matchMethod": "", - "expectedValue": "", - "deleteDialogTitle": "", - "deleteDialogDescription": "", - "pageSpeedMonitor": "", - "shown": "", - "ago": "", - "companyName": "", - "pageSpeedDetailsPerformanceReport": "", - "pageSpeedDetailsPerformanceReportCalculator": "", - "checkingEvery": "", - "statusPageCreateSettings": "", - "basicInformation": "", - "statusPageCreateBasicInfoDescription": "", - "statusPageCreateSelectTimeZoneDescription": "", - "statusPageCreateAppearanceDescription": "", - "statusPageCreateSettingsCheckboxLabel": "", - "statusPageCreateBasicInfoStatusPageAddress": "", - "statusPageCreateTabsContent": "", - "statusPageCreateTabsContentDescription": "", - "statusPageCreateTabsContentFeaturesDescription": "", - "showCharts": "", - "showUptimePercentage": "", - "removeLogo": "", - "statusPageStatus": "", - "statusPageStatusContactAdmin": "", - "statusPageStatusNotPublic": "", - "statusPageStatusNoPage": "", - "statusPageStatusServiceStatus": "", - "deleteStatusPage": "", - "deleteStatusPageConfirm": "", - "deleteStatusPageDescription": "", - "uptimeCreate": "", - "uptimeCreateJsonPath": "", - "uptimeCreateJsonPathQuery": "", - "maintenanceTableActionMenuDialogTitle": "", - "infrastructureEditYour": "", - "infrastructureEditMonitor": "", - "infrastructureMonitorCreated": "", - "infrastructureMonitorUpdated": "", - "errorInvalidTypeId": "", - "errorInvalidFieldId": "", - "inviteNoTokenFound": "", - "pageSpeedWarning": "", - "pageSpeedLearnMoreLink": "", - "pageSpeedAddApiKey": "", - "update": "", - "invalidFileFormat": "", - "invalidFileSize": "", - "ClickUpload": "", - "DragandDrop": "", - "MaxSize": "", - "SupportedFormats": "", - "FirstName": "", - "LastName": "", - "EmailDescriptionText": "", - "YourPhoto": "", - "PhotoDescriptionText": "", - "save": "", - "DeleteDescriptionText": "", - "DeleteAccountWarning": "", - "DeleteWarningTitle": "", + "testLocale": "テストロケール", + "add": "追加", + "monitors": "モニター", + "distributedUptimeStatusCreateStatusPage": "ステータスページ", + "distributedUptimeStatusCreateStatusPageAccess": "アクセス", + "distributedUptimeStatusCreateStatusPageReady": "ステータスページの準備ができたら、公開済みとしてマークできます。", + "distributedUptimeStatusBasicInfoHeader": "基本情報", + "distributedUptimeStatusBasicInfoDescription": "会社名とステータスページが指すサブドメインを定義。", + "distributedUptimeStatusLogoHeader": "ロゴ", + "distributedUptimeStatusLogoDescription": "ステータスページ用のロゴをアップロード", + "distributedUptimeStatusLogoUploadButton": "ロゴをアップロード", + "distributedUptimeStatusStandardMonitorsHeader": "標準モニター", + "distributedUptimeStatusStandardMonitorsDescription": "標準モニターをステータスページに添付。", + "distributedUptimeStatusCreateYour": "作成する", + "distributedUptimeStatusEditYour": "編集する", + "distributedUptimeStatusPublishedLabel": "公開済みで一般に表示", + "distributedUptimeStatusCompanyNameLabel": "会社名", + "distributedUptimeStatusPageAddressLabel": "ステータスページアドレス", + "distributedUptimeStatus30Days": "30日", + "distributedUptimeStatus60Days": "60日", + "distributedUptimeStatus90Days": "90日", + "distributedUptimeStatusPageNotSetUp": "ステータスページが設定されていません。", + "distributedUptimeStatusContactAdmin": "管理者にお問い合わせください", + "distributedUptimeStatusPageNotPublic": "このステータスページは非公開です。", + "distributedUptimeStatusPageDeleteDialog": "このステータスページを削除しますか?", + "distributedUptimeStatusPageDeleteConfirm": "はい、ステータスページを削除", + "distributedUptimeStatusPageDeleteDescription": "削除されると、ステータスページは復元できません。", + "distributedUptimeStatusDevices": "デバイス", + "distributedUptimeStatusUpt": "UPT", + "distributedUptimeStatusUptBurned": "UPT消費", + "distributedUptimeStatusUptLogo": "Uptロゴ", + "incidentsTableNoIncidents": "記録されたインシデントはありません", + "incidentsTablePaginationLabel": "インシデント", + "incidentsTableMonitorName": "モニター名", + "incidentsTableStatus": "ステータス", + "incidentsTableDateTime": "日付と時刻", + "incidentsTableStatusCode": "ステータスコード", + "incidentsTableMessage": "メッセージ", + "incidentsOptionsHeader": "インシデント対象:", + "incidentsOptionsHeaderFilterBy": "フィルター:", + "incidentsOptionsHeaderFilterAll": "すべて", + "incidentsOptionsHeaderFilterDown": "ダウン", + "incidentsOptionsHeaderFilterCannotResolve": "解決不可", + "incidentsOptionsHeaderShow": "表示:", + "incidentsOptionsHeaderLastHour": "最終時間", + "incidentsOptionsHeaderLastDay": "最終日", + "incidentsOptionsHeaderLastWeek": "最終週", + "incidentsOptionsPlaceholderAllServers": "すべてのサーバー", + "infrastructureCreateYour": "作成する", + "infrastructureCreateGeneralSettingsDescription": "ここでホストのURL、フレンドリーネーム、サーバーエージェントに接続するための認証シークレットを選択できます。", + "infrastructureServerRequirement": "監視中のサーバーで実行されている必要があります", + "infrastructureCustomizeAlerts": "アラートをカスタマイズ", + "infrastructureAlertNotificationDescription": "しきい値が指定された割合を超えた場合にユーザーに通知を送信。", + "infrastructureCreateMonitor": "インフラストラクチャモニターを作成", + "infrastructureProtocol": "プロトコル", + "infrastructureServerUrlLabel": "サーバーURL", + "infrastructureDisplayNameLabel": "表示名", + "infrastructureAuthorizationSecretLabel": "認証シークレット", + "gb": "GB", + "mb": "MB", + "mem": "メモリ", + "memoryUsage": "メモリ使用率", + "cpu": "CPU", + "cpuUsage": "CPU使用率", + "cpuTemperature": "CPU温度", + "diskUsage": "ディスク使用率", + "used": "使用済み", + "total": "合計", + "cores": "コア", + "frequency": "頻度", + "status": "ステータス", + "cpuPhysical": "CPU(物理)", + "cpuLogical": "CPU(論理)", + "cpuFrequency": "CPU周波数", + "avgCpuTemperature": "平均CPU温度", + "memory": "メモリ", + "disk": "ディスク", + "uptime": "稼働時間", + "os": "OS", + "host": "ホスト", + "actions": "アクション", + "integrations": "統合", + "integrationsPrism": "Prismをお気に入りのサービスに接続。", + "integrationsSlack": "Slack", + "integrationsSlackInfo": "Slackと接続してチャンネルでインシデントを表示", + "integrationsDiscord": "Discord", + "integrationsDiscordInfo": "Discordと接続してチャンネルでインシデントを直接表示", + "integrationsZapier": "Zapier", + "integrationsZapierInfo": "すべてのインシデントをZapierに送信し、どこでも表示", + "commonSave": "保存", + "createYour": "作成する", + "createMonitor": "モニターを作成", + "pause": "一時停止", + "resume": "再開", + "editing": "編集中...", + "url": "URL", + "access": "アクセス", + "timezone": "タイムゾーン", + "features": "機能", + "administrator": "管理者?", + "loginHere": "こちらでログイン", + "displayName": "表示名", + "urlMonitor": "監視するURL", + "portToMonitor": "監視するポート", + "websiteMonitoring": "ウェブサイト監視", + "websiteMonitoringDescription": "HTTP(s)を使用してウェブサイトやAPIエンドポイントを監視。", + "pingMonitoring": "Ping監視", + "pingMonitoringDescription": "サーバーが利用可能かどうかをチェック。", + "dockerContainerMonitoring": "Dockerコンテナ監視", + "dockerContainerMonitoringDescription": "Dockerコンテナが実行中かどうかをチェック。", + "portMonitoring": "ポート監視", + "portMonitoringDescription": "ポートが開いているかどうかをチェック。", + "createMaintenanceWindow": "メンテナンスウィンドウを作成", + "createMaintenance": "メンテナンスを作成", + "editMaintenance": "メンテナンスを編集", + "maintenanceWindowName": "メンテナンスウィンドウ名", + "friendlyNameInput": "フレンドリーネーム", + "friendlyNamePlaceholder": "__:__で___分間のメンテナンス", + "maintenanceRepeat": "メンテナンス繰り返し", + "maintenance": "メンテナンス", + "duration": "期間", + "addMonitors": "モニターを追加", + "window": "ウィンドウ", + "cancel": "キャンセル", + "message": "メッセージ", + "low": "低", + "high": "高", + "statusCode": "ステータスコード", + "date&Time": "日付と時刻", + "type": "タイプ", + "statusPageName": "ステータスページ名", + "publicURL": "公開URL", + "repeat": "繰り返し", + "edit": "編集", + "createA": "作成する", + "remove": "削除", + "maintenanceWindowDescription": "この時間帯はpingが送信されません", + "startTime": "開始時刻", + "timeZoneInfo": "すべての日付と時刻はGMT+0タイムゾーンです。", + "monitorsToApply": "メンテナンスウィンドウを適用するモニター", + "nextWindow": "次のウィンドウ", + "notFoundButton": "メインダッシュボードに移動", + "pageSpeedConfigureSettingsDescription": "ここでホストのURL、モニタータイプを選択できます。", + "monitorDisplayName": "モニター表示名", + "whenNewIncident": "新しいインシデントが発生した場合、", + "notifySMS": "SMS経由で通知(近日公開)", + "notifyEmails": "複数のアドレスにメールでも通知(近日公開)", + "seperateEmails": "複数のメールはカンマで区切ることができます", + "checkFrequency": "チェック頻度", + "matchMethod": "マッチ方法", + "expectedValue": "期待値", + "deleteDialogTitle": "本当にこのモニターを削除しますか?", + "deleteDialogDescription": "削除されると、このモニターは復元できません。", + "pageSpeedMonitor": "PageSpeedモニター", + "shown": "表示", + "ago": "前", + "companyName": "会社名", + "pageSpeedDetailsPerformanceReport": "値は推定値で変動する可能性があります。", + "pageSpeedDetailsPerformanceReportCalculator": "計算機を見る", + "checkingEvery": "チェック間隔", + "statusPageCreateSettings": "ステータスページの準備ができたら、公開済みとしてマークできます。", + "basicInformation": "基本情報", + "statusPageCreateBasicInfoDescription": "会社名とステータスページが指すサブドメインを定義。", + "statusPageCreateSelectTimeZoneDescription": "ステータスページが表示されるタイムゾーンを選択。", + "statusPageCreateAppearanceDescription": "公開ステータスページのデフォルトの外観を定義。", + "statusPageCreateSettingsCheckboxLabel": "公開済みで一般に表示", + "statusPageCreateBasicInfoStatusPageAddress": "ステータスページアドレス", + "statusPageCreateTabsContent": "ステータスページサーバー", + "statusPageCreateTabsContentDescription": "監視している任意の数のサーバーをステータスページに追加できます。最適な表示体験のために並び替えることもできます。", + "statusPageCreateTabsContentFeaturesDescription": "ステータスページでより多くの詳細を表示", + "showCharts": "チャートを表示", + "showUptimePercentage": "稼働率を表示", + "removeLogo": "ロゴを削除", + "statusPageStatus": "公開ステータスページが設定されていません。", + "statusPageStatusContactAdmin": "管理者にお問い合わせください", + "statusPageStatusNotPublic": "このステータスページは非公開です。", + "statusPageStatusNoPage": "ここにはステータスページがありません。", + "statusPageStatusServiceStatus": "サービスステータス", + "deleteStatusPage": "このステータスページを削除しますか?", + "deleteStatusPageConfirm": "はい、ステータスページを削除", + "deleteStatusPageDescription": "削除されると、ステータスページは復元できません。", + "uptimeCreate": "期待値はレスポンス結果と照合するために使用され、マッチがステータスを決定します。", + "uptimeCreateJsonPath": "この式はレスポンスJSONデータに対して評価され、結果は期待値との照合に使用されます。参照", + "uptimeCreateJsonPathQuery": "クエリ言語ドキュメント。", + "maintenanceTableActionMenuDialogTitle": "本当にこのメンテナンスウィンドウを削除しますか?", + "infrastructureEditYour": "編集する", + "infrastructureEditMonitor": "インフラストラクチャモニターを保存", + "infrastructureMonitorCreated": "インフラストラクチャモニターが正常に作成されました!", + "infrastructureMonitorUpdated": "インフラストラクチャモニターが正常に更新されました!", + "errorInvalidTypeId": "無効な通知タイプが提供されました", + "errorInvalidFieldId": "無効なフィールドIDが提供されました", + "inviteNoTokenFound": "招待トークンが見つかりません", + "pageSpeedWarning": "警告: Google PageSpeed APIキーがまだ追加されていません。これがないとPageSpeedモニターは機能しません。", + "pageSpeedLearnMoreLink": "こちらをクリック", + "pageSpeedAddApiKey": "APIキーを追加する。", + "update": "更新", + "invalidFileFormat": "サポートされていないファイル形式!", + "invalidFileSize": "ファイルサイズが大きすぎます!", + "ClickUpload": "クリックしてアップロード", + "DragandDrop": "ドラッグアンドドロップ", + "MaxSize": "最大サイズ", + "SupportedFormats": "対応フォーマット", + "FirstName": "名", + "LastName": "姓", + "EmailDescriptionText": "こちらは現在ご使用のメールアドレスです — 変更することはできません。", + "YourPhoto": "プロフィール写真", + "PhotoDescriptionText": "この写真はプロフィールページに表示されます。", + "save": "保存", + "DeleteDescriptionText": "これにより、アカウントと関連するすべてのデータがサーバーから削除されます。この操作は元に戻せません。", + "DeleteAccountWarning": "アカウントを削除すると、再度サインインできなくなり、すべてのデータが削除されます。この操作は元に戻せません。", + "DeleteWarningTitle": "本当にこのアカウントを削除しますか?", "bulkImport": { - "title": "", - "selectFileTips": "", - "selectFileDescription": "", - "selectFile": "", - "parsingFailed": "", - "uploadSuccess": "", - "validationFailed": "", - "noFileSelected": "", - "fallbackPage": "" + "title": "一括インポート", + "selectFileTips": "アップロードするCSVファイルを選択", + "selectFileDescription": "またはサンプルをダウンロードできます", + "selectFile": "ファイルを選択", + "parsingFailed": "解析に失敗しました", + "uploadSuccess": "モニターが正常に作成されました!", + "validationFailed": "検証に失敗しました", + "noFileSelected": "ファイルが選択されていません", + "fallbackPage": "サーバーのリストを一括アップロードするためのファイルをインポート", + "invalidFileType": "無効なファイルタイプ", + "uploadFailed": "アップロードに失敗しました" }, - "DeleteAccountTitle": "", - "DeleteAccountButton": "", - "publicLink": "", - "maskedPageSpeedKeyPlaceholder": "", - "reset": "", - "ignoreTLSError": "", - "tlsErrorIgnored": "", - "ignoreTLSErrorDescription": "", - "createNew": "", + "DeleteAccountTitle": "アカウントを削除", + "DeleteAccountButton": "アカウントを削除", + "publicLink": "公開リンク", + "maskedPageSpeedKeyPlaceholder": "*************************************", + "reset": "リセット", + "ignoreTLSError": "TLS/SSLエラーを無視", + "tlsErrorIgnored": "TLS/SSLエラーが無視されました", + "ignoreTLSErrorDescription": "TLS/SSLエラーを無視してウェブサイトの可用性をチェック続行", + "createNew": "新規作成", "greeting": { - "prepend": "", - "append": "", - "overview": "" + "prepend": "こんにちは", + "append": "午後はあなたの遊び場です—壮大なものにしましょう!", + "overview": "{{type}}モニターの概要です。" }, "roles": { - "superAdmin": "", - "admin": "", - "teamMember": "", - "demoUser": "" + "superAdmin": "スーパー管理者", + "admin": "管理者", + "teamMember": "チームメンバー", + "demoUser": "デモユーザー" }, "teamPanel": { - "teamMembers": "", + "teamMembers": "チームメンバー", "filter": { - "all": "", - "member": "" + "all": "すべて", + "member": "メンバー" }, - "inviteTeamMember": "", - "inviteNewTeamMember": "", - "inviteDescription": "", - "email": "", - "selectRole": "", - "inviteLink": "", - "cancel": "", - "noMembers": "", - "getToken": "", - "emailToken": "", + "inviteTeamMember": "チームメンバーを招待", + "inviteNewTeamMember": "新しいチームメンバーを招待", + "inviteDescription": "新しいチームメンバーを追加すると、すべてのモニターへのアクセス権が付与されます。", + "email": "メール", + "selectRole": "役割を選択", + "inviteLink": "招待リンク", + "cancel": "キャンセル", + "noMembers": "この役割のチームメンバーはいません", + "getToken": "トークンを取得", + "emailToken": "メールトークン", "table": { - "name": "", - "email": "", - "role": "", - "created": "" + "name": "名前", + "email": "メール", + "role": "役割", + "created": "作成日" } }, "monitorState": { - "paused": "", - "resumed": "", - "active": "" + "paused": "一時停止", + "resumed": "再開", + "active": "アクティブ" }, "menu": { - "uptime": "", - "pagespeed": "", - "infrastructure": "", - "incidents": "", - "statusPages": "", - "maintenance": "", - "integrations": "", - "settings": "", - "support": "", - "discussions": "", - "docs": "", - "changelog": "", - "profile": "", - "password": "", - "team": "", - "logOut": "", - "notifications": "", - "logs": "" + "uptime": "稼働時間", + "pagespeed": "ページ速度", + "infrastructure": "インフラストラクチャ", + "incidents": "インシデント", + "statusPages": "ステータスページ", + "maintenance": "メンテナンス", + "integrations": "統合", + "settings": "設定", + "support": "サポート", + "discussions": "ディスカッション", + "docs": "ドキュメント", + "changelog": "変更履歴", + "profile": "プロフィール", + "password": "パスワード", + "team": "チーム", + "logOut": "ログアウト", + "notifications": "通知", + "logs": "ログ" }, - "settingsEmailUser": "", - "state": "", - "statusBreadCrumbsStatusPages": "", - "statusBreadCrumbsDetails": "", - "commonSaving": "", - "navControls": "", - "incidentsPageTitle": "", + "settingsEmailUser": "メールユーザー - 認証用ユーザー名、指定されている場合はメールアドレスを上書き", + "state": "状態", + "statusBreadCrumbsStatusPages": "ステータスページ", + "statusBreadCrumbsDetails": "詳細", + "commonSaving": "保存中...", + "navControls": "コントロール", + "incidentsPageTitle": "インシデント", "passwordPanel": { - "passwordChangedSuccess": "", - "passwordInputIncorrect": "", - "currentPassword": "", - "enterCurrentPassword": "", - "newPassword": "", - "enterNewPassword": "", - "confirmNewPassword": "", - "passwordRequirements": "", - "saving": "" + "passwordChangedSuccess": "パスワードが正常に変更されました。", + "passwordInputIncorrect": "パスワード入力が間違っています。", + "currentPassword": "現在のパスワード", + "enterCurrentPassword": "現在のパスワードを入力", + "newPassword": "新しいパスワード", + "enterNewPassword": "新しいパスワードを入力", + "confirmNewPassword": "新しいパスワードの確認", + "passwordRequirements": "新しいパスワードは少なくとも8文字で、少なくとも1つの大文字、1つの小文字、1つの数字、1つの特殊文字を含む必要があります。", + "saving": "保存中..." }, - "emailSent": "", - "failedToSendEmail": "", - "settingsTestEmailSuccess": "", - "settingsTestEmailFailed": "", - "settingsTestEmailFailedWithReason": "", - "settingsTestEmailUnknownError": "", + "emailSent": "メールが正常に送信されました", + "failedToSendEmail": "メールの送信に失敗しました", + "settingsTestEmailSuccess": "テストメールが正常に送信されました", + "settingsTestEmailFailed": "テストメールの送信に失敗しました", + "settingsTestEmailFailedWithReason": "テストメールの送信に失敗しました: {{reason}}", + "settingsTestEmailUnknownError": "不明なエラー", "statusMsg": { - "paused": "", - "up": "", - "down": "", - "pending": "" + "paused": "監視が一時停止されています。", + "up": "サイトが稼働中です。", + "down": "サイトがダウンしています。", + "pending": "保留中..." }, "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "" + "http": "監視するURLまたはIPを入力(例: https://example.com/ または 192.168.1.100)し、ダッシュボードに表示される明確な表示名を追加。", + "ping": "pingするIPアドレスまたはホスト名を入力(例: 192.168.1.100 または example.com)し、ダッシュボードに表示される明確な表示名を追加。", + "docker": "コンテナのDocker IDを入力。Docker IDは64文字のフルDocker IDである必要があります。docker inspect を実行してフルコンテナIDを取得できます。", + "port": "サーバーのURLまたはIP、ポート番号、ダッシュボードに表示される明確な表示名を入力。" }, "common": { - "appName": "", - "monitoringAgentName": "", + "appName": "Checkmate", + "monitoringAgentName": "Capture", "buttons": { - "toggleTheme": "" + "toggleTheme": "ライト&ダークを切り替え" }, "toasts": { - "networkError": "", - "checkConnection": "", - "unknownError": "" + "networkError": "ネットワークエラー", + "checkConnection": "接続を確認してください", + "unknownError": "不明なエラー" } }, "auth": { "common": { "navigation": { - "continue": "", - "back": "" + "continue": "続行", + "back": "戻る" }, "inputs": { "email": { - "label": "", - "placeholder": "", + "label": "メール", + "placeholder": "jordan.ellis@domain.com", "errors": { - "empty": "", - "invalid": "" + "empty": "続行するには、メールアドレスを入力してください", + "invalid": "入力されたメールアドレスの有効性を再確認してください" } }, "password": { - "label": "", + "label": "パスワード", "rules": { "length": { - "beginning": "", - "highlighted": "" + "beginning": "少なくとも", + "highlighted": "8文字である必要があります" }, "special": { - "beginning": "", - "highlighted": "" + "beginning": "少なくとも", + "highlighted": "1つの特殊文字を含む必要があります" }, "number": { - "beginning": "", - "highlighted": "" + "beginning": "少なくとも", + "highlighted": "1つの数字を含む必要があります" }, "uppercase": { - "beginning": "", - "highlighted": "" + "beginning": "少なくとも", + "highlighted": "1つの大文字を含む必要があります" }, "lowercase": { - "beginning": "", - "highlighted": "" + "beginning": "少なくとも", + "highlighted": "1つの小文字を含む必要があります" }, "match": { - "beginning": "", - "highlighted": "" + "beginning": "パスワードの確認とパスワードは", + "highlighted": "一致する必要があります" } }, "errors": { - "empty": "", - "length": "", - "uppercase": "", - "lowercase": "", - "number": "", - "special": "", - "incorrect": "" + "empty": "パスワードを入力してください", + "length": "パスワードは少なくとも8文字である必要があります", + "uppercase": "パスワードは少なくとも1つの大文字を含む必要があります", + "lowercase": "パスワードは少なくとも1つの小文字を含む必要があります", + "number": "パスワードは少なくとも1つの数字を含む必要があります", + "special": "パスワードは少なくとも1つの特殊文字を含む必要があります", + "incorrect": "入力されたパスワードが記録と一致しません" } }, "passwordConfirm": { - "label": "", - "placeholder": "", + "label": "パスワードの確認", + "placeholder": "確認のためにパスワードを再入力", "errors": { - "empty": "", - "different": "" + "empty": "確認のためにパスワードを再度入力してください(タイプミスを防ぐため)", + "different": "入力されたパスワードが一致しないため、どちらかが間違っています" } }, "firstName": { - "label": "", - "placeholder": "", + "label": "名前", + "placeholder": "太郎", "errors": { - "empty": "", - "length": "", - "pattern": "" + "empty": "名前を入力してください", + "length": "名前は50文字未満である必要があります", + "pattern": "名前は文字、スペース、アポストロフィ、またはハイフンのみを含む必要があります" } }, "lastName": { - "label": "", - "placeholder": "", + "label": "姓", + "placeholder": "田中", "errors": { - "empty": "", - "length": "", - "pattern": "" + "empty": "姓を入力してください", + "length": "姓は50文字未満である必要があります", + "pattern": "姓は文字、スペース、アポストロフィ、またはハイフンのみを含む必要があります" } } }, "errors": { - "validation": "" + "validation": "データの検証エラー。" }, "fields": { "password": { "errors": { - "incorrect": "" + "incorrect": "入力されたパスワードが記録と一致しません" + } + }, + "role": { + "errors": { + "min": "" } } } }, "login": { - "heading": "", + "heading": "ログイン", "subheadings": { - "stepOne": "", - "stepTwo": "" + "stepOne": "メールアドレスを入力", + "stepTwo": "パスワードを入力" }, "links": { - "forgotPassword": "", - "register": "", - "forgotPasswordLink": "", - "registerLink": "" + "forgotPassword": "パスワードを忘れましたか?", + "register": "アカウントをお持ちでないですか?", + "forgotPasswordLink": "パスワードをリセット", + "registerLink": "こちらで登録" }, "toasts": { - "success": "", - "incorrectPassword": "" + "success": "おかえりなさい!正常にログインしました。", + "incorrectPassword": "パスワードが間違っています" }, "errors": { "password": { - "incorrect": "" + "incorrect": "入力されたパスワードが記録と一致しません" } } }, "registration": { "heading": { - "superAdmin": "", - "user": "" + "superAdmin": "スーパー管理者を作成", + "user": "サインアップ" }, "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "" + "stepOne": "個人情報を入力", + "stepTwo": "メールアドレスを入力", + "stepThree": "パスワードを作成" }, "description": { - "superAdmin": "", - "user": "" + "superAdmin": "スーパー管理者アカウントを作成して開始", + "user": "ユーザーとしてサインアップし、スーパー管理者にモニターへのアクセスを求める" }, "gettingStartedButton": { - "superAdmin": "", - "user": "" + "superAdmin": "スーパー管理者アカウントを作成", + "user": "一般ユーザーでサインアップ" }, - "termsAndPolicies": "", + "termsAndPolicies": "アカウントを作成することで、利用規約とプライバシーポリシーに同意したことになります。", "links": { - "login": "" + "login": "既にアカウントをお持ちですか? ログイン" }, "toasts": { - "success": "" + "success": "ようこそ!アカウントが正常に作成されました。" } }, "forgotPassword": { - "heading": "", + "heading": "パスワードを忘れましたか?", "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "", - "stepFour": "" + "stepOne": "ご心配なく、リセット手順をお送りします。", + "stepTwo": "パスワードリセットリンクを に送信しました", + "stepThree": "新しいパスワードは以前に使用したパスワードとは異なる必要があります。", + "stepFour": "パスワードが正常にリセットされました。下記をクリックして魔法のようにログインしてください。" }, "buttons": { - "openEmail": "", - "resetPassword": "" + "openEmail": "メールアプリを開く", + "resetPassword": "パスワードをリセット" }, "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" + "passwordKey": "パスワードキーアイコン", + "email": "メールアイコン", + "lock": "ロックアイコン", + "passwordConfirm": "パスワード確認アイコン" }, "links": { - "login": "", - "resend": "" + "login": "ログインに戻る", + "resend": "メールが届きませんか? クリックして再送信" }, "toasts": { - "sent": "", - "emailNotFound": "", - "redirect": "", - "success": "", - "error": "" + "sent": "手順を に送信しました。", + "emailNotFound": "メールが見つかりません。", + "redirect": "秒後にリダイレクトします...", + "success": "パスワードが正常にリセットされました。", + "error": "パスワードをリセットできません。後でもう一度試すか、サポートにお問い合わせください。" } } }, "errorPages": { "serverUnreachable": { "toasts": { - "reconnected": "", - "stillUnreachable": "" + "reconnected": "サーバーに正常に再接続しました。", + "stillUnreachable": "サーバーにまだ到達できません。後でもう一度お試しください。" }, - "alertBox": "", - "description": "", + "alertBox": "サーバー接続エラー", + "description": "サーバーに接続できません。インターネット接続を確認するか、問題が続く場合はデプロイ設定を確認してください。", "retryButton": { - "default": "", - "processing": "" + "default": "接続を再試行", + "processing": "接続中..." } } }, "createNotifications": { - "title": "", + "title": "通知チャンネルを作成", "nameSettings": { - "title": "", - "description": "", - "nameLabel": "", - "namePlaceholder": "" + "title": "名前", + "description": "統合の説明的な名前。", + "nameLabel": "名前", + "namePlaceholder": "例: Slack通知" }, "typeSettings": { - "title": "", - "description": "", - "typeLabel": "" + "title": "タイプ", + "description": "作成したい通知チャンネルのタイプを選択。", + "typeLabel": "タイプ" }, "emailSettings": { - "title": "", - "description": "", - "emailLabel": "", - "emailPlaceholder": "" + "title": "メール", + "description": "送信先メールアドレス。", + "emailLabel": "メールアドレス", + "emailPlaceholder": "例: john@example.com" }, "slackSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" + "title": "Slack", + "description": "Slack Webhookをここで設定", + "webhookLabel": "Slack Webhook URL", + "webhookPlaceholder": "https://hooks.slack.com/services/..." }, "pagerdutySettings": { - "title": "", - "description": "", - "integrationKeyLabel": "", - "integrationKeyPlaceholder": "" + "title": "PagerDuty", + "description": "PagerDuty統合をここで設定", + "integrationKeyLabel": "統合キー", + "integrationKeyPlaceholder": "1234567890" }, "discordSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" + "title": "Discord", + "description": "Discord Webhookをここで設定", + "webhookLabel": "Discord Webhook URL", + "webhookPlaceholder": "https://your-server.com/webhook" }, "webhookSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - } + "title": "Webhook", + "description": "Webhookをここで設定", + "webhookLabel": "Webhook URL", + "webhookPlaceholder": "https://your-server.com/webhook" + }, + "testNotification": "テスト通知", + "dialogDeleteTitle": "この通知を削除してもよろしいですか?", + "dialogDeleteConfirm": "削除" }, "notificationConfig": { - "title": "", - "description": "" + "title": "通知", + "description": "使用したい通知チャンネルを選択" }, "monitorStatus": { - "checkingEvery": "", - "withCaptureAgent": "", - "up": "", - "down": "", - "paused": "" + "checkingEvery": "{{interval}}ごとにチェック", + "withCaptureAgent": "Captureエージェント{{version}}で", + "up": "アップ", + "down": "ダウン", + "paused": "一時停止" }, - "advancedMatching": "", - "sendTestNotifications": "", - "testNotificationsDisabled": "", - "selectAll": "", - "showAdminLoginLink": "", + "advancedMatching": "高度なマッチング", + "sendTestNotifications": "テスト通知を送信", + "selectAll": "すべて選択", + "showAdminLoginLink": "ステータスページに「管理者?こちらでログイン」リンクを表示", "logsPage": { - "title": "", - "description": "", + "title": "ログ", + "description": "システムログ - 最新1000行", "tabs": { - "queue": "", - "logs": "" + "queue": "ジョブキュー", + "logs": "サーバーログ", + "diagnostics": "診断" }, "toast": { - "fetchLogsSuccess": "" + "fetchLogsSuccess": "ログが正常に取得されました" }, "logLevelSelect": { - "title": "", + "title": "ログレベル", "values": { - "all": "", - "info": "", - "warn": "", - "error": "", - "debug": "" + "all": "すべて", + "info": "情報", + "warn": "警告", + "error": "エラー", + "debug": "デバッグ" } } }, "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", + "title": "キュー", + "refreshButton": "更新", + "flushButton": "キューをフラッシュ", "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "", - "activeHeader": "", - "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "", - "lastRunTookHeader": "" + "title": "現在キューにあるジョブ", + "idHeader": "モニターID", + "urlHeader": "URL", + "typeHeader": "タイプ", + "activeHeader": "アクティブ", + "lockedAtHeader": "ロック日時", + "runCountHeader": "実行回数", + "failCountHeader": "失敗回数", + "lastRunHeader": "最終実行日時", + "lastFinishedAtHeader": "最終完了日時", + "lastRunTookHeader": "最終実行時間" }, "metricsTable": { - "title": "", - "metricHeader": "", - "valueHeader": "" + "title": "キューメトリック", + "metricHeader": "メトリック", + "valueHeader": "値" }, "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", - "failedAtHeader": "", - "failReasonHeader": "" + "title": "失敗したジョブ", + "monitorIdHeader": "モニターID", + "monitorUrlHeader": "モニターURL", + "failCountHeader": "失敗回数", + "failedAtHeader": "最終失敗日時", + "failReasonHeader": "失敗理由" } }, "export": { - "title": "", - "success": "", - "failed": "" + "title": "モニターをエクスポート", + "success": "モニターが正常にエクスポートされました!", + "failed": "モニターのエクスポートに失敗しました" }, "monitorActions": { - "title": "", - "import": "", - "export": "" + "title": "エクスポート/インポート", + "import": "モニターをインポート", + "export": "モニターをエクスポート", + "deleteSuccess": "モニターが正常に削除されました", + "deleteFailed": "モニターの削除に失敗しました", + "details": "詳細" }, "settingsPage": { "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "" + "labelDevelopedBy": "Bluewave Labsによる開発", + "labelVersion": "バージョン", + "title": "について" }, "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" + "buttonAddMonitors": "デモモニターを追加", + "description": "デモンストレーション目的でサンプルモニターを追加。", + "title": "デモモニター" }, "emailSettings": { - "buttonSendTestEmail": "", - "description": "", - "descriptionTransport": "", - "labelAddress": "", - "labelConnectionHost": "", - "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", - "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", - "labelRequireTLS": "", - "labelSecure": "", - "labelTLSServername": "", - "labelUser": "", - "linkTransport": "", - "placeholderUser": "", - "title": "" + "buttonSendTestEmail": "テストメールを送信", + "description": "システムのメール設定を構成。これは通知とアラートの送信に使用されます。", + "descriptionTransport": "これはNodeMailer用のSMTPトランスポートを構築します", + "labelAddress": "メールアドレス - 認証に使用", + "labelConnectionHost": "メール接続ホスト - HELO/EHLO挨拶で使用するホスト名", + "labelHost": "メールホスト - 接続するホスト名またはIPアドレス", + "labelIgnoreTLS": "STARTTLSを無効化: サーバーがサポートしていてもTLSを使用しない", + "labelPassword": "メールパスワード - 認証用パスワード", + "labelPasswordSet": "パスワードが設定されています。変更するにはリセットをクリック。", + "labelPool": "接続プーリングを有効化: パフォーマンス向上のため既存接続を再利用", + "labelPort": "メールポート - 接続するポート", + "labelRejectUnauthorized": "無効な証明書を拒否: 自己署名または信頼されていない証明書での接続を拒否", + "labelRequireTLS": "STARTTLSを強制: TLSアップグレードを必須とし、サポートされていない場合は失敗", + "labelSecure": "SSL使用(推奨): SSL/TLSを使用して接続を暗号化", + "labelTLSServername": "TLS Servername - ホストがIPの場合のTLS検証用オプションホスト名", + "labelUser": "メールユーザー - 認証用ユーザー名、指定されている場合はメールアドレスを上書き", + "linkTransport": "仕様はこちらを参照", + "placeholderUser": "不要な場合は空白のまま", + "title": "メール", + "toastEmailRequiredFieldsError": "メールアドレス、ホスト、ポート、パスワードが必要です" }, "pageSpeedSettings": { - "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "" + "description": "Google PageSpeed APIキーを入力してGoogle PageSpeed監視を有効化。キーを更新するにはリセットをクリック。", + "labelApiKeySet": "APIキーが設定されています。変更するにはリセットをクリック。", + "labelApiKey": "PageSpeed APIキー", + "title": "Google PageSpeed APIキー" }, - "saveButtonLabel": "", + "saveButtonLabel": "保存", "statsSettings": { - "clearAllStatsButton": "", - "clearAllStatsDescription": "", - "clearAllStatsDialogConfirm": "", - "clearAllStatsDialogDescription": "", - "clearAllStatsDialogTitle": "", - "description": "", - "labelTTL": "", - "labelTTLOptional": "", - "title": "" + "clearAllStatsButton": "すべての統計をクリア", + "clearAllStatsDescription": "すべての統計をクリア。この操作は元に戻せません。", + "clearAllStatsDialogConfirm": "はい、すべての統計をクリア", + "clearAllStatsDialogDescription": "削除されると、監視履歴と統計は復元できません。", + "clearAllStatsDialogTitle": "すべての統計をクリアしますか?", + "description": "履歴データを保持する期間を定義。既存のデータをすべてクリアすることもできます。", + "labelTTL": "監視履歴を保持したい日数。", + "labelTTLOptional": "0で無限", + "title": "モニター履歴" }, "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", - "dialogDescription": "", - "dialogTitle": "", - "title": "" + "buttonRemoveAllMonitors": "すべてのモニターを削除", + "description": "システムからすべてのモニターを削除。", + "dialogConfirm": "はい、すべてのモニターを削除", + "dialogDescription": "削除されると、モニターは復元できません。", + "dialogTitle": "すべてのモニターを削除しますか?", + "title": "システムリセット" }, "timezoneSettings": { - "description": "", - "label": "", - "title": "" + "description": "アプリケーション全体で日付と時刻を表示するために使用するタイムゾーンを選択。", + "label": "表示タイムゾーン", + "title": "表示タイムゾーン" }, - "title": "", + "title": "設定", "uiSettings": { - "description": "", - "labelLanguage": "", - "labelTheme": "", - "title": "" + "description": "ライトモードとダークモードを切り替えるか、ユーザーインターフェース言語を変更。", + "labelLanguage": "言語", + "labelTheme": "テーマモード", + "title": "外観" }, "urlSettings": { - "description": "", - "label": "", - "selectDisabled": "", - "selectEnabled": "", - "title": "" + "description": "公開ステータスページでモニターのIPアドレスまたはURLを表示。無効にすると、機密情報を保護するためモニター名のみが表示されます。", + "label": "ステータスページでIP/URLを表示", + "selectDisabled": "無効", + "selectEnabled": "有効", + "title": "ステータスページのモニターIP/URL" } - } + }, + "statusPageCreate": { + "buttonSave": "保存" + }, + "incidentsOptionsHeaderFilterResolved": "解決済み", + "settingsSave": "保存", + "statusPageCreateAppearanceTitle": "外観", + "confirmPassword": "パスワードの確認", + "monitorHooks": { + "failureAddDemoMonitors": "デモモニターの追加に失敗しました", + "successAddDemoMonitors": "デモモニターが正常に追加されました" + }, + "settingsAppearance": "外観", + "settingsDisplayTimezone": "表示タイムゾーン", + "settingsGeneralSettings": "一般設定", + "incidentsOptionsHeaderTotalIncidents": "総インシデント", + "statusPage": { + "deleteSuccess": "ステータスページが正常に削除されました", + "deleteFailed": "ステータスページの削除に失敗しました", + "createSuccess": "ステータスページが正常に作成されました", + "updateSuccess": "ステータスページが正常に更新されました", + "generalSettings": "一般設定", + "contents": "コンテンツ", + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "testNotificationsDisabled": "このモニターには通知が設定されていません。「設定」ボタンをクリックして追加する必要があります", + "incidentsTableResolvedAt": "解決日時", + "incidentsTableActionResolve": "解決", + "checkHooks": { + "failureResolveOne": "インシデントの解決に失敗しました。", + "failureResolveAll": "すべてのインシデントの解決に失敗しました。", + "failureResolveMonitor": "" + }, + "checkFormError": "フォームでエラーを確認してください。", + "diagnosticsPage": { + "diagnosticDescription": "システム診断", + "statsDescription": "システム統計", + "gauges": { + "heapAllocationTitle": "ヒープ割り当て", + "heapAllocationSubtitle": "利用可能メモリの%", + "heapUsageTitle": "ヒープ使用量", + "heapUsageSubtitle": "利用可能メモリの%", + "heapUtilizationTitle": "ヒープ利用率", + "heapUtilizationSubtitle": "割り当て済みの%", + "instantCpuUsageTitle": "瞬間CPU使用率", + "instantCpuUsageSubtitle": "CPUが使用した1秒の%" + }, + "stats": { + "eventLoopDelayTitle": "イベントループ遅延", + "uptimeTitle": "稼働時間", + "usedHeapSizeTitle": "使用ヒープサイズ", + "totalHeapSizeTitle": "総ヒープサイズ", + "osMemoryLimitTitle": "OSメモリ制限" + } + }, + "pageSpeedLighthouseAPI": "Lighthouse PageSpeed APIを使用してウェブサイトを監視", + "time": { + "threeMinutes": "3分", + "fiveMinutes": "5分", + "tenMinutes": "10分", + "twentyMinutes": "20分", + "oneHour": "1時間", + "oneDay": "1日", + "oneWeek": "1週間" + }, + "general": { + "noOptionsFound": "" + }, + "infrastructureMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "maintenanceWindow": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "pageSpeed": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "uptimeMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "editUserPage": { + "form": { + "email": "", + "firstName": "", + "lastName": "", + "role": "", + "save": "" + }, + "table": { + "actionHeader": "", + "roleHeader": "" + }, + "title": "", + "toast": { + "successUserUpdate": "", + "validationErrors": "" + } + }, + "incidentsPageActionResolveMonitor": "", + "incidentsPageActionResolveAll": "" } diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index f9ccc81b4..2315a723d 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -8,7 +8,6 @@ "settingsFailedToSave": "Falha ao salvar as configurações", "settingsStatsCleared": "Estatísticas limpas com sucesso", "settingsFailedToClearStats": "Falha ao limpar estatísticas", - "settingsFailedToAddDemoMonitors": "Falha ao adicionar monitores de demonstração", "settingsMonitorsDeleted": "Todos os monitores foram excluídos com sucesso", "settingsFailedToDeleteMonitors": "Falha ao excluir todos os monitores", "starPromptTitle": "Star Checkmate", @@ -86,7 +85,8 @@ "networkError": "Ocorreu um erro de rede", "fallback": { "title": "", - "checks": [""] + "checks": [""], + "actionButton": "" }, "createButton": "", "createTitle": "", @@ -331,7 +331,9 @@ "uploadSuccess": "Monitores criados com sucesso!", "validationFailed": "Falha na validação", "noFileSelected": "Nenhum arquivo selecionado", - "fallbackPage": "Importe um arquivo para enviar uma lista de servidores em massa" + "fallbackPage": "Importe um arquivo para enviar uma lista de servidores em massa", + "invalidFileType": "", + "uploadFailed": "" }, "DeleteAccountTitle": "Remover conta", "DeleteAccountButton": "Remover conta", @@ -432,47 +434,47 @@ "pending": "Pendente..." }, "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "" + "http": "Insira a URL ou IP para monitorar (ex. https://exemplo.com.br/ ou 192.168.1.100) e adicione uma descrição que aparecerá na dashboard.", + "ping": "Insira o endereço IP ou nome de domínio para ping (ex. 192.168.1.100 ou exemplo.com.br) e adicione uma descrição que aparecerá na dashboard.", + "docker": "Insira o Docker Id do seu container. Docker Ids devem ser todos os 64 caracteres. Você pode executar docker inspect para descobrir o Id completo.", + "port": "Insira a URL ou o IP do servidor, aporta e uma descrição que aparecerá na dashboard." }, "common": { - "appName": "", - "monitoringAgentName": "", + "appName": "Checkmate", + "monitoringAgentName": "Capture", "buttons": { - "toggleTheme": "" + "toggleTheme": "Trocar claro e escuro" }, "toasts": { - "networkError": "", - "checkConnection": "", - "unknownError": "" + "networkError": "Erro de rede", + "checkConnection": "Por favor, verifique a conexão", + "unknownError": "Erro desconhecido" } }, "auth": { "common": { "navigation": { - "continue": "", - "back": "" + "continue": "Continuar", + "back": "Voltar" }, "inputs": { "email": { - "label": "", - "placeholder": "", + "label": "Email", + "placeholder": "joao.silva@dominio.com.br", "errors": { - "empty": "", - "invalid": "" + "empty": "Para continuar, insira seu endereço de email", + "invalid": "Por favor, confirme o endereço de email informado" } }, "password": { - "label": "", + "label": "Senha", "rules": { "length": { - "beginning": "", - "highlighted": "" + "beginning": "Deve ter no mínimo", + "highlighted": "8 caracteres de comprimento" }, "special": { - "beginning": "", + "beginning": "Deve conter pelo menos", "highlighted": "" }, "number": { @@ -493,9 +495,9 @@ } }, "errors": { - "empty": "", - "length": "", - "uppercase": "", + "empty": "Por favor, digite sua senha", + "length": "A senha deve ter pelo menos 8 caracteres", + "uppercase": "A senha deve conter pelo menos 1 letra maiúscula", "lowercase": "", "number": "", "special": "", @@ -511,50 +513,55 @@ } }, "firstName": { - "label": "", - "placeholder": "", + "label": "Nome", + "placeholder": "João", "errors": { - "empty": "", - "length": "", - "pattern": "" + "empty": "Por favor, insira seu nome", + "length": "Nome deve ter menos de 50 caracteres", + "pattern": "Nome deve conter apenas letras, espaços, apóstrofos ou hífens" } }, "lastName": { - "label": "", - "placeholder": "", + "label": "Sobrenome", + "placeholder": "Silva", "errors": { - "empty": "", - "length": "", - "pattern": "" + "empty": "Por favor, insira seu sobrenome", + "length": "Sobrenome deve ter menos de 50 caracteres", + "pattern": "Sobrenome deve conter apenas letras, espaços, apóstrofos ou hífens" } } }, "errors": { - "validation": "" + "validation": "Erro de validação de dados." }, "fields": { "password": { "errors": { "incorrect": "" } + }, + "role": { + "errors": { + "min": "" + } } } }, "login": { - "heading": "", + "heading": "Entrar", "subheadings": { - "stepOne": "", - "stepTwo": "" + "stepOne": "Insira seu email", + "stepTwo": "Insira sua senha" }, "links": { - "forgotPassword": "", - "register": "", + "forgotPassword": "Esqueceu sua senha?", + "register": "Não tem uma conta?", "forgotPasswordLink": "", "registerLink": "" }, "toasts": { - "success": "", - "incorrectPassword": "" + "success": "Bem-vindo de volta! Você está autenticado.", + "incorrectPassword": "Senha incorreta" }, "errors": { "password": { @@ -564,8 +571,8 @@ }, "registration": { "heading": { - "superAdmin": "", - "user": "" + "superAdmin": "Crie um usuário administrador", + "user": "Se inscrever" }, "subheadings": { "stepOne": "", @@ -604,11 +611,11 @@ "passwordKey": "", "email": "", "lock": "", - "passwordConfirm": "" + "passwordConfirm": "Ícone de confirmação de senha" }, "links": { - "login": "", - "resend": "" + "login": "Voltar para o login", + "resend": "Não recebeu o email? Clique para reenviar" }, "toasts": { "sent": "", @@ -675,7 +682,10 @@ "description": "", "webhookLabel": "", "webhookPlaceholder": "" - } + }, + "testNotification": "", + "dialogDeleteTitle": "", + "dialogDeleteConfirm": "" }, "notificationConfig": { "title": "", @@ -690,7 +700,6 @@ }, "advancedMatching": "", "sendTestNotifications": "", - "testNotificationsDisabled": "", "selectAll": "", "showAdminLoginLink": "", "logsPage": { @@ -698,7 +707,8 @@ "description": "", "tabs": { "queue": "", - "logs": "" + "logs": "", + "diagnostics": "" }, "toast": { "fetchLogsSuccess": "" @@ -753,7 +763,10 @@ "monitorActions": { "title": "", "import": "", - "export": "" + "export": "", + "deleteSuccess": "", + "deleteFailed": "", + "details": "" }, "settingsPage": { "aboutSettings": { @@ -785,7 +798,8 @@ "labelUser": "", "linkTransport": "", "placeholderUser": "", - "title": "" + "title": "", + "toastEmailRequiredFieldsError": "" }, "pageSpeedSettings": { "description": "", @@ -832,5 +846,124 @@ "selectEnabled": "", "title": "" } - } + }, + "statusPageCreate": { + "buttonSave": "" + }, + "incidentsOptionsHeaderFilterResolved": "", + "settingsSave": "", + "statusPageCreateAppearanceTitle": "", + "confirmPassword": "", + "monitorHooks": { + "failureAddDemoMonitors": "", + "successAddDemoMonitors": "" + }, + "settingsAppearance": "", + "settingsDisplayTimezone": "", + "settingsGeneralSettings": "", + "incidentsOptionsHeaderTotalIncidents": "", + "statusPage": { + "deleteSuccess": "", + "deleteFailed": "", + "createSuccess": "", + "updateSuccess": "", + "generalSettings": "", + "contents": "", + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "testNotificationsDisabled": "", + "incidentsTableResolvedAt": "", + "incidentsTableActionResolve": "", + "checkHooks": { + "failureResolveOne": "", + "failureResolveAll": "", + "failureResolveMonitor": "" + }, + "checkFormError": "", + "diagnosticsPage": { + "diagnosticDescription": "", + "statsDescription": "", + "gauges": { + "heapAllocationTitle": "", + "heapAllocationSubtitle": "", + "heapUsageTitle": "", + "heapUsageSubtitle": "", + "heapUtilizationTitle": "", + "heapUtilizationSubtitle": "", + "instantCpuUsageTitle": "", + "instantCpuUsageSubtitle": "" + }, + "stats": { + "eventLoopDelayTitle": "", + "uptimeTitle": "", + "usedHeapSizeTitle": "", + "totalHeapSizeTitle": "", + "osMemoryLimitTitle": "" + } + }, + "pageSpeedLighthouseAPI": "", + "time": { + "threeMinutes": "", + "fiveMinutes": "", + "tenMinutes": "", + "twentyMinutes": "", + "oneHour": "", + "oneDay": "", + "oneWeek": "" + }, + "general": { + "noOptionsFound": "" + }, + "infrastructureMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "maintenanceWindow": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "pageSpeed": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "uptimeMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "editUserPage": { + "form": { + "email": "", + "firstName": "", + "lastName": "", + "role": "", + "save": "" + }, + "table": { + "actionHeader": "", + "roleHeader": "" + }, + "title": "", + "toast": { + "successUserUpdate": "", + "validationErrors": "" + } + }, + "incidentsPageActionResolveMonitor": "", + "incidentsPageActionResolveAll": "" } diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index e0f66b98e..814e94da1 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -8,10 +8,9 @@ "settingsFailedToSave": "Не удалось сохранить настройки", "settingsStatsCleared": "Статистика успешно очищена", "settingsFailedToClearStats": "Не удалось очистить статистику", - "settingsFailedToAddDemoMonitors": "Не удалось добавить демонстрационные мониторы", "settingsMonitorsDeleted": "Успешно удалены все мониторы", "settingsFailedToDeleteMonitors": "Не удалось удалить все мониторы", - "starPromptTitle": "Star Checkmate", + "starPromptTitle": "", "starPromptDescription": "Ознакомьтесь с последними релизами и помогите развить сообщество на GitHub", "https": "HTTPS", "http": "HTTP", @@ -86,7 +85,8 @@ "networkError": "Произошла сетевая ошибка", "fallback": { "title": "", - "checks": [""] + "checks": [""], + "actionButton": "" }, "createButton": "", "createTitle": "", @@ -331,7 +331,9 @@ "uploadSuccess": "Мониторы успешно созданы!", "validationFailed": "Не удалось выполнить проверку", "noFileSelected": "Файл не выбран", - "fallbackPage": "Импортируйте файл для массовой загрузки списка серверов" + "fallbackPage": "Импортируйте файл для массовой загрузки списка серверов", + "invalidFileType": "", + "uploadFailed": "" }, "DeleteAccountTitle": "Удалить аккаунт", "DeleteAccountButton": "Удалить аккаунт", @@ -537,6 +539,11 @@ "errors": { "incorrect": "" } + }, + "role": { + "errors": { + "min": "" + } } } }, @@ -675,7 +682,10 @@ "description": "", "webhookLabel": "", "webhookPlaceholder": "" - } + }, + "testNotification": "", + "dialogDeleteTitle": "", + "dialogDeleteConfirm": "" }, "notificationConfig": { "title": "", @@ -690,7 +700,6 @@ }, "advancedMatching": "", "sendTestNotifications": "", - "testNotificationsDisabled": "", "selectAll": "", "showAdminLoginLink": "", "logsPage": { @@ -698,7 +707,8 @@ "description": "", "tabs": { "queue": "", - "logs": "" + "logs": "", + "diagnostics": "" }, "toast": { "fetchLogsSuccess": "" @@ -753,7 +763,10 @@ "monitorActions": { "title": "", "import": "", - "export": "" + "export": "", + "deleteSuccess": "", + "deleteFailed": "", + "details": "" }, "settingsPage": { "aboutSettings": { @@ -785,7 +798,8 @@ "labelUser": "", "linkTransport": "", "placeholderUser": "", - "title": "" + "title": "", + "toastEmailRequiredFieldsError": "" }, "pageSpeedSettings": { "description": "", @@ -832,5 +846,124 @@ "selectEnabled": "", "title": "" } - } + }, + "statusPageCreate": { + "buttonSave": "" + }, + "incidentsOptionsHeaderFilterResolved": "", + "settingsSave": "", + "statusPageCreateAppearanceTitle": "", + "confirmPassword": "", + "monitorHooks": { + "failureAddDemoMonitors": "", + "successAddDemoMonitors": "" + }, + "settingsAppearance": "", + "settingsDisplayTimezone": "", + "settingsGeneralSettings": "", + "incidentsOptionsHeaderTotalIncidents": "", + "statusPage": { + "deleteSuccess": "", + "deleteFailed": "", + "createSuccess": "", + "updateSuccess": "", + "generalSettings": "", + "contents": "", + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "testNotificationsDisabled": "", + "incidentsTableResolvedAt": "", + "incidentsTableActionResolve": "", + "checkHooks": { + "failureResolveOne": "", + "failureResolveAll": "", + "failureResolveMonitor": "" + }, + "checkFormError": "", + "diagnosticsPage": { + "diagnosticDescription": "", + "statsDescription": "", + "gauges": { + "heapAllocationTitle": "", + "heapAllocationSubtitle": "", + "heapUsageTitle": "", + "heapUsageSubtitle": "", + "heapUtilizationTitle": "", + "heapUtilizationSubtitle": "", + "instantCpuUsageTitle": "", + "instantCpuUsageSubtitle": "" + }, + "stats": { + "eventLoopDelayTitle": "", + "uptimeTitle": "", + "usedHeapSizeTitle": "", + "totalHeapSizeTitle": "", + "osMemoryLimitTitle": "" + } + }, + "pageSpeedLighthouseAPI": "", + "time": { + "threeMinutes": "", + "fiveMinutes": "", + "tenMinutes": "", + "twentyMinutes": "", + "oneHour": "", + "oneDay": "", + "oneWeek": "" + }, + "general": { + "noOptionsFound": "" + }, + "infrastructureMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "maintenanceWindow": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "pageSpeed": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "uptimeMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "editUserPage": { + "form": { + "email": "", + "firstName": "", + "lastName": "", + "role": "", + "save": "" + }, + "table": { + "actionHeader": "", + "roleHeader": "" + }, + "title": "", + "toast": { + "successUserUpdate": "", + "validationErrors": "" + } + }, + "incidentsPageActionResolveMonitor": "", + "incidentsPageActionResolveAll": "" } diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index 845b24aaf..e9e2d4e29 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -8,7 +8,6 @@ "settingsFailedToSave": "Ayarlar kaydedilemedi", "settingsStatsCleared": "İstatistikler başarıyla temizlendi", "settingsFailedToClearStats": "İstatistikler temizlenemedi", - "settingsFailedToAddDemoMonitors": "Demo monitörler eklenemedi", "settingsMonitorsDeleted": "Tüm monitörler başarıyla silindi", "settingsFailedToDeleteMonitors": "Monitörler silinemedi", "starPromptTitle": "Checkmate yıldızla değerlendirin", @@ -86,7 +85,8 @@ "networkError": "Ağ hatası oluştu", "fallback": { "title": "", - "checks": [""] + "checks": [""], + "actionButton": "" }, "createButton": "Bildirim kanalı oluştur", "createTitle": "Bildirim kanalı", @@ -95,16 +95,16 @@ "failed": "Bildirim kanalı oluşturulamadı" }, "fetch": { - "success": "", - "failed": "" + "success": "Bildirimler başarıyla alındı", + "failed": "Bildirimler alınamadı" }, "delete": { - "success": "", - "failed": "" + "success": "Bildirim başarıyla silindi", + "failed": "Bildirim silinemedi" }, "edit": { - "success": "", - "failed": "" + "success": "Bildirim başarıyla güncellendi", + "failed": "Bildirim güncellenemedi" }, "test": { "success": "Test bildirimi başarıyla gönderildi", @@ -331,7 +331,9 @@ "uploadSuccess": "Monitör başarıyla oluşturuldu!", "validationFailed": "Doğrulama başarısız oldu", "noFileSelected": "Hiçbir dosya seçilmedi", - "fallbackPage": "Toplu olarak sunucu listesini yüklemek için bir dosyayı içe aktarın" + "fallbackPage": "Toplu olarak sunucu listesini yüklemek için bir dosyayı içe aktarın", + "invalidFileType": "", + "uploadFailed": "" }, "DeleteAccountTitle": "Hesabı sil", "DeleteAccountButton": "Hesabı sil", @@ -340,11 +342,11 @@ "reset": "Sıfırla", "ignoreTLSError": "TLS/SSL hatalarını gözardı et", "tlsErrorIgnored": "TLS/SSL hataları gözardı ediliyor", - "ignoreTLSErrorDescription": "", + "ignoreTLSErrorDescription": "TLS/SSL hatalarını gözardı et ve websitesinin ayakta olduğunu denetle", "createNew": "Yeni oluştur", "greeting": { "prepend": "Merhaba", - "append": "", + "append": "Öğleden sonra muhteşem geçsin!", "overview": "" }, "roles": { @@ -365,28 +367,28 @@ "email": "Eposta", "selectRole": "Rolü seçin", "inviteLink": "Davet linki", - "cancel": "", - "noMembers": "", + "cancel": "İptal", + "noMembers": "Bu role sahip hiç üye yok", "getToken": "", "emailToken": "", "table": { - "name": "", - "email": "", - "role": "", - "created": "" + "name": "İsim", + "email": "Eposta", + "role": "Rol", + "created": "Oluşturulma" } }, "monitorState": { - "paused": "", - "resumed": "", - "active": "" + "paused": "Durduruldu", + "resumed": "Yeniden başlatıldı", + "active": "Aktif" }, "menu": { "uptime": "", - "pagespeed": "", + "pagespeed": "Sayfa hızı", "infrastructure": "", - "incidents": "", - "statusPages": "", + "incidents": "Olaylar", + "statusPages": "Durum sayfaları", "maintenance": "Bakım", "integrations": "Entegrasyonlar", "settings": "Ayarlar", @@ -399,7 +401,7 @@ "team": "Ekip", "logOut": "Çıkış yap", "notifications": "Bildirimler", - "logs": "" + "logs": "Günlükler" }, "settingsEmailUser": "E-posta kullanıcısı – Kimlik doğrulama için kullanıcı adı; belirtilirse e-posta adresinin yerine geçer.", "state": "Durum", @@ -512,11 +514,11 @@ }, "firstName": { "label": "", - "placeholder": "", + "placeholder": "Jordan", "errors": { - "empty": "", - "length": "", - "pattern": "" + "empty": "Lütfen adınızı girin", + "length": "İsim 50 karakterden az olmalıdır", + "pattern": "İsim yalnızca harfler, boşluklar, kesme işaretleri veya tireler içerebilir" } }, "lastName": { @@ -525,23 +527,28 @@ "errors": { "empty": "Lütfen soyadınızı girin", "length": "Soyadı en çok 50 karakter olmalıdır", - "pattern": "" + "pattern": "Soyad yalnızca harfler, boşluklar, kesme işaretleri veya tireler içerebilir" } } }, "errors": { - "validation": "" + "validation": "Veriyi işlerken hata oluştu." }, "fields": { "password": { "errors": { "incorrect": "" } + }, + "role": { + "errors": { + "min": "" + } } } }, "login": { - "heading": "", + "heading": "Giriş yap", "subheadings": { "stepOne": "Eposta adresinizi girin", "stepTwo": "Parolanızı girin" @@ -580,7 +587,7 @@ "superAdmin": "Süper yönetici hesabı oluşturun", "user": "" }, - "termsAndPolicies": "", + "termsAndPolicies": "Bir hesap oluşturarak Hizmet Şartları ve Gizlilik Politikası'nı kabul etmiş olursunuz.", "links": { "login": "Bir hesabınız mı var? Giriş yapın" }, @@ -593,8 +600,8 @@ "subheadings": { "stepOne": "Endişelenmeyin, sıfırlamak için gerekli bilgileri göndereceğiz.", "stepTwo": "Parola sıfırlama için gerekli bilgileri hesabına ilettik.", - "stepThree": "", - "stepFour": "" + "stepThree": "Parolanız daha önce kullandığınız paroladan farklı olmalıdır.", + "stepFour": "Parolanız başarıyla sıfırlandı. Buraya tıklayarak giriş yapabilirsiniz." }, "buttons": { "openEmail": "Eposta uygulamasını aç", @@ -612,7 +619,7 @@ }, "toasts": { "sent": "", - "emailNotFound": "", + "emailNotFound": "Eposta bulunamadı.", "redirect": "", "success": "", "error": "" @@ -675,7 +682,10 @@ "description": "", "webhookLabel": "", "webhookPlaceholder": "https://sunucu-adı.com/webhook\n" - } + }, + "testNotification": "Test bildirimi", + "dialogDeleteTitle": "Gerçekten bu bildirimi silmek istiyor musunuz?", + "dialogDeleteConfirm": "Sil" }, "notificationConfig": { "title": "Bildirimler", @@ -688,20 +698,20 @@ "down": "çalışmıyor", "paused": "beklemede" }, - "advancedMatching": "", + "advancedMatching": "Gelişmiş eşleştirme", "sendTestNotifications": "Bildirimleri dene", - "testNotificationsDisabled": "", "selectAll": "Tümünü seç", "showAdminLoginLink": "", "logsPage": { - "title": "", - "description": "", + "title": "Günlükler", + "description": "Sistem günlükleri - son 1000 satır", "tabs": { - "queue": "", - "logs": "" + "queue": "İş kuyruğu", + "logs": "Sunucu günlükleri", + "diagnostics": "Teşhis" }, "toast": { - "fetchLogsSuccess": "" + "fetchLogsSuccess": "Günlükler başarıyla alındı" }, "logLevelSelect": { "title": "", @@ -723,37 +733,40 @@ "idHeader": "", "urlHeader": "", "typeHeader": "", - "activeHeader": "", + "activeHeader": "Aktif", "lockedAtHeader": "", "runCountHeader": "", "failCountHeader": "", "lastRunHeader": "", - "lastFinishedAtHeader": "", + "lastFinishedAtHeader": "Son tamamlama", "lastRunTookHeader": "" }, "metricsTable": { "title": "", - "metricHeader": "", - "valueHeader": "" + "metricHeader": "Metrik", + "valueHeader": "Değer" }, "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", + "title": "Tamamlanamayan işler", + "monitorIdHeader": "Monitör ID", + "monitorUrlHeader": "Monitör URL", + "failCountHeader": "Hata sayısı", "failedAtHeader": "", "failReasonHeader": "" } }, "export": { - "title": "", - "success": "", - "failed": "" + "title": "Monitörleri dışarı al", + "success": "Monitörler başarıyla dışarıya alındı", + "failed": "Monitörleri dışarıya alırken bir hata oluştu" }, "monitorActions": { - "title": "", + "title": "Dışarı/İçeri al", "import": "", - "export": "" + "export": "", + "deleteSuccess": "Monitör başarıyla silindi", + "deleteFailed": "Monitör silinemedi", + "details": "Detaylar" }, "settingsPage": { "aboutSettings": { @@ -784,16 +797,17 @@ "labelTLSServername": "", "labelUser": "", "linkTransport": "", - "placeholderUser": "", - "title": "" + "placeholderUser": "Eğer gerekmiyorsa boş bırakabilirsiniz", + "title": "Eposta", + "toastEmailRequiredFieldsError": "" }, "pageSpeedSettings": { - "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "" + "description": "Google PageSpeed izlemeyi etkinleştirmek için Google PageSpeed API anahtarınızı girin. Anahtarı güncellemek için Sıfırla'ya tıklayın.", + "labelApiKeySet": "API key tanımlandı. Sıfırla düğmesine tıklayarak değiştirebilirsiniz.", + "labelApiKey": "PageSpeed API anahtarı", + "title": "Google PageSpeed API anahtarı" }, - "saveButtonLabel": "", + "saveButtonLabel": "Kaydet", "statsSettings": { "clearAllStatsButton": "", "clearAllStatsDescription": "", @@ -802,35 +816,154 @@ "clearAllStatsDialogTitle": "", "description": "", "labelTTL": "", - "labelTTLOptional": "", - "title": "" + "labelTTLOptional": "Sonsuz için 0", + "title": "İzleme geçmişi" }, "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", + "buttonRemoveAllMonitors": "Tüm monitörleri sil", + "description": "Sistemden tüm monitörleri sil", + "dialogConfirm": "Evet, tüm monitörleri sil", "dialogDescription": "", - "dialogTitle": "", - "title": "" + "dialogTitle": "Gerçekten tüm monitörleri silmek istiyor musunuz?", + "title": "Sistemi sıfırla" }, "timezoneSettings": { "description": "", - "label": "", - "title": "" + "label": "Görünüm zaman dilimi", + "title": "Görünüm zaman dilimi" }, - "title": "", + "title": "Ayarlar", "uiSettings": { - "description": "", - "labelLanguage": "", - "labelTheme": "", - "title": "" + "description": "Aydınlık ve karanlık mod arasında geçiş yapın ya da kullanıcı arayüzü dilini değiştirin.", + "labelLanguage": "Dil", + "labelTheme": "Tema modu", + "title": "Görünüm" }, "urlSettings": { "description": "", "label": "", - "selectDisabled": "", - "selectEnabled": "", + "selectDisabled": "Kapalı", + "selectEnabled": "Etkin", "title": "" } - } + }, + "statusPageCreate": { + "buttonSave": "" + }, + "incidentsOptionsHeaderFilterResolved": "", + "settingsSave": "", + "statusPageCreateAppearanceTitle": "", + "confirmPassword": "", + "monitorHooks": { + "failureAddDemoMonitors": "", + "successAddDemoMonitors": "" + }, + "settingsAppearance": "", + "settingsDisplayTimezone": "", + "settingsGeneralSettings": "", + "incidentsOptionsHeaderTotalIncidents": "", + "statusPage": { + "deleteSuccess": "Durum sayfası başarıyla silindi", + "deleteFailed": "Durum sayfası silinemedi", + "createSuccess": "Durum sayfası başarıyla oluşturuldu", + "updateSuccess": "Durum sayfası başarıyla güncellendi", + "generalSettings": "Genel ayarlar", + "contents": "İçerikler", + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "testNotificationsDisabled": "Bu izleyici için ayarlanmış bir bildirim yok. 'Yapılandır' düğmesine tıklayarak bir bildirim eklemeniz gerekiyor.", + "incidentsTableResolvedAt": "", + "incidentsTableActionResolve": "Çözümle", + "checkHooks": { + "failureResolveOne": "Olay çözümlenirken bir hata oluştu.", + "failureResolveAll": "Tüm olayları çözümlerken bir hata oluştu.", + "failureResolveMonitor": "" + }, + "checkFormError": "Formda hatalar var. Lütfen kontrol edin.", + "diagnosticsPage": { + "diagnosticDescription": "", + "statsDescription": "", + "gauges": { + "heapAllocationTitle": "", + "heapAllocationSubtitle": "", + "heapUsageTitle": "", + "heapUsageSubtitle": "", + "heapUtilizationTitle": "", + "heapUtilizationSubtitle": "", + "instantCpuUsageTitle": "", + "instantCpuUsageSubtitle": "" + }, + "stats": { + "eventLoopDelayTitle": "", + "uptimeTitle": "", + "usedHeapSizeTitle": "", + "totalHeapSizeTitle": "", + "osMemoryLimitTitle": "" + } + }, + "pageSpeedLighthouseAPI": "", + "time": { + "threeMinutes": "", + "fiveMinutes": "", + "tenMinutes": "", + "twentyMinutes": "", + "oneHour": "1 saat", + "oneDay": "1 gün", + "oneWeek": "1 hafta" + }, + "general": { + "noOptionsFound": "" + }, + "infrastructureMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "maintenanceWindow": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "pageSpeed": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "uptimeMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "editUserPage": { + "form": { + "email": "", + "firstName": "", + "lastName": "", + "role": "", + "save": "" + }, + "table": { + "actionHeader": "", + "roleHeader": "" + }, + "title": "", + "toast": { + "successUserUpdate": "", + "validationErrors": "" + } + }, + "incidentsPageActionResolveMonitor": "", + "incidentsPageActionResolveAll": "" } diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index 0c86b1e16..56f1841d4 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -8,7 +8,6 @@ "settingsFailedToSave": "", "settingsStatsCleared": "", "settingsFailedToClearStats": "", - "settingsFailedToAddDemoMonitors": "", "settingsMonitorsDeleted": "", "settingsFailedToDeleteMonitors": "", "starPromptTitle": "", @@ -86,7 +85,8 @@ "networkError": "", "fallback": { "title": "", - "checks": [""] + "checks": [""], + "actionButton": "" }, "createButton": "", "createTitle": "", @@ -331,7 +331,9 @@ "uploadSuccess": "", "validationFailed": "", "noFileSelected": "", - "fallbackPage": "" + "fallbackPage": "", + "invalidFileType": "", + "uploadFailed": "" }, "DeleteAccountTitle": "", "DeleteAccountButton": "", @@ -537,6 +539,11 @@ "errors": { "incorrect": "" } + }, + "role": { + "errors": { + "min": "" + } } } }, @@ -675,7 +682,10 @@ "description": "", "webhookLabel": "", "webhookPlaceholder": "" - } + }, + "testNotification": "", + "dialogDeleteTitle": "", + "dialogDeleteConfirm": "" }, "notificationConfig": { "title": "", @@ -690,7 +700,6 @@ }, "advancedMatching": "", "sendTestNotifications": "", - "testNotificationsDisabled": "", "selectAll": "", "showAdminLoginLink": "", "logsPage": { @@ -698,7 +707,8 @@ "description": "", "tabs": { "queue": "", - "logs": "" + "logs": "", + "diagnostics": "" }, "toast": { "fetchLogsSuccess": "" @@ -753,7 +763,10 @@ "monitorActions": { "title": "", "import": "", - "export": "" + "export": "", + "deleteSuccess": "", + "deleteFailed": "", + "details": "" }, "settingsPage": { "aboutSettings": { @@ -785,7 +798,8 @@ "labelUser": "", "linkTransport": "", "placeholderUser": "", - "title": "" + "title": "", + "toastEmailRequiredFieldsError": "" }, "pageSpeedSettings": { "description": "", @@ -832,5 +846,124 @@ "selectEnabled": "", "title": "" } - } + }, + "statusPageCreate": { + "buttonSave": "" + }, + "incidentsOptionsHeaderFilterResolved": "", + "settingsSave": "", + "statusPageCreateAppearanceTitle": "", + "confirmPassword": "", + "monitorHooks": { + "failureAddDemoMonitors": "", + "successAddDemoMonitors": "" + }, + "settingsAppearance": "", + "settingsDisplayTimezone": "", + "settingsGeneralSettings": "", + "incidentsOptionsHeaderTotalIncidents": "", + "statusPage": { + "deleteSuccess": "", + "deleteFailed": "", + "createSuccess": "", + "updateSuccess": "", + "generalSettings": "", + "contents": "", + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "testNotificationsDisabled": "", + "incidentsTableResolvedAt": "", + "incidentsTableActionResolve": "", + "checkHooks": { + "failureResolveOne": "", + "failureResolveAll": "", + "failureResolveMonitor": "" + }, + "checkFormError": "", + "diagnosticsPage": { + "diagnosticDescription": "", + "statsDescription": "", + "gauges": { + "heapAllocationTitle": "", + "heapAllocationSubtitle": "", + "heapUsageTitle": "", + "heapUsageSubtitle": "", + "heapUtilizationTitle": "", + "heapUtilizationSubtitle": "", + "instantCpuUsageTitle": "", + "instantCpuUsageSubtitle": "" + }, + "stats": { + "eventLoopDelayTitle": "", + "uptimeTitle": "", + "usedHeapSizeTitle": "", + "totalHeapSizeTitle": "", + "osMemoryLimitTitle": "" + } + }, + "pageSpeedLighthouseAPI": "", + "time": { + "threeMinutes": "", + "fiveMinutes": "", + "tenMinutes": "", + "twentyMinutes": "", + "oneHour": "", + "oneDay": "", + "oneWeek": "" + }, + "general": { + "noOptionsFound": "" + }, + "infrastructureMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "maintenanceWindow": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "pageSpeed": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "uptimeMonitor": { + "fallback": { + "checks": [""], + "title": "", + "actionButton": "" + } + }, + "editUserPage": { + "form": { + "email": "", + "firstName": "", + "lastName": "", + "role": "", + "save": "" + }, + "table": { + "actionHeader": "", + "roleHeader": "" + }, + "title": "", + "toast": { + "successUserUpdate": "", + "validationErrors": "" + } + }, + "incidentsPageActionResolveMonitor": "", + "incidentsPageActionResolveAll": "" } From de80a530e5198e68565610f931e0d6146be84a59 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 28 Jul 2025 15:03:28 -0700 Subject: [PATCH 106/226] refactor logger --- server/src/config/services.js | 8 +++++--- server/src/db/models/User.js | 8 -------- server/src/db/mongo/MongoDB.js | 14 +++++++------- server/src/index.js | 9 +++++++-- server/src/service/system/serviceRegistry.js | 10 +++++----- server/src/utils/logger.js | 10 ++++------ 6 files changed, 28 insertions(+), 31 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index 84f9ddca9..e2b30034d 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -33,14 +33,16 @@ import mjml2html from "mjml"; import jwt from "jsonwebtoken"; import crypto from "crypto"; -export const initializeServices = async (envSettings, settingsService) => { +export const initializeServices = async ({ logger, envSettings, settingsService }) => { + const serviceRegistry = new ServiceRegistry({ logger }); + const translationService = new TranslationService(logger); await translationService.initialize(); const stringService = new StringService(translationService); // Create DB - const db = new MongoDB({ envSettings }); + const db = new MongoDB({ logger, envSettings }); await db.connect(); const networkService = new NetworkService(axios, ping, logger, http, Docker, net, stringService, settingsService); @@ -144,7 +146,7 @@ export const initializeServices = async (envSettings, settingsService) => { }; Object.values(services).forEach((service) => { - ServiceRegistry.register(service.serviceName, service); + serviceRegistry.register(service.serviceName, service); }); return services; diff --git a/server/src/db/models/User.js b/server/src/db/models/User.js index f180cfb86..9368d98b1 100755 --- a/server/src/db/models/User.js +++ b/server/src/db/models/User.js @@ -103,12 +103,4 @@ UserSchema.methods.comparePassword = async function (submittedPassword) { const User = mongoose.model("User", UserSchema); -User.init().then(() => { - logger.info({ - message: "User model initialized", - service: "UserModel", - method: "init", - }); -}); - export default User; diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index 1f08facc4..d53fea928 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -1,6 +1,5 @@ import mongoose from "mongoose"; import AppSettings from "../models/AppSettings.js"; -import logger from "../../utils/logger.js"; //**************************************** // User Operations @@ -70,7 +69,8 @@ import * as diagnosticModule from "./modules/diagnosticModule.js"; class MongoDB { static SERVICE_NAME = "MongoDB"; - constructor({ envSettings }) { + constructor({ logger, envSettings }) { + this.logger = logger; this.envSettings = envSettings; Object.assign(this, userModule); Object.assign(this, inviteModule); @@ -110,13 +110,13 @@ class MongoDB { await model.syncIndexes(); } - logger.info({ + this.logger.info({ message: "Connected to MongoDB", service: this.SERVICE_NAME, method: "connect", }); } catch (error) { - logger.error({ + this.logger.error({ message: error.message, service: this.SERVICE_NAME, method: "connect", @@ -128,12 +128,12 @@ class MongoDB { disconnect = async () => { try { - logger.info({ message: "Disconnecting from MongoDB" }); + this.logger.info({ message: "Disconnecting from MongoDB" }); await mongoose.disconnect(); - logger.info({ message: "Disconnected from MongoDB" }); + this.logger.info({ message: "Disconnected from MongoDB" }); return; } catch (error) { - logger.error({ + this.logger.error({ message: error.message, service: this.SERVICE_NAME, method: "disconnect", diff --git a/server/src/index.js b/server/src/index.js index 87c140fac..6b12eb25d 100755 --- a/server/src/index.js +++ b/server/src/index.js @@ -2,15 +2,16 @@ import { initializeServices } from "./config/services.js"; import { initializeControllers } from "./config/controllers.js"; import { createApp } from "./app.js"; import { initShutdownListener } from "./shutdown.js"; -import logger from "./utils/logger.js"; import { fileURLToPath } from "url"; import path from "path"; import fs from "fs"; +import Logger from "./utils/logger.js"; import SettingsService from "./service/system/settingsService.js"; import AppSettings from "./db/models/AppSettings.js"; const SERVICE_NAME = "Server"; +let logger; const startApp = async () => { // FE path @@ -18,12 +19,16 @@ const startApp = async () => { const __dirname = path.dirname(__filename); const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8")); const frontendPath = path.join(__dirname, "public"); + // Create services const settingsService = new SettingsService(AppSettings); const envSettings = settingsService.loadSettings(); + // Create logger + logger = new Logger({ envSettings }); + // Initialize services - const services = await initializeServices(envSettings, settingsService); + const services = await initializeServices({ logger, envSettings, settingsService }); // Initialize controllers const controllers = initializeControllers(services); diff --git a/server/src/service/system/serviceRegistry.js b/server/src/service/system/serviceRegistry.js index 6aa3f94ef..03406c57e 100755 --- a/server/src/service/system/serviceRegistry.js +++ b/server/src/service/system/serviceRegistry.js @@ -1,9 +1,9 @@ const SERVICE_NAME = "ServiceRegistry"; -import logger from "../../utils/logger.js"; class ServiceRegistry { static SERVICE_NAME = SERVICE_NAME; - constructor() { + constructor({ logger }) { this.services = {}; + this.logger = logger; } get serviceName() { @@ -11,7 +11,7 @@ class ServiceRegistry { } register(name, service) { - logger.info({ + this.logger.info({ message: `Registering service ${name}`, service: SERVICE_NAME, method: "register", @@ -21,7 +21,7 @@ class ServiceRegistry { get(name) { if (!this.services[name]) { - logger.error({ + this.logger.error({ message: `Service ${name} is not registered`, service: SERVICE_NAME, method: "get", @@ -36,4 +36,4 @@ class ServiceRegistry { } } -export default new ServiceRegistry(); +export default ServiceRegistry; diff --git a/server/src/utils/logger.js b/server/src/utils/logger.js index a94ad6be2..46b311783 100755 --- a/server/src/utils/logger.js +++ b/server/src/utils/logger.js @@ -6,7 +6,8 @@ const SERVICE_NAME = "Logger"; class Logger { static SERVICE_NAME = SERVICE_NAME; - constructor() { + constructor({ envSettings }) { + this.envSettings = envSettings; this.logCache = []; this.maxCacheSize = 1000; const consoleFormat = format.printf(({ level, message, service, method, details, timestamp, stack }) => { @@ -45,7 +46,7 @@ class Logger { return msg; }); - const logLevel = process.env.LOG_LEVEL || "info"; + const logLevel = this.envSettings.logLevel || "info"; this.logger = createLogger({ level: logLevel, @@ -145,7 +146,4 @@ class Logger { } } -const logger = new Logger(); -export { Logger }; - -export default logger; +export default Logger; From 6e49e19ba104895aed7b9a690055ac09a2a6245b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 28 Jul 2025 15:25:33 -0700 Subject: [PATCH 107/226] refactor serviceRegistry --- server/src/config/services.js | 3 ++- server/src/service/system/serviceRegistry.js | 24 ++++++++++++++++++++ server/src/service/system/settingsService.js | 6 ++--- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index e2b30034d..93327befc 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -35,6 +35,7 @@ import crypto from "crypto"; export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); + ServiceRegistry.instance = serviceRegistry; const translationService = new TranslationService(logger); await translationService.initialize(); @@ -146,7 +147,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService }; Object.values(services).forEach((service) => { - serviceRegistry.register(service.serviceName, service); + ServiceRegistry.register(service.serviceName, service); }); return services; diff --git a/server/src/service/system/serviceRegistry.js b/server/src/service/system/serviceRegistry.js index 03406c57e..4e3e28464 100755 --- a/server/src/service/system/serviceRegistry.js +++ b/server/src/service/system/serviceRegistry.js @@ -1,6 +1,8 @@ const SERVICE_NAME = "ServiceRegistry"; + class ServiceRegistry { static SERVICE_NAME = SERVICE_NAME; + constructor({ logger }) { this.services = {}; this.logger = logger; @@ -10,6 +12,7 @@ class ServiceRegistry { return ServiceRegistry.SERVICE_NAME; } + // Instance methods register(name, service) { this.logger.info({ message: `Registering service ${name}`, @@ -34,6 +37,27 @@ class ServiceRegistry { listServices() { return Object.keys(this.services); } + + static get(name) { + if (!ServiceRegistry.instance) { + throw new Error("ServiceRegistry not initialized"); + } + return ServiceRegistry.instance.get(name); + } + + static register(name, service) { + if (!ServiceRegistry.instance) { + throw new Error("ServiceRegistry not initialized"); + } + return ServiceRegistry.instance.register(name, service); + } + + static listServices() { + if (!ServiceRegistry.instance) { + throw new Error("ServiceRegistry not initialized"); + } + return ServiceRegistry.instance.listServices(); + } } export default ServiceRegistry; diff --git a/server/src/service/system/settingsService.js b/server/src/service/system/settingsService.js index 1c274d944..4ad30c6b1 100755 --- a/server/src/service/system/settingsService.js +++ b/server/src/service/system/settingsService.js @@ -3,9 +3,9 @@ const SERVICE_NAME = "SettingsService"; const envConfig = { nodeEnv: process.env.NODE_ENV, logLevel: process.env.LOG_LEVEL, - clientHost: process.env.CLIENT_HOST, //* - dbConnectionString: process.env.DB_CONNECTION_STRING, //* - port: process.env.PORT, //* + clientHost: process.env.CLIENT_HOST, + dbConnectionString: process.env.DB_CONNECTION_STRING, + port: process.env.PORT, }; /** * SettingsService From 2802cce6f16d7be3ff95a9d42082e2fea3d426bc Mon Sep 17 00:00:00 2001 From: satnam72 <125819218+satnam72@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:40:24 -0400 Subject: [PATCH 108/226] Fix the breadcrumbs route - Updated breadcrumbs to use `/status/uptime/${url}` instead of `/status/${url}` --- client/src/Pages/StatusPage/Create/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Pages/StatusPage/Create/index.jsx b/client/src/Pages/StatusPage/Create/index.jsx index 40cbb7a9a..f0dce35d0 100644 --- a/client/src/Pages/StatusPage/Create/index.jsx +++ b/client/src/Pages/StatusPage/Create/index.jsx @@ -239,7 +239,7 @@ const CreateStatusPage = () => { From c5cd3512af2369eb8ff2aaddd90452aad64d8598 Mon Sep 17 00:00:00 2001 From: satnam72 <125819218+satnam72@users.noreply.github.com> Date: Tue, 29 Jul 2025 02:56:41 -0400 Subject: [PATCH 109/226] Fixed Formatting issue Ran the npm run format to fix formatting --- client/src/Pages/StatusPage/Create/index.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/StatusPage/Create/index.jsx b/client/src/Pages/StatusPage/Create/index.jsx index f0dce35d0..d4cbc0fd5 100644 --- a/client/src/Pages/StatusPage/Create/index.jsx +++ b/client/src/Pages/StatusPage/Create/index.jsx @@ -239,7 +239,10 @@ const CreateStatusPage = () => { From bf5d7f3c5322d49fe1c4780966aea013ebe6713a Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Sat, 26 Jul 2025 15:24:25 +0530 Subject: [PATCH 110/226] Adding Global Threshold Settings --- .../src/Pages/Infrastructure/Create/index.jsx | 74 +++++++---- .../Settings/SettingsGlobalThresholds.jsx | 117 ++++++++++++++++++ client/src/Pages/Settings/index.jsx | 11 ++ client/src/Validation/validation.js | 7 ++ client/src/locales/en.json | 4 + server/src/db/models/AppSettings.js | 8 +- server/src/validation/joi.js | 8 ++ 7 files changed, 203 insertions(+), 26 deletions(-) create mode 100644 client/src/Pages/Settings/SettingsGlobalThresholds.jsx diff --git a/client/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx index 0800bb500..fa6ac4fcb 100644 --- a/client/src/Pages/Infrastructure/Create/index.jsx +++ b/client/src/Pages/Infrastructure/Create/index.jsx @@ -3,7 +3,7 @@ import { useTheme } from "@emotion/react"; import { useParams } from "react-router-dom"; import { useState, useEffect } from "react"; import { useSelector } from "react-redux"; - +import axios from "axios"; // Utility and Network import { infrastructureMonitorValidation } from "../../../Validation/validation"; import { useFetchHardwareMonitorById } from "../../../Hooks/monitorHooks"; @@ -87,35 +87,59 @@ const CreateInfrastructureMonitor = () => { }); // Populate form fields if editing + // Populate global thresholds from /settings if creating + const { authToken } = useSelector((state) => state.auth); + useEffect(() => { - if (isCreate || !monitor) return; + const fetchGlobalThresholds = async () => { + if (!isCreate) return; - setInfrastructureMonitor({ - url: monitor.url.replace(/^https?:\/\//, ""), - name: monitor.name || "", - notifications: monitor.notifications, - interval: monitor.interval / MS_PER_MINUTE, - cpu: monitor.thresholds?.usage_cpu !== undefined, - usage_cpu: monitor.thresholds?.usage_cpu ? monitor.thresholds.usage_cpu * 100 : "", + try { + const token = authToken; + if (!token) { + console.warn("No token found for fetching global thresholds."); + return; + } - memory: monitor.thresholds?.usage_memory !== undefined, - usage_memory: monitor.thresholds?.usage_memory - ? monitor.thresholds.usage_memory * 100 - : "", + const response = await axios.get(`${import.meta.env.VITE_APP_API_BASE_URL}/settings`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); - disk: monitor.thresholds?.usage_disk !== undefined, - usage_disk: monitor.thresholds?.usage_disk - ? monitor.thresholds.usage_disk * 100 - : "", + const globalThresholds = response.data?.data?.settings?.globalThresholds; - temperature: monitor.thresholds?.usage_temperature !== undefined, - usage_temperature: monitor.thresholds?.usage_temperature - ? monitor.thresholds.usage_temperature * 100 - : "", - secret: monitor.secret || "", - }); - setHttps(monitor.url.startsWith("https")); - }, [isCreate, monitor]); + if (!globalThresholds) { + console.warn("No global thresholds found in /settings response."); + return; + } + + setInfrastructureMonitor((prev) => ({ + ...prev, + cpu: globalThresholds.cpu != null, + usage_cpu: globalThresholds.cpu != null ? globalThresholds.cpu.toString() : "", + + memory: globalThresholds.memory != null, + usage_memory: + globalThresholds.memory != null ? globalThresholds.memory.toString() : "", + + disk: globalThresholds.disk != null, + usage_disk: + globalThresholds.disk != null ? globalThresholds.disk.toString() : "", + + temperature: globalThresholds.temperature != null, + usage_temperature: + globalThresholds.temperature != null + ? globalThresholds.temperature.toString() + : "", + })); + } catch (error) { + console.error("Failed to fetch global thresholds:", error); + } + }; + + fetchGlobalThresholds(); + }, [isCreate, user]); // Handlers const onSubmit = async (event) => { diff --git a/client/src/Pages/Settings/SettingsGlobalThresholds.jsx b/client/src/Pages/Settings/SettingsGlobalThresholds.jsx new file mode 100644 index 000000000..eca752688 --- /dev/null +++ b/client/src/Pages/Settings/SettingsGlobalThresholds.jsx @@ -0,0 +1,117 @@ +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import ConfigBox from "../../Components/ConfigBox"; +import TextInput from "../../Components/Inputs/TextInput"; + +import { useTheme } from "@emotion/react"; +import { PropTypes } from "prop-types"; +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; + +const SettingsGlobalThresholds = ({ + isAdmin, + HEADING_SX, + settingsData, + setSettingsData, +}) => { + const { t } = useTranslation(); // For language translation + const theme = useTheme(); // MUI theme access + + // Local state for thresholds + const [thresholds, setThresholds] = useState({ + cpu: "", + memory: "", + disk: "", + temperature: "", + }); + + // Load existing thresholds from settingsData on first render or when settingsData changes + useEffect(() => { + if (settingsData?.settings?.globalThresholds) { + setThresholds({ + cpu: settingsData.settings.globalThresholds.cpu || "", + memory: settingsData.settings.globalThresholds.memory || "", + disk: settingsData.settings.globalThresholds.disk || "", + temperature: settingsData.settings.globalThresholds.temperature || "", + }); + } + }, [settingsData]); + + // Handles input change and updates state & parent data + const handleChange = (e, min, max) => { + const { name, value } = e.target; + + // Allow empty or numeric value within range + if ( + value === "" || + (/^\d+$/.test(value) && Number(value) >= min && Number(value) <= max) + ) { + const updatedThresholds = { + ...thresholds, + [name]: value, + }; + + setThresholds(updatedThresholds); + + // Update settingsData without enabling/disabling metrics + setSettingsData({ + ...settingsData, + settings: { + ...settingsData.settings, + globalThresholds: updatedThresholds, + }, + }); + } + }; + + // Only render this section for admins + if (!isAdmin) return null; + + return ( + + {/* Header and description */} + + + {t("settingsPage.globalThresholds.title", "Global Thresholds")} + + + {t( + "settingsPage.globalThresholds.description", + "Configure global CPU, Memory, Disk, and Temperature thresholds." + )} + + + + {/* Threshold inputs */} + + {[ + ["CPU Threshold (%)", "cpu", 1, 100], + ["Memory Threshold (%)", "memory", 1, 100], + ["Disk Threshold (%)", "disk", 1, 100], + ["Temperature Threshold (°C)", "temperature", 1, 150], + ].map(([label, name, min, max]) => ( + handleChange(e, min, max)} + /> + ))} + + + ); +}; + +// Prop types +SettingsGlobalThresholds.propTypes = { + isAdmin: PropTypes.bool, + HEADING_SX: PropTypes.object, + settingsData: PropTypes.object, + setSettingsData: PropTypes.func, +}; + +export default SettingsGlobalThresholds; diff --git a/client/src/Pages/Settings/index.jsx b/client/src/Pages/Settings/index.jsx index dae6bd680..ab529eb68 100644 --- a/client/src/Pages/Settings/index.jsx +++ b/client/src/Pages/Settings/index.jsx @@ -8,6 +8,7 @@ import SettingsPagespeed from "./SettingsPagespeed"; import SettingsDemoMonitors from "./SettingsDemoMonitors"; import SettingsAbout from "./SettingsAbout"; import SettingsEmail from "./SettingsEmail"; +import SettingsGlobalThresholds from "./SettingsGlobalThresholds"; import Button from "@mui/material/Button"; // Utils import { settingsValidation } from "../../Validation/validation"; @@ -48,6 +49,7 @@ const Settings = () => { setIsApiKeySet, setIsEmailPasswordSet, }); + const [addDemoMonitors, isAddingDemoMonitors] = useAddDemoMonitors(); const [isSaving, saveError, saveSettings] = useSaveSettings({ @@ -60,6 +62,7 @@ const Settings = () => { const [deleteAllMonitors, isDeletingMonitors] = useDeleteAllMonitors(); const [deleteMonitorStats, isDeletingMonitorStats] = useDeleteMonitorStats(); + // Setup const isAdmin = useIsAdmin(); const theme = useTheme(); @@ -149,6 +152,7 @@ const Settings = () => { error.details.forEach((err) => { newErrors[err.path[0]] = err.message; }); + setErrors(newErrors); } saveSettings(settingsData?.settings); @@ -190,6 +194,13 @@ const Settings = () => { handleChange={handleChange} errors={errors} /> + + { if (!dayjs(value).isValid()) { return helpers.error("any.invalid"); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 78d0f2aed..5c69ecd81 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -837,6 +837,10 @@ "title": "Display timezone" }, "title": "Settings", + "globalThresholds": { + "title": "Global Thresholds", + "description": "Configure global CPU, Memory, Disk, and Temperature thresholds. If a value is provided, it will automatically be enabled for monitoring." + }, "uiSettings": { "description": "Switch between light and dark mode, or change user interface language.", "labelLanguage": "Language", diff --git a/server/src/db/models/AppSettings.js b/server/src/db/models/AppSettings.js index cd63118e9..be7c856a4 100755 --- a/server/src/db/models/AppSettings.js +++ b/server/src/db/models/AppSettings.js @@ -65,7 +65,13 @@ const AppSettingsSchema = mongoose.Schema( type: Number, default: 1, }, - }, + globalThresholds: { + cpu: { type: Number }, + memory: { type: Number}, + disk: { type: Number}, + temperature: { type: Number}, + } +}, { timestamps: true, } diff --git a/server/src/validation/joi.js b/server/src/validation/joi.js index 744b0ec74..51d8a94a2 100755 --- a/server/src/validation/joi.js +++ b/server/src/validation/joi.js @@ -416,8 +416,16 @@ const updateAppSettingsBodyValidation = joi.object({ systemEmailIgnoreTLS: joi.boolean(), systemEmailRequireTLS: joi.boolean(), systemEmailRejectUnauthorized: joi.boolean(), + + globalThresholds: joi.object({ + cpu: joi.number().min(1).max(100).allow("").optional(), + memory: joi.number().min(1).max(100).allow("").optional(), + disk: joi.number().min(1).max(100).allow("").optional(), + temperature: joi.number().min(1).max(150).allow("").optional(), + }).optional(), }); + //**************************************** // Status Page Validation //**************************************** From 8d105e636db84b64678dfcfd0662098542c1a0ee Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Tue, 29 Jul 2025 00:42:56 +0530 Subject: [PATCH 111/226] Updated Global THreshold Code Quality --- client/src/Hooks/monitorHooks.js | 26 +++++++ .../src/Pages/Infrastructure/Create/index.jsx | 72 +++++++------------ 2 files changed, 50 insertions(+), 48 deletions(-) diff --git a/client/src/Hooks/monitorHooks.js b/client/src/Hooks/monitorHooks.js index 4f0e9c049..6b8cb983b 100644 --- a/client/src/Hooks/monitorHooks.js +++ b/client/src/Hooks/monitorHooks.js @@ -300,6 +300,31 @@ const useCreateMonitor = () => { return [createMonitor, isLoading]; }; +const useFetchGlobalSettings = () => { + const [isLoading, setIsLoading] = useState(true); + const [globalSettings, setGlobalSettings] = useState(null); + + useEffect(() => { + const fetchGlobalSettings = async () => { + try { + const res = await networkService.getAppSettings(); + setGlobalSettings(res?.data); + setIsLoading(false); + } catch (error) { + console.error("Failed to fetch global settings:", error); + } finally { + setIsLoading(false); + } + }; + + fetchGlobalSettings(); + }, []); + + return [globalSettings, isLoading]; +}; + + + const useDeleteMonitor = () => { const [isLoading, setIsLoading] = useState(false); const navigate = useNavigate(); @@ -499,6 +524,7 @@ export { useFetchUptimeMonitorById, useFetchHardwareMonitorById, useCreateMonitor, + useFetchGlobalSettings, useDeleteMonitor, useUpdateMonitor, usePauseMonitor, diff --git a/client/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx index fa6ac4fcb..151383cfb 100644 --- a/client/src/Pages/Infrastructure/Create/index.jsx +++ b/client/src/Pages/Infrastructure/Create/index.jsx @@ -11,7 +11,11 @@ import { capitalizeFirstLetter } from "../../../Utils/stringUtils"; import { useTranslation } from "react-i18next"; import { useGetNotificationsByTeamId } from "../../../Hooks/useNotifications"; import NotificationsConfig from "../../../Components/NotificationConfig"; -import { useUpdateMonitor, useCreateMonitor } from "../../../Hooks/monitorHooks"; +import { + useUpdateMonitor, + useCreateMonitor, + useFetchGlobalSettings, +} from "../../../Hooks/monitorHooks"; // MUI import { Box, Stack, Typography, Button, ButtonGroup } from "@mui/material"; @@ -65,6 +69,7 @@ const CreateInfrastructureMonitor = () => { useGetNotificationsByTeamId(); const [updateMonitor, isUpdating] = useUpdateMonitor(); const [createMonitor, isCreating] = useCreateMonitor(); + const [globalSettings, globalSettingsLoading] = useFetchGlobalSettings(); // State const [errors, setErrors] = useState({}); @@ -87,59 +92,30 @@ const CreateInfrastructureMonitor = () => { }); // Populate form fields if editing - // Populate global thresholds from /settings if creating - const { authToken } = useSelector((state) => state.auth); useEffect(() => { - const fetchGlobalThresholds = async () => { - if (!isCreate) return; + if (!isCreate || globalSettingsLoading) return; + const globalThresholds = globalSettings?.data?.settings?.globalThresholds; - try { - const token = authToken; - if (!token) { - console.warn("No token found for fetching global thresholds."); - return; - } + setInfrastructureMonitor((prev) => ({ + ...prev, + cpu: globalThresholds.cpu != null, + usage_cpu: globalThresholds.cpu != null ? globalThresholds.cpu.toString() : "", - const response = await axios.get(`${import.meta.env.VITE_APP_API_BASE_URL}/settings`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); + memory: globalThresholds.memory != null, + usage_memory: + globalThresholds.memory != null ? globalThresholds.memory.toString() : "", - const globalThresholds = response.data?.data?.settings?.globalThresholds; + disk: globalThresholds.disk != null, + usage_disk: globalThresholds.disk != null ? globalThresholds.disk.toString() : "", - if (!globalThresholds) { - console.warn("No global thresholds found in /settings response."); - return; - } - - setInfrastructureMonitor((prev) => ({ - ...prev, - cpu: globalThresholds.cpu != null, - usage_cpu: globalThresholds.cpu != null ? globalThresholds.cpu.toString() : "", - - memory: globalThresholds.memory != null, - usage_memory: - globalThresholds.memory != null ? globalThresholds.memory.toString() : "", - - disk: globalThresholds.disk != null, - usage_disk: - globalThresholds.disk != null ? globalThresholds.disk.toString() : "", - - temperature: globalThresholds.temperature != null, - usage_temperature: - globalThresholds.temperature != null - ? globalThresholds.temperature.toString() - : "", - })); - } catch (error) { - console.error("Failed to fetch global thresholds:", error); - } - }; - - fetchGlobalThresholds(); - }, [isCreate, user]); + temperature: globalThresholds.temperature != null, + usage_temperature: + globalThresholds.temperature != null + ? globalThresholds.temperature.toString() + : "", + })); + }, [isCreate, globalSettings]); // Handlers const onSubmit = async (event) => { From ad06544a649b436568088bc649941b2d4737bff6 Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Tue, 29 Jul 2025 00:54:25 +0530 Subject: [PATCH 112/226] Removed Consecutive same statements --- client/src/Hooks/monitorHooks.js | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/Hooks/monitorHooks.js b/client/src/Hooks/monitorHooks.js index 6b8cb983b..767ef5af8 100644 --- a/client/src/Hooks/monitorHooks.js +++ b/client/src/Hooks/monitorHooks.js @@ -309,7 +309,6 @@ const useFetchGlobalSettings = () => { try { const res = await networkService.getAppSettings(); setGlobalSettings(res?.data); - setIsLoading(false); } catch (error) { console.error("Failed to fetch global settings:", error); } finally { From 24e7b1c2242a28c1cbac0bbd12f3edc976b753f4 Mon Sep 17 00:00:00 2001 From: karenvicent Date: Tue, 29 Jul 2025 10:07:37 -0400 Subject: [PATCH 113/226] add darmode animation to fallback component --- .../Fallback/FallbackBackground.jsx | 29 ++++++++---------- .../src/assets/Animations/darkmodeOutput.gif | Bin 0 -> 312179 bytes 2 files changed, 13 insertions(+), 16 deletions(-) create mode 100644 client/src/assets/Animations/darkmodeOutput.gif diff --git a/client/src/Components/Fallback/FallbackBackground.jsx b/client/src/Components/Fallback/FallbackBackground.jsx index 29214d6bb..8767837dc 100644 --- a/client/src/Components/Fallback/FallbackBackground.jsx +++ b/client/src/Components/Fallback/FallbackBackground.jsx @@ -3,28 +3,25 @@ import Box from "@mui/material/Box"; import Background from "../../assets/Images/background-grid.svg?react"; import SkeletonDark from "../../assets/Images/create-placeholder-dark.svg?react"; import OutputAnimation from "../../assets/Animations/output.gif"; +import DarkmodeOutput from "../../assets/Animations/darkmodeOutput.gif"; import { useSelector } from "react-redux"; const FallbackBackground = () => { const theme = useTheme(); const mode = useSelector((state) => state.ui.mode); return ( <> - {mode === "light" ? ( - - ) : ( - - )} + d`&M=qVt?&&;Cv;uYM>i_sTPh5X`$+zW8<7fXHVMNCRWOi4^kOhQ6MLP||SMng(U zN(K};85so~@Qs|3o&qRJ;Ke{mK?xKUP}J|KXg*NUexL@5hLHv+THwV*3lu#QEjNv+^;s z@v^Y;va+(WKNbg2TwGkAK7Hcm=H}()<>%)Y07_6$NJvOnSXdM&F;Q`#B!HI$@J&)u zQc6lnT3T90Mn(?!1(K6fP*C{%`LohzC1qu0RiM<=)Ya8BG#^V#OG{f@8~9~=_`j_1 zuRy>-UW~k;dUy%@^5u({FJ6FwyhwWk^5XYjJp9LB`2EHT@Qe3H6N{uybHX#2DN6?Fjofcgja z{sn&jIRgIz!v7wJ{|?2!MdRNB^4~D|_ptmQAoE|q`B!}YhUnj*^zVTB?^yi}*S{k6 zub}-aZvTMp!y_;ro`LZ{;GAb*JQCI4Fx4|KfIjPg)?fX}7LV`GjPbXP*)wDOO)vM% z7|*TLGccZk@$c&VFSz(ml=`#A0CdHpy8pADHO8Y2@T@VOfdL@m85sXzjy;R--`?&s zFrI+{m{R{Y0prP~1!f!1cJ04RLY`fz|7EV!!;1%SEWvUOKQAyu3YFgSjBkE$gu=;! z6`5T@Xzz^Xdn>Yf!ti*c2R$OPd;Q+X6v{eQ<_yHqY4;}!RpoLc5Qs5O7*^?b#e97D za3oYsHj*hAHQZTHT_BSN?TO(cTvIq%q*!G{jM0%b;f_!nh%HiEJX-}5HYJNG^=h=- zV6{I*q^?vo%zR6S@YOL7}F0^h<{rQW|)IY|YG#L9`oz7^Z7o>y%Ci&rVZxm(T zO&IOb`e>F&ns`fN_vTd9$mtMz)A`{Nt<^)%7Kh8)$;Nomg|ds@&DqiFK$^s&#of-~ z;qKyyy3YOmiv>nC5AY0ur7vKtXX>6%j9W`yaQtM;-bf638a`g6{k213D-Cc$Fkbep@^iu%u0;rv6s&Qx z(QL0pN%E7gN4tXyt;Z<7n$d|>8Lrcf)9@wV@K-0hs3_P-vgl?}jFLhcMV1|}SGb+4CD*^5 z2dfIPlb;=Hv{R686u(ngEWB-4RNnotGg~AjOtD*1Kdfh5+AxN(RBGizwiAT@@@B03 z$1!U`2lJ=B%OR*3|J=)VRSQ>b>kXvT|w$-O(=`!(|ME%oWB z;D)G87C+bQJ9i;Am(y`c0qU~}Sq0NGIYxC=*{My#{j(S`M6B~^ojB9;S%bWi+9{E; zeMTkIHtLIco9PsSX}e8qv<0h>hZN#Pr?)hhOA%dS4q_g-YFW#HOszGmxYyY5tNuMP zmn9=oFjpJOhH5cu$$oA>)?vO37j9 zl_wcFA40y7VDD}aiM^fsR%FnuIFyBbcRVE~ad$Q&V(99CZI*U-0Z(JTSThoblX3Ck zZ7AnuV8F=z%~6ke*$t6(9p~+7nVWmu)xcrf*3Hf0m+QAFC*VgOU@j`!wY=>htfTxx z#u`mTm+cU#zMd$9>3(#*gjwj4UU*O$qhyz#;7dt7NQhGRnVCxva>JDpSpp$zeA>W< zj(h|^EnC_6fAgu3^ybReggvc76E^{a)&9^$>O>VNKS)qK zFjx(X0DV0FBcq*0NIv%}_)oze*16+Qhe5)arTiXlbjo1Y zTU=alrrwVjC*dpv0a)-?4dyScgnWpUh|q=lgq2Pr4U~yUnELwS*(2$rY?9YFXZxfV zD57&^At;dR`ekt>qDq5vsPsR_Y>Xlh{a_oWzRDa>UC4tjPCu-p_i5=Xi>4h3?Hh8v_kEs29VS&&(is~}6=Or`vV)1>*AVY}W|3^&d{P)K zH@RND`j5Hob0YxkLj2uI#B(F?zp@bs*LaKc?~MSwgvOtZKp>V-WpJ3U3J=>5t_}W(lY4NE1#LfybS*`_~jrmuY+p zoJ@!$o~|$$FH{|1G@bPn|MAoa{7g^+U-)?=`&zKb<+Z?@y~^~ZWH(qDqGD$9fNqvE z?o^Xg`t^ahx7}SpBd|F_Qsapu)>v)m|4O1rZHV<(+ms;Uw^XsD)wZg5>gk1{ru~hD zb}vX=@#gyDRiF`2A8u~=wKlOw^eyNCmll!j>etqI*?nni z%gx1#?YA^_Y0kT{jmJjdq44hh1t{5)NA|kFlF$Qo{*osYcbvv!Bak5mD{iptgQl0i z?2BdAtLBHNrmNvk6l<^&KqjuJ8CX=(yCQ?zO12uj)*!eVg4yN28p=*Em1RwMFPI*} zjpB$O!A}x^6DiF2)CgR~gi6bUqeRK;73d1eT1~7+DEpG^VN`4%H^E=AB{laR%S58)u!MVMvCq1Xn7;USSK|KsoX@n zo$b7I-!8-aJU(Hd5y&mvDV*iNn9qNo_|Q*bQaNl?Oy9(YFirR9~f zNjX1EP_b2i=-N)@X#c6nVIqd#$X5p50Y7O}#kaNj#eXmfwb zFVoBE`GZel{CY4}@ZrWKCoKB*^5QVk<@VR2Ei+=fG|&j_a;Lv)kZG@`E?2&G#!Dg~ z`@}6(?uj2pdiTWW0;*Hb@wlDM3hhWJNT|^@A6H60lz85!w`XEGUdP0`slK#;x>+7djBHEHn zDKclm1oT(BF}Mxc4N%&`71e9Tm9j&^f>)6$p-{1Cjsw(Lk+JL3oG7zh&odz zWP>^ov}x$k1`K?}!`XVG#SM`jFP^FXzukU(ruzStRB!QLX#Uj>1RS16s$c8!B$GjT z-}hVdg?k5n&mLZX~R-+;n?Q(OA8$>A46_Gg*WPc0s=DQzh0%Qbx|VNo}V>MK?o3BX!) zZtE-82jWR#vXdUEKAp`9TCCAVzCYIsLew?RLUo!pp;GS-)<Y(EvRIm1#@saAEH2>M|%p=vqFQhiAPs-HW{*UJcFc%u4i<)joo3Y5&)-&9Yr6P+0PsQFptjWmmLcXl2%zmrOl zpcSC`eKiAjOPkMkD6JarcO=V=;W1MxzLGd+ruI_oQuz#>pzl{r%<=6iREibFl}u*# zQF~0=1%=nH#8S@GZR8F}*Y7Ctnl%NqLYgO^4DZUd{G2U1Y`s0-Efd^3z&L6HLEWQl z1tl#mPmqIwQiuejO~z|PP%t^}LesxZ`i5aYkJf|ZM+Ip9I4a^U3r~}iek!KfynbqP zG|R#F(=L?#)T>Y=gN%8-)`IUYsBEM^!J3|a7bI=b`wo6zd^5Vk-=wN3e9l1MG& zY^vXAs76U`5#veo+x2Ltm7LF+)Tts2vo=xNb6aA5zLe$x9Wg)qI1WE%5Y#Mn{ zjf17Kz?2hTvaa&G52ZVg4yp-0R@S~$|Firq9AY1aI&SJGcrtf@l9$@#KpG_9Ti)|jSA%|Hl%9p?S zBRUU$tSDJY3M3RsWtSoc*qxAJIM~jb_e(NQ5KcdCn2v1D?Pm|e`^Y``)?ti z>oD(!Uy;8OMAr=n`S-us9ZcJw^?YkJ>?0_7wkA8w=jb4Qz|oHs9rM?TV&L4Ci!c-l zh>N+<1YcK`HQUyGtLjF+5wDYote0SrTd-i>nc?Su9u}2R|(O#ti@xWfysMW@kkT=b2 zQrpd_1rs0Guj6)tJkVKbjoIf}55?4Pq$~u4d_x7~Ve=^rxp~XwMp0Sf#lh}jn{vvH zYFisT)R818Zt>AqD18cxZ*T%s$DJ=_if6vvlbBfQp_%U;w_>=fq-Wy?nVj@}%`86Y z$I6vw7@%{Xeq{IJ$w9_VsPzHXi#_Wi_6MqJ??-koVpflTpU8*1|6WC;4E;m1443H{ zAmmHVrc?$dZKgH+03q*5;cPN#=mt$c$6T;q^dsS`*!jFoP@mmT2Sg#Wh4ho(LY}6c z-)dU*atY~Y$mL2f(*Yplor^DRqVmA@mLv2_Sr%utU~J4{%+-oFGhxN9MKhXWoVHI& zV6p*6ALej7_#4g5KIZqT|vL;GTwlycB1Ce+3Z{*1EJ)7G{5Q(394DJNQ-N;qLM<{^8znE-n0F z2od+;mZbe9q0E3#xTHjBJo2Fn9tPJYql6th@+D2m$TjiD#gX=5R2|n;k`VX&R_f7B#yOb$ z(L=E_PcbLJn;dF+FtRiG>J7JCa0pxwLF`IFF-`6lEtz%)UV0o|a_8BVmkCwT42kq) zrbPa)7viIs)qFy!@wGoao8`~99RGT6_{_1tIaX4`4-|ow>_4BH|2E5m5io4XdMk-L zqW+kg0~~9;(p*rL#~=6cf%sJD$t;heQ0oMlav0%kcdHK$Tc z*BjSbE2u3IC^cI}F>Qb^op0iwrEm=drsn=s4-hz_GnqfX`eUMq?j6ys_Jk8AVf%AG zD(H7IAr!KJg2p}b#2In~rsnxFBJcBosrhsq+qdC8hg#EK9&>$+x+-)L)aNT?_CrjpQV7zZ&=bjv_D)RC)PSk^x8YmK0m^Zcq#>-6 z;$I_>4ZGIrJg-ep{rT#As&s@luPc1SMkuzv%QnUAjL88U>mp^oUsEZOHC0on=$=BT zuALNOJZt0}XZM4yG9`E3g76IB*v*nUeVfAv)yl=!zR=9`uCS%qOTMHF@GD;#S~Zsg zDc2s&ava*V*j*?4jbwYXg^koSU&pO%KtV@mQNFp}E{k+}8mwoi8cJba}QJ#&ot4 zXU;jj>0Ib~?Og&)%|pM^KHMfoNIYa7ES7QEogW@PY=Z4BK5!GXzw{(_0SyL!^-I2; z>qEKll}I|sVpaPMyM-s(8csfef7`MJ-Q(2UlUI;iF^0|+m3=u^I{zz5qogBka0b)> zaSFtNjxY0IMmT3aPgjKnGb8&XY^Ae7EPm{$Kkwd1g=c&f5i7j2C=wiO5o4D>$8jKX z009cIe<$@pNs!ieg0#*pK32Jk;D=X)7}LGojD<(Rp6D3ppP2-hX!=4OZWA%y#$g_! zi@tIl%u>lD=xr^}4Yz_9#DlI&=2+p6*eJ+))mF_qe55E+Ii-Qqpe_VchoO$oe_eE(Aq&*;AQI^HprYk*e&oj)yE~0Zhe4*TAQSRCA+;Z36RQS7)0}9`S_gTLr&El zQ{~zNxmY%}`#!e@CBO&=Q}#xAnt9u8E&n#c zdyxM$!m%`aWxc3=OJ&8xpeGuiN$6eWW471%MHbqp1Je2)}@T zT7E0c_j9KKmfylNE%dVm7=^cUOvDnia^e@d4S|_A#cJ{kBZ@y}-o>>$h0?it54l>! zWm7xf^Bf|KitHK>c6Q6!L4S~zd__@4tmr0zBrOd(FEnu<=A`Yn2%Lj#IJ5+KoGN)znS<-IRyT#jd+}&8*8!qYc)_MP>H4Zpd^k-S?0i z?fe9ybW#wsdn(^xe(4?6z}d@L${`9AA9rK! zM;0?El8=bW4l!;PTdULQ6a5-4UQS*c5rBt&G{Q?xzjD*=|7H2@52?tlyYN{sO%ufzLf3l`3!%;h z@&lm<(T#_kQu|+f1a5g+!&2B4I^x`J0|qY6T@1g!Wx6wnQBc11GzF*+U3DP% z=T7dGiE@s2!5RHl4sn=?!#-@UGAmjg^1u099`W$*eLs)fq9Z|}XAyVFL~29nBG1hM zmftc_mTfwtz$}A4cVr5-Iv~U%+OoI7-<%>9ij%E`{mUSWV&y@a5=}&8H&y@a5>Ccq@&-r`L`7r;8?0ZfW`!ne8|8t3A&no>{ zr9Z3mXO;e}(*Mfm&yjD>l>YyBO6O$92ae66fS6=nsq!2VPx9Ya=YJUQ)+_%o-hpE? zV15o!@mexo6gW0}nx8YLX+7T!=IZQ#9W~(U zywlSyAQf8F;b7@2+1p6I!TJjABtKjxq?&Ertooo z4#$%hOjXIS_oLoAEBr3rPHmyl%lwVnl+O`xY&Ia8IZpx1&u0?@fU9#`Um7SO7dPy) z&4W>Es)tv=2KoWfs?;^(NbB7+)%H)|zkkgBM5AuE9q*e zfX@+4J3-ghE{V?b0mo*D@^6=Yu*75VHScZ7GIel64Kk(i`4v_IsA?U)2P*p1X9T@x z1=S8==9gXeU}CJ+4*hsPtsTy5#jFz{2t)QG0{cFGP3&E0_nNtcyyIlFyw;kIqLiK? zLadtaXPkJgSVQatz1&%>L?fCS-6Ww3@H`#W;o0!ycR2=|sWPViq-ijd;9F|$Fh*p0 zK3@wqGtRn5qtb)V1P*yB|O6Cax%G4A)Xo4jPPK4Va1Uwn9oZo=n|jHl1%cBekR?6q#Gx-iyRH z91HuGwP#!#%eI3-4q7O@8E~Th0!xTRUx>_PV%`le?sCw@Zdpw4iepV|6-*fBVAVgL zTFx*q)EcbVN0&zOP8=dB&U%O`i|^eRu94YyX`k}gZHAP;6ce8<^O*TKX7 z=)6mj3R988TbzhRUsO;BC4W*iB>Ny*WEO!Cwp6|K*th@|2p__Rnqnrq)CRRoW46mJ zI`hzL(;R1KC)KDj@syoPQX%Jkno@m}y?T8|=R?F}&YN9l2-s$xwzSi$<1%9PnB(A@ z)Z$+*)FUnxomn7vXC%@it{1aSSFWK8q&0V!ZJ8rhS5*aNSo2e;BrP`(J_Byzo9c0G z3ABe24=xwuX=WRKDD7S51S8;hSzx`yTVxhwZ*Or|zoH_73QH2A=nTGox6U;r6^;+z zoE{?^^C{X}(mU?+D7a6}*Mq%cFS1!Yh(p3}$1$THKA50E`5@I`9u)ZW&5pu6R78H8 zhJv@}OV)Ijx8Ws@ed8~7tIQX<)ya;(<0S`$j9kS`LzPJrh@*o7M_t#Y_vQGb?wXeE zSbld-jg*Rsi#Go-Sq$^AZm>XycG2D?KZjX=kX0~JBFbjZM>W4N-w9{}M4s@sh@OCB#O10@F0;*x0iBf z0>$~dKkf^{DdjlB)~EfQal$B9dN)xVSk! zTsvd#!9&gu&1~rAhoRYOA~hFQ!$X_F3q@&{UjxnOQ{r6T#Vm?u#5E#b(b`fHX}s8- zZYSLNN?_u(1Y(O12Za;pNIki@5&sf4x8S?CD+xKzApW;RCsJ1~>E+=|4R~V>F)v){ zm1advc+i41e@t&b*d7N8Of`Ce>=)Wy9s?g(#N}aLebMyfor!r1%^+<62gXys*2|D! zsgTXiiLuntNBf=L-A%(E=Ye=3r&vyMiJhVESRLoES5gpbCRQTib zP2+$6M2F{_#{ZQ!jgtSTs~mr9WIlD2$upIkZQf5ejlf1`21zJI$xKJyK+a=)!DC06 zAzt?T>Y)roG&jBogt0JU*9u@{BZ3ltSfH-*a94On;Ic1I-Z5x$uZZ zYGLe*NWHnw*@<>aj~yihd=6TK61-7*U?GK4LFo>KXl~_$-*!>8nQj4Z$yA}SY{S7$ zkyHz?k;z}DgtS-QyI^BdNz|P<#OMPOAS6Lr$|z^%13BxK1o~dWB;Yb__ zusefUJ&`xH<_5-sHM6F9)CTi}E!0eLs*BVNKb`U(`s^1;Ht+VQyx?gT7H#H2s8&3j z`!0J!L=2OcrAn|_1jE#E)>mVVQ`5VmokL}|vUSanw>@n`m1P69?X`9@QH`ATxQE5r zf@||kYjzga-JH`-k5lpXcCHSPciPHyT~07*MqGSr2_Ks6q#?ODz6_)4-JLP&E{30~ zdoHT<-8+qNcy6SHxi0)DlE}D($e~5MJ%}UX5v7lZ+*SXUJJH$?E@}0) zGk)ndvJHgW=nIUCTs#C?$J{5lH_*lDd<Bg0i;c14%TF|1;S|q%iC6KChe#$p zzBhb|gPx5mc?LPDK8YG7G)UpKc1bfCzbA4y}0Z zbeg_8aqYpq0rCl=I}KyP8lQ5ngJ%aNvieLkENP`zpH&-*Z9zXO$aZFWdXHQpA z$kk=Zh63x%iaQjF7vxhFb``~ZedJY&5C}CeM_Tt*l&m7BliU?&1qL5>G4lwgrHm$N zZLxo-3t3Nxtjm?@@$LyhU<&7+#fS#$PG}Oo?r-0LD9Xj4THDZ?%@>o8k#pLMwaflo zDoI_LGbd;GHcu=vUB2SzmdfN8v+|pVlG<9HF6wI?k?7%_bFb#R5&|E#^sMXJygK9w zOI6f)oj}Ig&NB(CtfGih>gg|KL-N)I1MxIeuNdb|hcg@Dz>a*U+4S1KaSa#Y@C<6U z>|sk-O{FJZx^1$g1@zhOD9#jXy z>qVV5I8botqmRq6?>8EbJ1mgne!b>_n;|8Uq9fuHMa1ydgvTE4=l__j89#~#POIka zmBIjr;^*IubSw{({c()|^Qu=+yfQLwaE*lRs!uYsGCD6=hcX@?E~vK>-C;h;OOZK( zhR!m!M|6WB>1xnGyee^eaD%bw3TntA^j#umupjje5#IAz?DJXd^I7cU^Thu9UEky7 zfWUt~i~W1ckzQbFzOQPt=g*TJsSwn#g9#w$ay$=#4a5ihkD$w+PPP=ai!v zy5q5;g}}*9uGwm4^qlll=*3P=U#!;87LUnuXwiDQK2KkuLByH{S@nJWeFOU`^g_?) zu|fRB-;+u|Rs%83ur-=5qc1LcyEmG=h>rm5$UVp2X_5!s^eb7p8 zP@MQe6hGeM$xax#?)TGO*Z}RtQo0~pAm7UG;o=^-{_@vWTBO8CE63qkU0hQd7dEHs zg4w|ENoU*Z-$z%b-BJfSeU78PMpY9AX!uqkg%z<2arb&bOYc6|kr zdLgh-)&VCwpiKHH z4%eqyGWHwW`o^yJ0^{Fkr));g|Ak#QkJTn^9{Q7YzsW7g2?c=Nr%sAS-v z**9_ky;&kRDIYkH-8m4vMe#FltL7`9?Kr^3Qfdcdx-Dse5lBYJOSq@BY%5oCsi=oC z-?Qw&QHQeXg%1;D@Oo{od(yXzP{%NE+KQFjP4}9_dI*b01#1W|S9D{Dd65!(wDjxx zuV!BTY>QE@-NDoG@39T1Y3!RZ>SL1DOty-O`ag}QW3W||X0m7+Yo=p{AsFXM&K=J~ z%#+YAbd9T2Ysc+=sM;??A3O|Qv^qSb)GT^J3fJrFpsGzR2d?&Ax>)C7&#Zt$BVQm`5WOA@dg-dwe2xuA+-Z$x zG`QLPhBMsptO>7_D!+rjz}M?*1m4+4K~i`O8ZT@UOb8hVCU~iEA98$rs0zYw=#7SK z;MJOZYFptLJ8-O=&Csw9rl_5`1bP1aNH7RdTS>&V{oZ_5D{|?*o#-WWEIc5RFnmN^ z6s$x+O}_{#8p-&l!z+S|SqL#?vbvE=zKbfjtg7Xsl`;3ji2)UJ3(xzVo+p*W*6 zF}C%2z~?%G?)5X#YV-v^^?a{1u*oKUNiH0gDQ#R@?MK`)EYw2A71ml!M%`j4VO1!@ zoH?AExMC<_PB4)XI*^a{k|O**dtw0@xq!iuLRK4XydYPgQ2vd+TmXX%5udC}jiHe* ztN)Ic%siLOE!q5QUK4Vik%h|DHwL+3 z<6ZYHA;k(ZI}0c1?piC=~*WeDOo7;-S&-{$#{B{?qoAf4WO+2)&(Dr~j zqbXsPbEmPgCfk;f;AaIDYFYk{nQ|*8+A&2?b7vR4Eqrh3zO-S>_6I^2LOnHPTTS=| z7Co1+F`tiX3KC+L<|acPe(u$It$XWf_!}BacY(ccgA~7+FncdSDglmSMuLV;O!%`GPuROsUgff{?vmx;l*(!G_jb%X0P&Q}8(FjetAx9b$)zZ=8` zET0ceR~$nYv7X3-aQ=KVvruxN{h!QCl8{8a!^O+1(%*`0absSO`ioa*qW+WRDAa@o z>j3j?IsQ)`BKvGPK3k4|7o8r6wS5fI7lj9(`FI5WDf+LL<2?9S&WJm>}kv$Hvt73{DhuFaNSn5!G_qYCPAZW$(dk?|m z^_XA$IuMJdHd)(EUCVs3IjYSTCf;(rwGAYurUl>8E$pMTc|FAabaj#1*cgwZDgzQz ztM(QL6z1UWToR9U1KUR0+`Dc=$x+AL-p_6>et`mafcF4nWKQx_r{7xg{{D$M+8YVD z9^*&b&|C5)`wW)JgB=Parb5K{uLNQg0!QG`jbN+6w|zijs$YO$bchGM|7w`PF_?}Z zZfXvJDo-PmO8+3#R#;`PLFI%ZQumI|>2B(~b6 z2}m(!D3f~0%|7IMi8dPzKav|}6DAUzSiAMprLX&D((gKiwo)AqK({iH$enOA&AE-X zMWfXUx789>47S80%^+m*GGqHu3POF1DE#wJ&dIV18y`^gii)OokV|SeoRCW!&nb}0 zT2s$=?Ak$_Hp=CYjrS_qLeic zr_?^c3E5QRLH7WD12Dwa4?LaHr>)xV2RUrD?VmW5eb8g3YZpW=UdRK>%CYzc%>`vC z4n=80XDcSmUeSVKPG#PM(*$YNyAmqOP($Knf7}PYIdC$7m%~2MkLx4!Zcw-}xJnUu zgzVR_#?QUE5#%!{8?*f>-qY`8=p0d_(9}ckMTKzlGsh{N0?sDrcLyh?dOktRPx1)w zC(LYrNlBemQSZ0=5#9=2XJ$08eb#AUP6eEA0Uf1%GD*3>`J(G&Nc1xImDpusmt~TD zL706Jvqr?GEX!~drtjs7hHmTVQnJhzOK==wO#@H*FUCg69KYe8KTAc>jdrjb1ljmY zdQvq)%6HvPc3M}#Zlmf5*CW5^??J{oy;SitN+d6{b`m7L z@z(I9)xf?@=a>5~HE8lH`hRQPD(85=77z6>KK2a@roum$K^-oFH-)%x0|fLMKLbOM zguJyoz@>@+AuX$m8YeHvaU~=Ct_8Wsr<@b|#^&!!CIRqT zQx$QwE5r<_S@hawYtgADDGYIj@rKff!fnlZC)MN>#+fe?J;ujqJNm=~^s166Pe|sI z`yw<}Hk0M?Nm-SrsO?LAQq;XS*#(dugk8kfRP3wuTP7OF+(RYP2BP(e;2TFSKiEit zxEiPsP)G%!opM2i1UVonNge7|XUX4?3DZCjgr@pu; zhEEZXEK=eKtFyqEn)QGwu4hXp_^u=$IUg!9+-#)0%`#0|QB(Sfpy+dMBU*z}V6@u@ z#o1Gc>~@G_KHoESB=CWE%?p7QHm?=6LQVu*S!<2S;;96iDTD`JAymc<=^H46DFyq~ zXe46q%Zn7v70;ZOqAXLLV1WIYdf3RW<%y(5{cQYWeyLWoAZNhb`9}P!L#$4RDU5jl z8=f9bTmvf$jb2lvO17n0{V4XNb(Qh35jLgL44=3y{6y)Il|50(gsF0y_QEgU>=x?? z8pDaCultR4v|V0@uFls?*M&lDHz{#Z69nqeubA6$h9(^rSJi{Nvpc|Yg;7yD5^Fy) ze}&%W#-pGreRI(d<#74d7hA`l#or;YwXs~#r3!RBc!o<}5yXI7e#v0Dqw}5Hu}e1I zYUh>%Ckq&_KloS8Zw_c>@4>wBm85l+@)g71w||RNJy|`yZHMDH7mBIl@uH*B>Q|0f zk5cCGB64tq=ke|Ti1j9lNN`yNupFzBH%0$xh<$T4;sI0ryQ6d(mZ#&vL+LPhm;ZTy z!ZHjNWoS!S`FbpVxH^|^?xB450wP@%*Sf3@Q~7dY6nh52k@ zK3kaoGYj)yp4Ib|IwTZGi1x?UBq`nY3CP;8{IDTtN-WAh0#5Uc>p|G!NwVE`K9iA|PQq+FR)fzo7`l zdRNZ>C6wG4XgJ>qHl!vbf{8gDZGT};W2fnsm?s8!hR~2 z`L@n7@_i_f?ALVJM-&tfAGzg*GOy2&e_3g z6f|dKtIg%fFM(Qh>^8Tn;m=4*?$TF}&*~}F1uRU8Xf-bu=-yFJI42@apG6KbM4#hh zzhysROE4gHXn}}85W{RuGhkoUU?G4iwwl16yn6=Q4u~rt6rwxm%?#pPKwER+IhX=0 z%xedoNYPl3HFJTfnYCyT8sJ$yvK&CXpQ7-TuDpsHd9I5l5Yg|bn@YZ((31m1^v^Z` z5&eUGKyI((ivMQn@@yZeiTlF3e!6!oqd|th6<}cobJrSVg=3r>WJjNWFwBXcvo+K) z)@zi?J22hZ&Nm1UHd1KK>vLR04E_uNlWRqkwq?^l~31F1up!paz>vm8OD0#k07RCQ=R%5jw|!~6$=KzQ$N zBT~%`d?RE>Q&!7F|E`(hWg%EC2BtE5PKpjJw0ez?dCp$!-Rs^R2{ zw&r8UWIP@D@FwJar7-PJ<8g7)&o=y$UN)8!jPfbF6Y{DhDO1xp`{Xm>jv+M~I&3=U zvmya4igP9k5ccyC!(#SXbWN@o;iYpAElio2r9fOh-;HYYf)}Q~!-@;dFmq}sm$?2K zy7a(@cw!MZruD=gA_p=5cU_K~dZBLX>*PtT-#0}PhFG_c^+Kn1^?VoV_sW&MS+ff_ z-EEP1k!muDe-!QFa-s>8B!3pC@P=V!T1%#8py98x1fN|et(~%IFWT=Z9 zM4+9l`CMgW+$xgsL@m*AG7>4lKExn)3f`3y2-qyaUC4D3Ux)dL&wqzaVlF3=hYOU7 z#zgt~0Rxu7GHB_$rgXCb9Yv&g@Z>;dXS-tUp(4D2i7cTRv zdZm2tsKfkQkO@ zeWJs;(bDMa+(bec@|B=5xh}*+rrE-GpGsoG!Pgh-W9k%R!D8dBbVu~au~qJc;&m*O z>0JZRny8}^!d{ZR=O7$1qzH_ky2WO!V#zTw+DssMsqfaz!fUA&ngqt3%hMY$TxVL9 z60EYxW-CvB>SCa}=be|mQScx^d|Z{r+E4oNPFmO&W`g&Ded`9HaP-!rKWxKVah8t2f+=?0Ni)>`$AszCc{3M z6eUaJlunhioT<=f${*|xleL0DMSG@E{6Bu?ey?M5LNoay(z8HfRu7I~D!7#SBY#`6 zq4NyPCd(Lmbs@KbCz;0fL#C>2$FNfG&x4*~%7w7{T9q|wbAv|A*dv0F7DLNJ$?>9v z;dX`AL2q+c!Z9DdweEM9(8f`XaQ+WYOp-G(VdzIkVRvr5aMlJEQc@dUe8tNjV9dgi zQ6dtd-hsiwAC&un6TN|9((452|AdDo3itZQ`>H~880c4;U~%>F!ZXlx83rDH7;U^2 zo5Vw~P?}ol7Nos{*}zid@@k1FQ3u<1OE$xBZ(>@{E=hEMIo5SgT*2kv7UsXsxTXHl zaQ3i3n5TNCT(Iju&Bnp1(JM{XVjPIUf!_0PIFVdCInA8`uBcWexzLSh~x`jyxX6vXVC*fu^u-md17J2lXs57-vf6R}@k<;LXU?f`>g%xL(@|Fpa%<#d=^~3kU_9e^Y25xg!0*D`N zO+Hh!pMErzKz=llkpS>?2>EQS(3o{5sbKc8YC=oSND!THDwu$^NGt4w^bk>g@~p`A zJ5pjrc?N;?7!{0q;E9lP7rOB}PGGtT22PG7LZ5TD_4Kt{$@5~3X=K-uNq=tVMyYfK zYzDfRZEmKi)+YQ&CwwEkmC1nKsGdSVejSkoE7ZT0v-n9VIW-=FufPh;YR=FrR^D$b zKloaaLMSuPhN7@s_~G2BIMp?9tvJ6%*f_MFpj4}}(*`|&t(o6iB3YmwR>Ej+ z^)1A3fy3@0{w&gEx5Z#-bgCtNnFXGPIW>^B5r1V_&;DZNic!2FDONpHZzIubh-fp# z3%oJey^V=QKI_5OX-hL_nAO9*>7I2-PKK9!EwqI5rnP1>pUtoBE$Phxd!#b!aT~rH zdru1!QnTU+VcE>l*tnqUnG9oCi_drSqPz2BHQ4c!W!tu*OYca4TkG6lIfoBW^X_j{ z{~z|=Dxj`)&9+?ug1fuR#N92yo#5{7PVfYGcXxMp2<{L(NFW4vhX4ul4zhMt?Ok`* zJ@?deUgzt4X`}!B>n%SP;^{`#;=?l`@K7F9!%m_D^+OnTD3;}#-q83b4%JV!vybpy z@UJ`vK4np|1kCm4NmBC^!n5rMG+yUHV?p-~e{>9*rXz!&X$`}usgoo3AzQTXe~Y(4 z6T&5ig6tv{k3Vh}Do~XW16GBlC-Gu3eS=sK=qDvcg8>a{t8FK7Q z3VsUvcsA{yhvL3rq5Tl#1%F}CfW=o^gXE(I8M8z!Ax~AbS|Y)`M55$tOt4;&eH;g*gTi))1d5? z?+@`#*oisErU*jx*I{xesy^LOA$JCs0iV zj$LIpgsUWB#;Od&zbv7};YFj#g9~X2EW>Ir<|y!`iVIiKu&QUwTJAM>glEE9cJ*Bng?<@k)Pb9ER0{Vls*r9XEUHm`#x{DSrq<-2YbIVK;3^0kM8<&XEA z@xIrUs?QOYz4FSWAE`o8uGoqYBELj`z*`7@1Jb13&|{ID1fI_|srzF&2MHM%jNl%C zgPb@dI@T^idIk~B+mkxii+4ttm0#W{^RSxw#0BEq)4=#!UcS-H%KA}~Ci{|QYC;$r zB;ay!dzI27JP6;So95&K_e7e`rwM( z6>*P6QOkZnuPy*{?|+Up|K*|lV@m)YM17b{X8sg|R$xi8?!S#Sr;gM+9~bLlnyW12 zkG0nxm)i8*NSz74=R!gK2|E7-oqvMP|9#Lo7WJRItp9|dM}%bm4nhC(m`5!_z=*H= zAG@sZQ;(l5{%e;NK+wOXSi%Q?8x=`FKmIJlR{R3?(^l& zL|S{o6a&`T!qkhxBzl4LFwGxuk;-FPN_?&|xtC|mtl9lrVdA}K+?x5}e)!bnMNsCT zt(DZp>%7&OA352--tJa0SKC<(9P_;IXo__PgN1mX^sjfyV}P!v?>P{3P8cA40Or+G zj}?&}umqpM%7-w(y!zbBG0*qCTvHfoKuQi`uv861Rkt)!#*`T%QLWR1O$;IqNiqtP z^q2ZBPHoO*tV)n`P~bxU;vyv+0$$d6WK2zB1n$~QL}~9dRU<{NZcXB`(87znC6|he zeBLryX(TGEKaeJ?n@f|XXuGedCy6<&Y$xgyiqg-OPadjU1rvb0iTSmwCed#a+a+Kar@rn2fzovtSNO6DZ)8i_!w zE}qsVwQ(fG%C?E*myDh2q<$r%=8F9jR+GB>F(dqvHPlfXN_ga1hirKyN!y9g-dX1* zG53qY#MPna=Umgd#Km3YaW7f2cU4~nSr&F5`fG+dBY0;1(*aDEAz)tpy(_EzzBxmB zK7lubvpCs%0p}sI+^PoA$qc!~0da{F>v8(!D2OSp%BV&KmVMpIcWh55r_*fh46eyy zB))2&m|GUdarY^Aj_H`4xQtsh#nD-L(uZ=-4-3hblcZ(lb}|KkWNm|>3HGk z!`M_Cp{DJe)a>@)WD+5-gE&5IzN6Tj>BoYaUImMjoLXek(+2L=w%)w`jqKC)JB9r- zXADL3vsk=Xoqb#KSiWl=_;dcfIbr0VlZ%SI4cDv8W8K=H-+x%V|9%c-a<}WfUUYSM z9sR6)(wck!^gfswY`L>3ydAGLf>I7fnaF+CrvzTs%RWLQDy`R~zPTc;^T+lg75g;! z2G%;l51WGknw^IU&iS(-!qqn?x*vRKAt6DOuSkdFe)O%`)depblNqUg5qx453t?eU zM^+3OAR4w1LC1yq5xA(e2CG0 zC(L7xyrZftogo1#O26tI&OB~KQ(k=Z4VuYQ9VnAK!!+8_6&9~LU_?M-JXWv4fMni6 zavE`t#oKn1>{U1c+FX2moR(=Osb@T=P(p%BJSDYS;h6qPRbu8~E+L8X=(`%m(Bd5o zki6BXQpQyRSc{6DwUL>es)4#Si)uobR#-D6GF3;#h;9p7POk$drJ-wvMR7+q8ta;R zf}_MghgP1L$|Z5CYMiq;Vd|ZIV@B}9o>f75lD$ZL#<#9`9dObOb``TN%8D_XythB*3XtI^L@G$mt47NxKRjh?W)9N=eb+$yo zQwe2y9VqZD7j1R*sVht6+UK9QE#1*n4!scC!(13`eVo#^^_=TF5_RpT^k>S>I+#0r z8bV9mOlm2|lgs+49a}*)?ifixhM++)S6$>G*ss_85hq}`+0g3}WAj?Usavz&LHCki z{H~?6hvk-;@u6}v(5v-RGhdzMUZ&Px6lwNQ8^_*o^TFa!p|UgU$__)q6h?OOCQugci_33DZ3yF4Z6CYN_fODa3l+!x4r3TIgM#S4lUv6d zi)f(G_}&r~nw?*q)Z>!{Zo)SRUVZ>l%dW{U94!LsaC7}x0=4|6NQyxt%9Q z0>q@e$Lkf2^e>O`jeXO30HYGVD1bmrh1iuIKuk*bp+85yv)ne5##+&Rt8mY*sn-pG zM4x?v{5z`42es&~c8%?6K(jZE$_Gev-E?N2HF)pOiCUMzre|OHJw|p&pg%Bf+&*4k z=m7`ky+ZaS*Qd)IfM##%=kx1>1#buus62<-VvTRsP%UoX0z5cj$-GD8zhnmw3Vulm z8lVj`a>wtu-wJaNesKpBxRQoKmK+qgW4$mcS^6kWBnSLRt`F-TpxMJ*f!&E?gO}ck zr@;qeQf#`hokR#eKmjCssu`bbC@sC4vTrqJ=EB3YlOL(?XGLwxkm9+QF_z1md4mhZFDC!wsad!_-4eeqgj95 zTupobl5_Q?OyqDClOWOCoKxUDb(NBa^t@is?5uED(v_v;)KepP-E0Q?;Vb7mlMxN; zFBT-SE;DuxR(HdWxnrB_PPq~9Hww?d(YmXY4be&8WFLsmd+YJ1xr-Xz<$ip3c0l4? zj!?9>1pW;z>Tk7zIS&f&OrCkO3u5&Rzb;DmkRFxjsCgZm_vFxPRSirhp0uchd$<2^ zj@dbr_RQfo`-DPrN*-xy)MeD{9o2QE$f=lo&IW&;>omrUf`02^jPk2u-DWfH{yT}h z--j&}8o|VUmlVH;jAoQx*K__K0@=qiBs;&X7HfOeo-uI1&l+qGv&g!6XSSym6X8`ZljsR8~g zRz?4aZIT(v(YNpuu_5cKWH70t$60Yv(YEO1+?Zpl3=Ih>!&B2SrB%CdLeEM<5f`q3sBpHmrAbG-MziH2>i9fOIVXF{OkNi$?kV1Uw+5HD zXSOoD>^w70l;bKNktXfztdN>9RxEo9th_Ad%C;cM+(r`L>6uqGa&7 z;cRgQ*-G2Otnm1e&X{6bKM&X+Xq97alRU`W$}AN!O6`PhglY8#?ttG?m1D5p(7oK%2YWF3ajE=03dHcFZ|Q&+Q- zFSgk1?^vw#S=80b9@RnvNmjetRdYDoh<(#uQYuMy&{U(;I?gETzQQVc|BF6kN2Z`_ zFS;(E8ZiYmQ*)N3rJg7_&qzl~L-uiz`nb|olA1s5rxTZ9s@|kS(UB~@U0hRKw5p|w z>_V1bN^`U)gDpYx>I#^6*rlO(+=g}xb7~sY+Tx4o7)Y?TI&RuJSRL<#6Rdq0SJjS> zch-~FIluEgsnq$#&P>Q#Vn_Qu!<;;|NV84Sg?3rPPVlbt=279-j8?mi*EWW|o!yIF z?~^aTp1_;P=sku6`~1o_`ncVy59Vw6giXMbbcTTcnkKFfT4K?M0#Wm%fj;~p8ZL-d zHxIh<&8ajwjL}L@gxg2MlR)m83!cs9%>lM@d&Dm<^Z|#M`_%Fk(Y}k zVQjM31oI>Ck*K4D7k@FqVAu{rY6W48Qgcxhior`0!xAIT63|n?$nw5(dW+}}gYTh5 z+5d9sz6DI=_0>&AyVV<6*6pTF8li>!@UO`Fi6gYO3llQh@D73qnkQF_n*$ayrXO6hO=DgI9+A*Y@X2Ov<;Q(Y^Y5hQq{(;y(5c_`zu?0N&^@Ss`q@sU&EE7S@|A1IT$)bM}U^SL1hB5~}%l>Hm4~SKz zkjx

    i^qgiTSd_=~l83%GUYfu>?+-!~@aACt9kNtL+6d!elj!7E;`P?{J{iAw+k`OGIZF%z0<0L`4%dNB-2IH6EKdMp;gR}t z%78}c=avWLsh39SM2<9=7+6iZ45u+Z$5J;q3_Fyvq%ASPHT}gE{kUTcxT2-zqx|YE zDZ)Y8A=@#4$8t87_7=1gk9YV3KVFLKut-Esh}JAlNnxcZfl^%>NFMO4%pw?!X$##A zQh19~;=`@>GQ{(NMk#6sT&aQXv=(}b^XfxhDl1^Y$~`q}mBRjry_%mFbT(9)AB7(b zG)kF%0FBZkZmZ%@BkQH&40jn~U^PjGsH`Bxny|d2B;!i~a(=~8Wn()S{gV{-=hJ55{i=7qViZ=5^ zIBTO~Vgt1wv{>2~90n{hy+;eLWXN02P(AOgu8DNen$TZhPPjt9Kkq?0lD+8DeEsuV zH!QOi;ELvcava3u=W`lDK#HmthbI`m7^ZMXywWEr1+i5x#$?Tn=_Mn^57TvyI2SSP zCtqc;2g0~a*6dDR&mcY^HYM_JzrQr$)NyMx74N0H`79ue#5wzwSnzfcM>?8wSldC~ zY1y5ry5ftD|Eb%6xsAkKpbfv>?aFhr-fEHb0Fu+H`*tL^jN3j~es|3;H`;bS9!;Ts zE8&!gr=54A+aqIwcADY)d&uV>iwOojc6%AZ6AxPyA2!^{X`Kl{wk!_A^ zD&f6PN~V$NcVX5PyvsUfI-hd8!5a;eDG0sPXXLLPe56O$MzXFZxF0!=W^v$s>lcqb z1+{%G48GhID&^K&PDR4^-1FeXKKJa`&X_;pgr7hAT+D!rPrt5_G(0}erwXgxvdca^ zrlLPE4yu7EzPTfMB>qK*YWULx7Lp{b$K}v80MjQPk{z}P$vKWIG~yeKG)+I+)p#Hm zULM@9fPSna@n9r|d;|lU0lWk%f8<8PT3pBhQ@zSyE=)93aBHxrJQ8adE+08MZBYj~ zQ@kJNU>rs+CH59TOC#607A>bJj80dVT_>V-(1aa5fTxLu8guI&Eb`aK`28S~Bp^<6 zxqMAO6QOZkFj>^OIIXH+b`$s#3}>ml}^)YWQ|kWoL$5)LI@jN~wzx zV6fzzUkPzkXv7MBXN6#*74H+(FmP-(YskN90gh;Cp?+epmn>fU>UGzWd5dDr&A!fk zcgNmCgkd{4y;kosGD8f}?BE`z?knzyKVu+t0}YcPPEDe6P! zviC11-zEi2z(z5!$2^_(?&dvl5qk9@CM1yMvjfDPysM5!DMhuVU0Ew z;(?S<0J-9FytTHid6U)$uM`*XKb;kn@criNapMJofe!!o8z`yGUTx{YCct9{}kJjaj(u2rlvZU&bhtROut=C8Y6@(3+-=gRTjv9*>ZYUC^RIkMng=)QS^K4 zQ=?d}H-hM?334&OAA*-`!kv7$#qnkAllyFiGvvawI`W1QsHmE>Aj+jNFLrq&0AruP z<$bRm84jlnyPfe!8at4^Y8CF(zrMu1nr*+Cs4G#a)F-O{MjloLT;9uj-@Tb-vVJ*) zx|doYf~mx814W*1oBC52=&zn+h>N8IhfsRu7jxh}sETQzAJCqg{|p{5+3N(nt!^;z ziNNLkeR||TE|9&h6AJI>w7z&-84&PZ#y+vj&_6!nl>rr1^Ps$sND?42xM?e~_`+RK*Hwl%QjWa38emkMC3-!g@>Ff`z8 zb-@C%S2?d@VC++3Wg&6GAGE82rGJQ-tnGeiCas&nMQNw*_n?-7mQ1UYX5K=ZnNeJS zNUiD^!?BlDH5R^~qkjbG*OeaO4)UbnWe)PG^R;sG!{&lB3ZmJD%XH$^`L>Eu%sX(4 zq`!tklw>^bT9qk4^K6xℑ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

    k6}_Vix9s0?Z- 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 z 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 literal 0 HcmV?d00001 From dbea93f9babf46562921c0b0471aaf203c86b05b Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Tue, 29 Jul 2025 21:35:30 +0530 Subject: [PATCH 114/226] Improved Code Quality --- client/src/Hooks/monitorHooks.js | 36 +++++++++---------- .../src/Pages/Infrastructure/Create/index.jsx | 27 ++++++++++---- .../Settings/SettingsGlobalThresholds.jsx | 20 ++++++----- server/src/db/models/AppSettings.js | 12 +++---- server/src/validation/joi.js | 15 ++++---- 5 files changed, 63 insertions(+), 47 deletions(-) diff --git a/client/src/Hooks/monitorHooks.js b/client/src/Hooks/monitorHooks.js index 767ef5af8..1d57ffd5f 100644 --- a/client/src/Hooks/monitorHooks.js +++ b/client/src/Hooks/monitorHooks.js @@ -301,29 +301,27 @@ const useCreateMonitor = () => { }; const useFetchGlobalSettings = () => { - const [isLoading, setIsLoading] = useState(true); - const [globalSettings, setGlobalSettings] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [globalSettings, setGlobalSettings] = useState(null); + useEffect(() => { + const fetchGlobalSettings = async () => { + try { + const res = await networkService.getAppSettings(); + setGlobalSettings(res?.data); + } catch (error) { + console.error("Failed to fetch global settings:", error); + createToast({ body: "Failed to load global settings" }); + } finally { + setIsLoading(false); + } + }; - useEffect(() => { - const fetchGlobalSettings = async () => { - try { - const res = await networkService.getAppSettings(); - setGlobalSettings(res?.data); - } catch (error) { - console.error("Failed to fetch global settings:", error); - } finally { - setIsLoading(false); - } - }; + fetchGlobalSettings(); + }, []); - fetchGlobalSettings(); - }, []); - - return [globalSettings, isLoading]; + return [globalSettings, isLoading]; }; - - const useDeleteMonitor = () => { const [isLoading, setIsLoading] = useState(false); const navigate = useNavigate(); diff --git a/client/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx index 151383cfb..3159a3529 100644 --- a/client/src/Pages/Infrastructure/Create/index.jsx +++ b/client/src/Pages/Infrastructure/Create/index.jsx @@ -3,7 +3,6 @@ import { useTheme } from "@emotion/react"; import { useParams } from "react-router-dom"; import { useState, useEffect } from "react"; import { useSelector } from "react-redux"; -import axios from "axios"; // Utility and Network import { infrastructureMonitorValidation } from "../../../Validation/validation"; import { useFetchHardwareMonitorById } from "../../../Hooks/monitorHooks"; @@ -95,25 +94,41 @@ const CreateInfrastructureMonitor = () => { useEffect(() => { if (!isCreate || globalSettingsLoading) return; - const globalThresholds = globalSettings?.data?.settings?.globalThresholds; + const globalThresholds = globalSettings?.data?.settings?.globalThresholds || {}; + + // Define default empty string for all thresholds + const defaultThresholds = { + cpu: "", + memory: "", + disk: "", + temperature: "", + }; setInfrastructureMonitor((prev) => ({ ...prev, cpu: globalThresholds.cpu != null, - usage_cpu: globalThresholds.cpu != null ? globalThresholds.cpu.toString() : "", + usage_cpu: + globalThresholds.cpu != null + ? globalThresholds.cpu.toString() + : defaultThresholds.cpu, memory: globalThresholds.memory != null, usage_memory: - globalThresholds.memory != null ? globalThresholds.memory.toString() : "", + globalThresholds.memory != null + ? globalThresholds.memory.toString() + : defaultThresholds.memory, disk: globalThresholds.disk != null, - usage_disk: globalThresholds.disk != null ? globalThresholds.disk.toString() : "", + usage_disk: + globalThresholds.disk != null + ? globalThresholds.disk.toString() + : defaultThresholds.disk, temperature: globalThresholds.temperature != null, usage_temperature: globalThresholds.temperature != null ? globalThresholds.temperature.toString() - : "", + : defaultThresholds.temperature, })); }, [isCreate, globalSettings]); diff --git a/client/src/Pages/Settings/SettingsGlobalThresholds.jsx b/client/src/Pages/Settings/SettingsGlobalThresholds.jsx index eca752688..cd1c50927 100644 --- a/client/src/Pages/Settings/SettingsGlobalThresholds.jsx +++ b/client/src/Pages/Settings/SettingsGlobalThresholds.jsx @@ -15,8 +15,8 @@ const SettingsGlobalThresholds = ({ settingsData, setSettingsData, }) => { - const { t } = useTranslation(); // For language translation - const theme = useTheme(); // MUI theme access + const { t } = useTranslation(); // For language translation + const theme = useTheme(); // MUI theme access // Local state for thresholds const [thresholds, setThresholds] = useState({ @@ -42,19 +42,18 @@ const SettingsGlobalThresholds = ({ const handleChange = (e, min, max) => { const { name, value } = e.target; - // Allow empty or numeric value within range - if ( + const numValue = parseFloat(value); + const isValidNumber = value === "" || - (/^\d+$/.test(value) && Number(value) >= min && Number(value) <= max) - ) { + (!isNaN(numValue) && isFinite(numValue) && numValue >= min && numValue <= max); + + if (isValidNumber) { const updatedThresholds = { ...thresholds, [name]: value, }; setThresholds(updatedThresholds); - - // Update settingsData without enabling/disabling metrics setSettingsData({ ...settingsData, settings: { @@ -72,7 +71,10 @@ const SettingsGlobalThresholds = ({ {/* Header and description */} - + {t("settingsPage.globalThresholds.title", "Global Thresholds")} diff --git a/server/src/db/models/AppSettings.js b/server/src/db/models/AppSettings.js index be7c856a4..2313d638c 100755 --- a/server/src/db/models/AppSettings.js +++ b/server/src/db/models/AppSettings.js @@ -66,12 +66,12 @@ const AppSettingsSchema = mongoose.Schema( default: 1, }, globalThresholds: { - cpu: { type: Number }, - memory: { type: Number}, - disk: { type: Number}, - temperature: { type: Number}, - } -}, + cpu: { type: Number }, + memory: { type: Number }, + disk: { type: Number }, + temperature: { type: Number }, + }, + }, { timestamps: true, } diff --git a/server/src/validation/joi.js b/server/src/validation/joi.js index 51d8a94a2..f2c15205b 100755 --- a/server/src/validation/joi.js +++ b/server/src/validation/joi.js @@ -417,15 +417,16 @@ const updateAppSettingsBodyValidation = joi.object({ systemEmailRequireTLS: joi.boolean(), systemEmailRejectUnauthorized: joi.boolean(), - globalThresholds: joi.object({ - cpu: joi.number().min(1).max(100).allow("").optional(), - memory: joi.number().min(1).max(100).allow("").optional(), - disk: joi.number().min(1).max(100).allow("").optional(), - temperature: joi.number().min(1).max(150).allow("").optional(), - }).optional(), + globalThresholds: joi + .object({ + cpu: joi.number().min(1).max(100).allow("").optional(), + memory: joi.number().min(1).max(100).allow("").optional(), + disk: joi.number().min(1).max(100).allow("").optional(), + temperature: joi.number().min(1).max(150).allow("").optional(), + }) + .optional(), }); - //**************************************** // Status Page Validation //**************************************** From 10c822c5d9f359994c2c2576b098e8c5641d1457 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 29 Jul 2025 10:01:20 -0700 Subject: [PATCH 115/226] add legacy logger --- server/src/config/services.js | 1 - server/src/db/mongo/modules/checkModule.js | 2 +- server/src/db/mongo/modules/hardwareCheckModule.js | 2 +- server/src/db/mongo/utils/seedDb.js | 2 +- server/src/middleware/handleErrors.js | 2 +- server/src/middleware/languageMiddleware.js | 2 +- server/src/middleware/verifyOwnership.js | 2 +- server/src/shutdown.js | 2 +- server/src/utils/logger.js | 4 ++++ 9 files changed, 11 insertions(+), 8 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index 93327befc..73955a3b4 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -1,5 +1,4 @@ import ServiceRegistry from "../service/system/serviceRegistry.js"; -import logger from "../utils/logger.js"; import TranslationService from "../service/system/translationService.js"; import StringService from "../service/system/stringService.js"; import MongoDB from "../db/mongo/MongoDB.js"; diff --git a/server/src/db/mongo/modules/checkModule.js b/server/src/db/mongo/modules/checkModule.js index 731d168bf..18c3d45ec 100755 --- a/server/src/db/mongo/modules/checkModule.js +++ b/server/src/db/mongo/modules/checkModule.js @@ -3,7 +3,7 @@ import Monitor from "../../models/Monitor.js"; import HardwareCheck from "../../models/HardwareCheck.js"; import PageSpeedCheck from "../../models/PageSpeedCheck.js"; import User from "../../models/User.js"; -import logger from "../../../utils/logger.js"; +import { logger } from "../../../utils/logger.js"; import { ObjectId } from "mongodb"; import { buildChecksSummaryByTeamIdPipeline } from "./checkModuleQueries.js"; diff --git a/server/src/db/mongo/modules/hardwareCheckModule.js b/server/src/db/mongo/modules/hardwareCheckModule.js index bfb9b0730..9cb228bc4 100755 --- a/server/src/db/mongo/modules/hardwareCheckModule.js +++ b/server/src/db/mongo/modules/hardwareCheckModule.js @@ -1,6 +1,6 @@ import HardwareCheck from "../../models/HardwareCheck.js"; import Monitor from "../../models/Monitor.js"; -import logger from "../../../utils/logger.js"; +import { logger } from "../../../utils/logger.js"; const SERVICE_NAME = "hardwareCheckModule"; const createHardwareCheck = async (hardwareCheckData) => { diff --git a/server/src/db/mongo/utils/seedDb.js b/server/src/db/mongo/utils/seedDb.js index 64f23178c..224744a51 100755 --- a/server/src/db/mongo/utils/seedDb.js +++ b/server/src/db/mongo/utils/seedDb.js @@ -1,6 +1,6 @@ import Monitor from "../../models/Monitor.js"; import Check from "../../models/Check.js"; -import logger from "../../../utils/logger.js"; +import { logger } from "../../../utils/logger.js"; const generateRandomUrl = () => { const domains = ["example.com", "test.org", "demo.net", "sample.io", "mock.dev"]; diff --git a/server/src/middleware/handleErrors.js b/server/src/middleware/handleErrors.js index 782d37ad9..b0ded5ab7 100755 --- a/server/src/middleware/handleErrors.js +++ b/server/src/middleware/handleErrors.js @@ -1,4 +1,4 @@ -import logger from "../utils/logger.js"; +import { logger } from "../utils/logger.js"; import ServiceRegistry from "../service/system/serviceRegistry.js"; import StringService from "../service/system/stringService.js"; diff --git a/server/src/middleware/languageMiddleware.js b/server/src/middleware/languageMiddleware.js index effd49cc7..6293dc837 100755 --- a/server/src/middleware/languageMiddleware.js +++ b/server/src/middleware/languageMiddleware.js @@ -1,4 +1,4 @@ -import logger from "../utils/logger.js"; +import { logger } from "../utils/logger.js"; const languageMiddleware = (stringService, translationService) => async (req, res, next) => { try { diff --git a/server/src/middleware/verifyOwnership.js b/server/src/middleware/verifyOwnership.js index 890c40b24..4431b0c26 100755 --- a/server/src/middleware/verifyOwnership.js +++ b/server/src/middleware/verifyOwnership.js @@ -1,4 +1,4 @@ -import logger from "../utils/logger.js"; +import { logger } from "../utils/logger.js"; import ServiceRegistry from "../service/system/serviceRegistry.js"; import StringService from "../service/system/stringService.js"; import { ObjectId } from "mongodb"; diff --git a/server/src/shutdown.js b/server/src/shutdown.js index b9b2b53c5..27eb0f21d 100644 --- a/server/src/shutdown.js +++ b/server/src/shutdown.js @@ -1,4 +1,4 @@ -import logger from "./utils/logger.js"; +import { logger } from "./utils/logger.js"; export const initShutdownListener = (server, services) => { const SERVICE_NAME = "Server"; diff --git a/server/src/utils/logger.js b/server/src/utils/logger.js index 46b311783..0917862fa 100755 --- a/server/src/utils/logger.js +++ b/server/src/utils/logger.js @@ -147,3 +147,7 @@ class Logger { } export default Logger; + +// Legacy logger +const logger = new Logger({ envSettings: { logLevel: "debug" } }); +export { logger }; From e63faebcb8a8d7190a92a2c2ad005d1ca96939b5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 29 Jul 2025 10:08:45 -0700 Subject: [PATCH 116/226] replace missing secrets --- server/src/service/system/settingsService.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/service/system/settingsService.js b/server/src/service/system/settingsService.js index 4ad30c6b1..70d8d192c 100755 --- a/server/src/service/system/settingsService.js +++ b/server/src/service/system/settingsService.js @@ -1,6 +1,9 @@ const SERVICE_NAME = "SettingsService"; const envConfig = { + jwtSecret: process.env.JWT_SECRET, + jwtTTL: process.env.TOKEN_TTL, + systemEmailHost: process.env.SYSTEM_EMAIL_HOST, nodeEnv: process.env.NODE_ENV, logLevel: process.env.LOG_LEVEL, clientHost: process.env.CLIENT_HOST, From 20dd3ead3e9c362244ba636e0decf6634801e49f Mon Sep 17 00:00:00 2001 From: karenvicent Date: Tue, 29 Jul 2025 13:43:47 -0400 Subject: [PATCH 117/226] change background and boder colors to tertiry pallette --- client/src/Components/ConfigBox/index.jsx | 2 +- .../Components/Fallback/FallbackContainer.jsx | 4 ++-- client/src/Utils/Theme/constants.js | 18 ++++++++---------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/client/src/Components/ConfigBox/index.jsx b/client/src/Components/ConfigBox/index.jsx index 0586234af..01b35729e 100644 --- a/client/src/Components/ConfigBox/index.jsx +++ b/client/src/Components/ConfigBox/index.jsx @@ -20,7 +20,7 @@ const ConfigBox = styled(Stack)(({ theme }) => ({ borderRightColor: theme.palette.primary.lowContrast, paddingRight: theme.spacing(15), paddingLeft: theme.spacing(15), - backgroundColor: theme.palette.container.background, + backgroundColor: theme.palette.tertiary.background, "& :is(h1, h2):first-of-type": { fontWeight: 600, marginBottom: theme.spacing(4), diff --git a/client/src/Components/Fallback/FallbackContainer.jsx b/client/src/Components/Fallback/FallbackContainer.jsx index 12711701b..7a2ec5fe0 100644 --- a/client/src/Components/Fallback/FallbackContainer.jsx +++ b/client/src/Components/Fallback/FallbackContainer.jsx @@ -7,9 +7,9 @@ const FallbackContainer = ({ children, type }) => { return ( Date: Tue, 29 Jul 2025 11:14:06 -0700 Subject: [PATCH 118/226] sort en.json --- client/src/locales/en.json | 1620 ++++++++++++++++++------------------ 1 file changed, 810 insertions(+), 810 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 78d0f2aed..5920a0e53 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1,540 +1,29 @@ { - "submit": "Submit", - "title": "Title", - "distributedStatusHeaderText": "Real-time, real-device coverage", - "distributedStatusSubHeaderText": "Powered by millions devices worldwide, view a system performance by global region, country or city", - "settingsDisabled": "Disabled", - "settingsSuccessSaved": "Settings saved successfully", - "settingsFailedToSave": "Failed to save settings", - "settingsStatsCleared": "Stats cleared successfully", - "settingsFailedToClearStats": "Failed to clear stats", - "settingsMonitorsDeleted": "Successfully deleted all monitors", - "settingsFailedToDeleteMonitors": "Failed to delete all monitors", - "starPromptTitle": "Star Checkmate", - "starPromptDescription": "See the latest releases and help grow the community on GitHub", - "https": "HTTPS", - "http": "HTTP", - "monitor": "monitor", - "aboutus": "About Us", - "now": "Now", - "delete": "Delete", - "configure": "Configure", - "responseTime": "Response time", - "ms": "ms", - "bar": "Bar", - "area": "Area", - "country": "COUNTRY", - "city": "CITY", - "response": "RESPONSE", - "monitorStatusUp": "Monitor {name} ({url}) is now UP and responding", - "monitorStatusDown": "Monitor {name} ({url}) is DOWN and not responding", - "webhookSendSuccess": "Webhook notification sent successfully", - "webhookSendError": "Error sending webhook notification to {platform}", - "webhookUnsupportedPlatform": "Unsupported platform: {platform}", - "distributedRightCategoryTitle": "Monitor", - "distributedStatusServerMonitors": "Server Monitors", - "distributedStatusServerMonitorsDescription": "Monitor status of related servers", - "distributedUptimeCreateSelectURL": "Here you can select the URL of the host, together with the type of monitor.", - "distributedUptimeCreateChecks": "Checks to perform", - "distributedUptimeCreateChecksDescription": "You can always add or remove checks after adding your site.", - "distributedUptimeCreateIncidentNotification": "Incident notifications", - "distributedUptimeCreateIncidentDescription": "When there is an incident, notify users.", - "distributedUptimeCreateAdvancedSettings": "Advanced settings", - "distributedUptimeDetailsNoMonitorHistory": "There is no check history for this monitor yet.", - "distributedUptimeDetailsStatusHeaderUptime": "Uptime:", - "distributedUptimeDetailsStatusHeaderLastUpdate": "Last updated", - "notifications": { - "enableNotifications": "Enable {{platform}} notifications", - "testNotification": "Test notification", - "addOrEditNotifications": "Add or edit notifications", - "slack": { - "label": "Slack", - "description": "To enable Slack notifications, create a Slack app and enable incoming webhooks. After that, simply provide the webhook URL here.", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "Slack webhook URL is required" - }, - "discord": { - "label": "Discord", - "description": "To send data to a Discord channel from Checkmate via Discord notifications using webhooks, you can use Discord's incoming Webhooks feature.", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "Discord webhook URL is required" - }, - "telegram": { - "label": "Telegram", - "description": "To enable Telegram notifications, create a Telegram bot using BotFather, an official bot for creating and managing Telegram bots. Then, get the API token and chat ID and write them down here.", - "tokenLabel": "Your bot token", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "Your Chat ID", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "Telegram token and chat ID are required" - }, - "webhook": { - "label": "Webhooks", - "description": "You can set up a custom webhook to receive notifications when incidents occur.", - "urlLabel": "Webhook URL", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "Webhook URL is required" - }, - "testNotificationDevelop": "Test notification 2", - "integrationButton": "Notification Integration", - "testSuccess": "Test notification sent successfully!", - "testFailed": "Failed to send test notification", - "unsupportedType": "Unsupported notification type", - "networkError": "Network error occurred", - "fallback": { - "title": "A notification channel is used to:", - "checks": [ - "Alert teams about downtime or performance issues", - "Let engineers know when incidents happen", - "Keep administrators informed of system changes" - ], - "actionButton": "Let's create your first notification channel!" - }, - "createButton": "Create notification channel", - "createTitle": "Notification channel", - "create": { - "success": "Notification created successfully", - "failed": "Failed to create notification" - }, - "fetch": { - "success": "Notifications fetched successfully", - "failed": "Failed to fetch notifications" - }, - "delete": { - "success": "Notification deleted successfully", - "failed": "Failed to delete notification" - }, - "edit": { - "success": "Notification updated successfully", - "failed": "Failed to update notification" - }, - "test": { - "success": "Test notification sent successfully", - "failed": "Failed to send test notification" - } - }, - "testLocale": "testLocale", - "add": "Add", - "monitors": "monitors", - "distributedUptimeStatusCreateStatusPage": "status page", - "distributedUptimeStatusCreateStatusPageAccess": "Access", - "distributedUptimeStatusCreateStatusPageReady": "If your status page is ready, you can mark it as published.", - "distributedUptimeStatusBasicInfoHeader": "Basic Information", - "distributedUptimeStatusBasicInfoDescription": "Define company name and the subdomain that your status page points to.", - "distributedUptimeStatusLogoHeader": "Logo", - "distributedUptimeStatusLogoDescription": "Upload a logo for your status page", - "distributedUptimeStatusLogoUploadButton": "Upload logo", - "distributedUptimeStatusStandardMonitorsHeader": "Standard Monitors", - "distributedUptimeStatusStandardMonitorsDescription": "Attach standard monitors to your status page.", - "distributedUptimeStatusCreateYour": "Create your", - "distributedUptimeStatusEditYour": "Edit your", - "distributedUptimeStatusPublishedLabel": "Published and visible to the public", - "distributedUptimeStatusCompanyNameLabel": "Company name", - "distributedUptimeStatusPageAddressLabel": "Your status page address", - "distributedUptimeStatus30Days": "30 days", - "distributedUptimeStatus60Days": "60 days", - "distributedUptimeStatus90Days": "90 days", - "distributedUptimeStatusPageNotSetUp": "A status page is not set up.", - "distributedUptimeStatusContactAdmin": "Please contact your administrator", - "distributedUptimeStatusPageNotPublic": "This status page is not public.", - "distributedUptimeStatusPageDeleteDialog": "Do you want to delete this status page?", - "distributedUptimeStatusPageDeleteConfirm": "Yes, delete status page", - "distributedUptimeStatusPageDeleteDescription": "Once deleted, your status page cannot be retrieved.", - "distributedUptimeStatusDevices": "Devices", - "distributedUptimeStatusUpt": "UPT", - "distributedUptimeStatusUptBurned": "UPT Burned", - "distributedUptimeStatusUptLogo": "Upt Logo", - "incidentsTableNoIncidents": "No incidents recorded", - "incidentsTablePaginationLabel": "incidents", - "incidentsTableMonitorName": "Monitor Name", - "incidentsTableStatus": "Status", - "incidentsTableDateTime": "Date & Time", - "incidentsTableStatusCode": "Status Code", - "incidentsTableMessage": "Message", - "incidentsOptionsHeader": "Incidents for:", - "incidentsOptionsHeaderFilterBy": "Filter by:", - "incidentsOptionsHeaderFilterAll": "All", - "incidentsOptionsHeaderFilterDown": "Down", - "incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve", - "incidentsOptionsHeaderShow": "Show:", - "incidentsOptionsHeaderLastHour": "Last hour", - "incidentsOptionsHeaderLastDay": "Last day", - "incidentsOptionsHeaderLastWeek": "Last week", - "incidentsOptionsPlaceholderAllServers": "All servers", - "infrastructureCreateYour": "Create your", - "infrastructureCreateGeneralSettingsDescription": "Here you can select the URL of the host, together with the friendly name and authorization secret to connect to the server agent.", - "infrastructureServerRequirement": "The server you are monitoring must be running the", - "infrastructureCustomizeAlerts": "Customize alerts", - "infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.", - "infrastructureCreateMonitor": "Create Infrastructure Monitor", - "infrastructureProtocol": "Protocol", - "infrastructureServerUrlLabel": "Server URL", - "infrastructureDisplayNameLabel": "Display name", - "infrastructureAuthorizationSecretLabel": "Authorization secret", - "gb": "GB", - "mb": "MB", - "mem": "Mem", - "memoryUsage": "Memory usage", - "cpu": "CPU", - "cpuUsage": "CPU usage", - "cpuTemperature": "CPU Temperature", - "diskUsage": "Disk Usage", - "used": "Used", - "total": "Total", - "cores": "Cores", - "frequency": "Frequency", - "status": "Status", - "cpuPhysical": "CPU (Physical)", - "cpuLogical": "CPU (Logical)", - "cpuFrequency": "CPU Frequency", - "avgCpuTemperature": "Average CPU Temperature", - "memory": "Memory", - "disk": "Disk", - "uptime": "Uptime", - "os": "OS", - "host": "Host", - "actions": "Actions", - "integrations": "Integrations", - "integrationsPrism": "Connect Prism to your favorite service.", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "Connect with Slack and see incidents in a channel", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "Connect with Discord and view incidents directly in a channel", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "Send all incidents to Zapier, and then see them everywhere", - "commonSave": "Save", - "createYour": "Create your", - "createMonitor": "Create monitor", - "pause": "Pause", - "resume": "Resume", - "editing": "Editing...", - "url": "URL", - "access": "Access", - "timezone": "Timezone", - "features": "Features", - "administrator": "Administrator?", - "loginHere": "Login here", - "displayName": "Display name", - "urlMonitor": "URL to monitor", - "portToMonitor": "Port to monitor", - "websiteMonitoring": "Website monitoring", - "websiteMonitoringDescription": "Use HTTP(s) to monitor your website or API endpoint.", - "pingMonitoring": "Ping monitoring", - "pingMonitoringDescription": "Check whether your server is available or not.", - "dockerContainerMonitoring": "Docker container monitoring", - "dockerContainerMonitoringDescription": "Check whether your Docker container is running or not.", - "portMonitoring": "Port monitoring", - "portMonitoringDescription": "Check whether your port is open or not.", - "createMaintenanceWindow": "Create maintenance window", - "createMaintenance": "Create maintenance", - "editMaintenance": "Edit maintenance", - "maintenanceWindowName": "Maintenance Window Name", - "friendlyNameInput": "Friendly name", - "friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes", - "maintenanceRepeat": "Maintenance Repeat", - "maintenance": "maintenance", - "duration": "Duration", - "addMonitors": "Add monitors", - "window": "window", - "cancel": "Cancel", - "message": "Message", - "low": "low", - "high": "high", - "statusCode": "Status code", - "date&Time": "Date & Time", - "type": "Type", - "statusPageName": "Status page name", - "publicURL": "Public URL", - "repeat": "Repeat", - "edit": "Edit", - "createA": "Create a", - "remove": "Remove", - "maintenanceWindowDescription": "Your pings won't be sent during this time frame", - "startTime": "Start time", - "timeZoneInfo": "All dates and times are in GMT+0 time zone.", - "monitorsToApply": "Monitors to apply maintenance window to", - "nextWindow": "Next window", - "notFoundButton": "Go to the main dashboard", - "pageSpeedConfigureSettingsDescription": "Here you can select the URL of the host, together with the type of monitor.", - "monitorDisplayName": "Monitor display name", - "whenNewIncident": "When there is a new incident,", - "notifySMS": "Notify via SMS (coming soon)", - "notifyEmails": "Also notify via email to multiple addresses (coming soon)", - "seperateEmails": "You can separate multiple emails with a comma", - "checkFrequency": "Check frequency", - "matchMethod": "Match Method", - "expectedValue": "Expected value", - "deleteDialogTitle": "Do you really want to delete this monitor?", - "deleteDialogDescription": "Once deleted, this monitor cannot be retrieved.", - "pageSpeedMonitor": "PageSpeed monitor", - "shown": "Shown", - "ago": "ago", - "companyName": "Company name", - "pageSpeedDetailsPerformanceReport": "Values are estimated and may vary.", - "pageSpeedDetailsPerformanceReportCalculator": "See calculator", - "checkingEvery": "Checking every", - "statusPageCreateSettings": "If your status page is ready, you can mark it as published.", - "basicInformation": "Basic Information", - "statusPageCreateBasicInfoDescription": "Define company name and the subdomain that your status page points to.", - "statusPageCreateSelectTimeZoneDescription": "Select the timezone that your status page will be displayed in.", - "statusPageCreateAppearanceDescription": "Define the default look and feel of your public status page.", - "statusPageCreateSettingsCheckboxLabel": "Published and visible to the public", - "statusPageCreateBasicInfoStatusPageAddress": "Your status page address", - "statusPageCreateTabsContent": "Status page servers", - "statusPageCreateTabsContentDescription": "You can add any number of servers that you monitor to your status page. You can also reorder them for the best viewing experience.", - "statusPageCreateTabsContentFeaturesDescription": "Show more details on the status page", - "showCharts": "Show charts", - "showUptimePercentage": "Show uptime percentage", - "removeLogo": "Remove Logo", - "statusPageStatus": "A public status page is not set up.", - "statusPageStatusContactAdmin": "Please contact to your administrator", - "statusPageStatusNotPublic": "This status page is not public.", - "statusPageStatusNoPage": "There's no status page here.", - "statusPageStatusServiceStatus": "Service status", - "deleteStatusPage": "Do you want to delete this status page?", - "deleteStatusPageConfirm": "Yes, delete status page", - "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", - "uptimeCreate": "The expected value is used to match against response result, and the match determines the status.", - "uptimeCreateJsonPath": "This expression will be evaluated against the reponse JSON data and the result will be used to match against the expected value. See", - "uptimeCreateJsonPathQuery": "for query language documentation.", - "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", - "infrastructureEditYour": "Edit your", - "infrastructureEditMonitor": "Save Infrastructure Monitor", - "infrastructureMonitorCreated": "Infrastructure monitor created successfully!", - "infrastructureMonitorUpdated": "Infrastructure monitor updated successfully!", - "errorInvalidTypeId": "Invalid notification type provided", - "errorInvalidFieldId": "Invalid field ID provided", - "inviteNoTokenFound": "No invite token found", - "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", - "pageSpeedLearnMoreLink": "Click here", - "pageSpeedAddApiKey": "to add your API key.", - "update": "Update", - "invalidFileFormat": "Unsupported file format!", - "invalidFileSize": "File size is too large!", "ClickUpload": "Click to upload", + "DeleteAccountButton": "Remove account", + "DeleteAccountTitle": "Remove account", + "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", + "DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.", + "DeleteWarningTitle": "Really remove this account?", "DragandDrop": "drag and drop", - "MaxSize": "Maximum Size", - "SupportedFormats": "Supported formats", + "EmailDescriptionText": "This is your current email address — it cannot be changed.", "FirstName": "First name", "LastName": "Last name", - "EmailDescriptionText": "This is your current email address — it cannot be changed.", - "YourPhoto": "Profile photo", + "MaxSize": "Maximum Size", "PhotoDescriptionText": "This photo will be displayed in your profile page.", - "save": "Save", - "DeleteDescriptionText": "This will remove the account and all associated data from the server. This isn't reversible.", - "DeleteAccountWarning": "Removing your account means you won't be able to sign in again and all your data will be removed. This isn't reversible.", - "DeleteWarningTitle": "Really remove this account?", - "bulkImport": { - "title": "Bulk Import", - "selectFileTips": "Select CSV file to upload", - "selectFileDescription": "You can download our or sample", - "selectFile": "Select File", - "parsingFailed": "Parsing failed", - "uploadSuccess": "Monitors created successfully!", - "validationFailed": "Validation failed", - "noFileSelected": "No file selected", - "fallbackPage": "Import a file to upload a list of servers in bulk", - "invalidFileType": "Invalid file type", - "uploadFailed": "Upload failed" - }, - "DeleteAccountTitle": "Remove account", - "DeleteAccountButton": "Remove account", - "publicLink": "Public link", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "reset": "Reset", - "ignoreTLSError": "Ignore TLS/SSL error", - "tlsErrorIgnored": "TLS/SSL errors ignored", - "ignoreTLSErrorDescription": "Ignore TLS/SSL errors and continue checking the website's availability", - "createNew": "Create new", - "greeting": { - "prepend": "Hey there", - "append": "The afternoon is your playground—let's make it epic!", - "overview": "Here's an overview of your {{type}} monitors." - }, - "roles": { - "superAdmin": "Super admin", - "admin": "Admin", - "teamMember": "Team member", - "demoUser": "Demo user" - }, - "teamPanel": { - "teamMembers": "Team members", - "filter": { - "all": "All", - "member": "Member" - }, - "inviteTeamMember": "Invite a team member", - "inviteNewTeamMember": "Invite new team member", - "inviteDescription": "When you add a new team member, they will get access to all monitors.", - "email": "Email", - "selectRole": "Select role", - "inviteLink": "Invite link", - "cancel": "Cancel", - "noMembers": "There are no team members with this role", - "getToken": "Get token", - "emailToken": "E-mail token", - "table": { - "name": "Name", - "email": "Email", - "role": "Role", - "created": "Created" - } - }, - "monitorState": { - "paused": "Paused", - "resumed": "Resumed", - "active": "Active" - }, - "menu": { - "uptime": "Uptime", - "pagespeed": "Pagespeed", - "infrastructure": "Infrastructure", - "incidents": "Incidents", - "statusPages": "Status pages", - "maintenance": "Maintenance", - "integrations": "Integrations", - "settings": "Settings", - "support": "Support", - "discussions": "Discussions", - "docs": "Docs", - "changelog": "Changelog", - "profile": "Profile", - "password": "Password", - "team": "Team", - "logOut": "Log out", - "notifications": "Notifications", - "logs": "Logs" - }, - "settingsEmailUser": "Email user - Username for authentication, overrides email address if specified", - "state": "State", - "statusBreadCrumbsStatusPages": "Status Pages", - "statusBreadCrumbsDetails": "Details", - "commonSaving": "Saving...", - "navControls": "Controls", - "incidentsPageTitle": "Incidents", - "passwordPanel": { - "passwordChangedSuccess": "Your password was changed successfully.", - "passwordInputIncorrect": "Your password input was incorrect.", - "currentPassword": "Current password", - "enterCurrentPassword": "Enter your current password", - "newPassword": "New password", - "enterNewPassword": "Enter your new password", - "confirmNewPassword": "Confirm new password", - "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character.", - "saving": "Saving..." - }, - "emailSent": "Email sent successfully", - "failedToSendEmail": "Failed to send email", - "settingsTestEmailSuccess": "Test email sent successfully", - "settingsTestEmailFailed": "Failed to send test email", - "settingsTestEmailFailedWithReason": "Failed to send test email: {{reason}}", - "settingsTestEmailUnknownError": "Unknown error", - "statusMsg": { - "paused": "Monitoring is paused.", - "up": "Your site is up.", - "down": "Your site is down.", - "pending": "Pending..." - }, - "uptimeGeneralInstructions": { - "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", - "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", - "docker": "Enter the Docker ID of your container. Docker IDs must be the full 64 char Docker ID. You can run docker inspect to get the full container ID.", - "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard." - }, - "common": { - "appName": "Checkmate", - "monitoringAgentName": "Capture", - "buttons": { - "toggleTheme": "Toggles light & dark" - }, - "toasts": { - "networkError": "Network error", - "checkConnection": "Please check your connection", - "unknownError": "Unknown error" - } - }, + "SupportedFormats": "Supported formats", + "YourPhoto": "Profile photo", + "aboutus": "About Us", + "access": "Access", + "actions": "Actions", + "add": "Add", + "addMonitors": "Add monitors", + "administrator": "Administrator?", + "advancedMatching": "Advanced matching", + "ago": "ago", + "area": "Area", "auth": { "common": { - "navigation": { - "continue": "Continue", - "back": "Back" - }, - "inputs": { - "email": { - "label": "Email", - "placeholder": "jordan.ellis@domain.com", - "errors": { - "empty": "To continue, please enter your email address", - "invalid": "Please recheck validity of entered email address" - } - }, - "password": { - "label": "Password", - "rules": { - "length": { - "beginning": "Must be at least", - "highlighted": "8 characters long" - }, - "special": { - "beginning": "Must contain at least", - "highlighted": "one special character" - }, - "number": { - "beginning": "Must contain at least", - "highlighted": "one number" - }, - "uppercase": { - "beginning": "Must contain at least", - "highlighted": "one upper character" - }, - "lowercase": { - "beginning": "Must contain at least", - "highlighted": "one lower character" - }, - "match": { - "beginning": "Confirm password and password", - "highlighted": "must match" - } - }, - "errors": { - "empty": "Please enter your password", - "length": "Password must be at least 8 characters long", - "uppercase": "Password must contain at least 1 uppercase letter", - "lowercase": "Password must contain at least 1 lowercase letter", - "number": "Password must contain at least 1 number", - "special": "Password must contain at least 1 special character", - "incorrect": "The password you provided does not match our records" - } - }, - "passwordConfirm": { - "label": "Confirm password", - "placeholder": "Re-enter password to confirm", - "errors": { - "empty": "Please enter your password again for confirmation (helps with typos)", - "different": "Entered passwords don't match, so one of them is probably mistyped" - } - }, - "firstName": { - "label": "Name", - "placeholder": "Jordan", - "errors": { - "empty": "Please enter your name", - "length": "Name must be less than 50 characters", - "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" - } - }, - "lastName": { - "label": "Surname", - "placeholder": "Ellis", - "errors": { - "empty": "Please enter your surname", - "length": "Surname must be less than 50 characters", - "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" - } - } - }, "errors": { "validation": "Error validating data." }, @@ -549,40 +38,139 @@ "min": "At least one role is required" } } + }, + "inputs": { + "email": { + "errors": { + "empty": "To continue, please enter your email address", + "invalid": "Please recheck validity of entered email address" + }, + "label": "Email", + "placeholder": "jordan.ellis@domain.com" + }, + "firstName": { + "errors": { + "empty": "Please enter your name", + "length": "Name must be less than 50 characters", + "pattern": "Name must contain only letters, spaces, apostrophes, or hyphens" + }, + "label": "Name", + "placeholder": "Jordan" + }, + "lastName": { + "errors": { + "empty": "Please enter your surname", + "length": "Surname must be less than 50 characters", + "pattern": "Surname must contain only letters, spaces, apostrophes, or hyphens" + }, + "label": "Surname", + "placeholder": "Ellis" + }, + "password": { + "errors": { + "empty": "Please enter your password", + "incorrect": "The password you provided does not match our records", + "length": "Password must be at least 8 characters long", + "lowercase": "Password must contain at least 1 lowercase letter", + "number": "Password must contain at least 1 number", + "special": "Password must contain at least 1 special character", + "uppercase": "Password must contain at least 1 uppercase letter" + }, + "label": "Password", + "rules": { + "length": { + "beginning": "Must be at least", + "highlighted": "8 characters long" + }, + "lowercase": { + "beginning": "Must contain at least", + "highlighted": "one lower character" + }, + "match": { + "beginning": "Confirm password and password", + "highlighted": "must match" + }, + "number": { + "beginning": "Must contain at least", + "highlighted": "one number" + }, + "special": { + "beginning": "Must contain at least", + "highlighted": "one special character" + }, + "uppercase": { + "beginning": "Must contain at least", + "highlighted": "one upper character" + } + } + }, + "passwordConfirm": { + "errors": { + "different": "Entered passwords don't match, so one of them is probably mistyped", + "empty": "Please enter your password again for confirmation (helps with typos)" + }, + "label": "Confirm password", + "placeholder": "Re-enter password to confirm" + } + }, + "navigation": { + "back": "Back", + "continue": "Continue" + } + }, + "forgotPassword": { + "buttons": { + "openEmail": "Open email app", + "resetPassword": "Reset password" + }, + "heading": "Forgot password?", + "imageAlts": { + "email": "Email icon", + "lock": "Lock icon", + "passwordConfirm": "Password confirm icon", + "passwordKey": "Password key icon" + }, + "links": { + "login": "Go back to Log In", + "resend": "Didn't receive the email? Click to resend" + }, + "subheadings": { + "stepFour": "Your password has been successfully reset. Click below to log in magically.", + "stepOne": "No worries, we'll send you reset instructions.", + "stepThree": "Your new password must be different from previously used passwords.", + "stepTwo": "We sent a password reset link to " + }, + "toasts": { + "emailNotFound": "Email not found.", + "error": "Unable to reset password. Please try again later or contact support.", + "redirect": "Redirecting in ...", + "sent": "Instructions sent to .", + "success": "Your password was reset successfully." } }, "login": { - "heading": "Log In", - "subheadings": { - "stepOne": "Enter your email", - "stepTwo": "Enter your password" - }, - "links": { - "forgotPassword": "Forgot password?", - "register": "Do not have an account?", - "forgotPasswordLink": "Reset password", - "registerLink": "Register here" - }, - "toasts": { - "success": "Welcome back! You're successfully logged in.", - "incorrectPassword": "Incorrect password" - }, "errors": { "password": { "incorrect": "The password you provided does not match our records" } + }, + "heading": "Log In", + "links": { + "forgotPassword": "Forgot password?", + "forgotPasswordLink": "Reset password", + "register": "Do not have an account?", + "registerLink": "Register here" + }, + "subheadings": { + "stepOne": "Enter your email", + "stepTwo": "Enter your password" + }, + "toasts": { + "incorrectPassword": "Incorrect password", + "success": "Welcome back! You're successfully logged in." } }, "registration": { - "heading": { - "superAdmin": "Create super admin", - "user": "Sign Up" - }, - "subheadings": { - "stepOne": "Enter your personal details", - "stepTwo": "Enter your email", - "stepThree": "Create your password" - }, "description": { "superAdmin": "Create your super admin account to get started", "user": "Sign up as a user and ask super admin for access to your monitors" @@ -591,187 +179,604 @@ "superAdmin": "Create super admin account", "user": "Sign up regular user" }, - "termsAndPolicies": "By creating an account, you agree to our Terms of Service and Privacy Policy.", + "heading": { + "superAdmin": "Create super admin", + "user": "Sign Up" + }, "links": { "login": "Already have an account? Log In" }, + "subheadings": { + "stepOne": "Enter your personal details", + "stepThree": "Create your password", + "stepTwo": "Enter your email" + }, + "termsAndPolicies": "By creating an account, you agree to our Terms of Service and Privacy Policy.", "toasts": { "success": "Welcome! Your account was created successfully." } - }, - "forgotPassword": { - "heading": "Forgot password?", - "subheadings": { - "stepOne": "No worries, we'll send you reset instructions.", - "stepTwo": "We sent a password reset link to ", - "stepThree": "Your new password must be different from previously used passwords.", - "stepFour": "Your password has been successfully reset. Click below to log in magically." - }, - "buttons": { - "openEmail": "Open email app", - "resetPassword": "Reset password" - }, - "imageAlts": { - "passwordKey": "Password key icon", - "email": "Email icon", - "lock": "Lock icon", - "passwordConfirm": "Password confirm icon" - }, - "links": { - "login": "Go back to Log In", - "resend": "Didn't receive the email? Click to resend" - }, - "toasts": { - "sent": "Instructions sent to .", - "emailNotFound": "Email not found.", - "redirect": "Redirecting in ...", - "success": "Your password was reset successfully.", - "error": "Unable to reset password. Please try again later or contact support." - } } }, + "avgCpuTemperature": "Average CPU Temperature", + "bar": "Bar", + "basicInformation": "Basic Information", + "bulkImport": { + "fallbackPage": "Import a file to upload a list of servers in bulk", + "invalidFileType": "Invalid file type", + "noFileSelected": "No file selected", + "parsingFailed": "Parsing failed", + "selectFile": "Select File", + "selectFileDescription": "You can download our or sample", + "selectFileTips": "Select CSV file to upload", + "title": "Bulk Import", + "uploadFailed": "Upload failed", + "uploadSuccess": "Monitors created successfully!", + "validationFailed": "Validation failed" + }, + "cancel": "Cancel", + "checkFormError": "Please check the form for errors.", + "checkFrequency": "Check frequency", + "checkHooks": { + "failureResolveAll": "Failed to resolve all incidents.", + "failureResolveMonitor": "Failed to resolve monitor incidents.", + "failureResolveOne": "Failed to resolve incident." + }, + "checkingEvery": "Checking every", + "city": "CITY", + "common": { + "appName": "Checkmate", + "buttons": { + "toggleTheme": "Toggles light & dark" + }, + "monitoringAgentName": "Capture", + "toasts": { + "checkConnection": "Please check your connection", + "networkError": "Network error", + "unknownError": "Unknown error" + } + }, + "commonSave": "Save", + "commonSaving": "Saving...", + "companyName": "Company name", + "configure": "Configure", + "confirmPassword": "Confirm password", + "cores": "Cores", + "country": "COUNTRY", + "cpu": "CPU", + "cpuFrequency": "CPU Frequency", + "cpuLogical": "CPU (Logical)", + "cpuPhysical": "CPU (Physical)", + "cpuTemperature": "CPU Temperature", + "cpuUsage": "CPU usage", + "createA": "Create a", + "createMaintenance": "Create maintenance", + "createMaintenanceWindow": "Create maintenance window", + "createMonitor": "Create monitor", + "createNew": "Create new", + "createNotifications": { + "dialogDeleteConfirm": "Delete", + "dialogDeleteTitle": "Are you sure you want to delete this notification?", + "discordSettings": { + "description": "Configure your Discord webhook here", + "title": "Discord", + "webhookLabel": "Discord Webhook URL", + "webhookPlaceholder": "https://your-server.com/webhook" + }, + "emailSettings": { + "description": "Destination email addresses.", + "emailLabel": "Email address", + "emailPlaceholder": "e.g. john@example.com", + "title": "Email" + }, + "nameSettings": { + "description": "A descriptive name for your integration.", + "nameLabel": "Name", + "namePlaceholder": "e.g. Slack notifications", + "title": "Name" + }, + "pagerdutySettings": { + "description": "Configure your PagerDuty integration here", + "integrationKeyLabel": "Integration key", + "integrationKeyPlaceholder": "1234567890", + "title": "PagerDuty" + }, + "slackSettings": { + "description": "Configure your Slack webhook here", + "title": "Slack", + "webhookLabel": "Slack webhook URL", + "webhookPlaceholder": "https://hooks.slack.com/services/..." + }, + "testNotification": "Test notification", + "title": "Create notification channel", + "typeSettings": { + "description": "Select the type of notification channel you want to create.", + "title": "Type", + "typeLabel": "Type" + }, + "webhookSettings": { + "description": "Configure your webhook here", + "title": "Webhook", + "webhookLabel": "Webhook URL", + "webhookPlaceholder": "https://your-server.com/webhook" + } + }, + "createYour": "Create your", + "date&Time": "Date & Time", + "delete": "Delete", + "deleteDialogDescription": "Once deleted, this monitor cannot be retrieved.", + "deleteDialogTitle": "Do you really want to delete this monitor?", + "deleteStatusPage": "Do you want to delete this status page?", + "deleteStatusPageConfirm": "Yes, delete status page", + "deleteStatusPageDescription": "Once deleted, your status page cannot be retrieved.", + "diagnosticsPage": { + "diagnosticDescription": "System diagnostics", + "gauges": { + "heapAllocationSubtitle": "% of available memory", + "heapAllocationTitle": "Heap allocation", + "heapUsageSubtitle": "% of available memory", + "heapUsageTitle": "Heap usage", + "heapUtilizationSubtitle": "% of allocated", + "heapUtilizationTitle": "Heap utilization", + "instantCpuUsageSubtitle": "% of 1s used by CPU", + "instantCpuUsageTitle": "Instant CPU usage" + }, + "stats": { + "eventLoopDelayTitle": "Event loop delay", + "osMemoryLimitTitle": "OS Memory Limit", + "totalHeapSizeTitle": "Total heap size", + "uptimeTitle": "Uptime", + "usedHeapSizeTitle": "Used heap size" + }, + "statsDescription": "System statistics" + }, + "disk": "Disk", + "diskUsage": "Disk Usage", + "displayName": "Display name", + "distributedRightCategoryTitle": "Monitor", + "distributedStatusHeaderText": "Real-time, real-device coverage", + "distributedStatusServerMonitors": "Server Monitors", + "distributedStatusServerMonitorsDescription": "Monitor status of related servers", + "distributedStatusSubHeaderText": "Powered by millions devices worldwide, view a system performance by global region, country or city", + "distributedUptimeCreateAdvancedSettings": "Advanced settings", + "distributedUptimeCreateChecks": "Checks to perform", + "distributedUptimeCreateChecksDescription": "You can always add or remove checks after adding your site.", + "distributedUptimeCreateIncidentDescription": "When there is an incident, notify users.", + "distributedUptimeCreateIncidentNotification": "Incident notifications", + "distributedUptimeCreateSelectURL": "Here you can select the URL of the host, together with the type of monitor.", + "distributedUptimeDetailsNoMonitorHistory": "There is no check history for this monitor yet.", + "distributedUptimeDetailsStatusHeaderLastUpdate": "Last updated", + "distributedUptimeDetailsStatusHeaderUptime": "Uptime:", + "distributedUptimeStatus30Days": "30 days", + "distributedUptimeStatus60Days": "60 days", + "distributedUptimeStatus90Days": "90 days", + "distributedUptimeStatusBasicInfoDescription": "Define company name and the subdomain that your status page points to.", + "distributedUptimeStatusBasicInfoHeader": "Basic Information", + "distributedUptimeStatusCompanyNameLabel": "Company name", + "distributedUptimeStatusContactAdmin": "Please contact your administrator", + "distributedUptimeStatusCreateStatusPage": "status page", + "distributedUptimeStatusCreateStatusPageAccess": "Access", + "distributedUptimeStatusCreateStatusPageReady": "If your status page is ready, you can mark it as published.", + "distributedUptimeStatusCreateYour": "Create your", + "distributedUptimeStatusDevices": "Devices", + "distributedUptimeStatusEditYour": "Edit your", + "distributedUptimeStatusLogoDescription": "Upload a logo for your status page", + "distributedUptimeStatusLogoHeader": "Logo", + "distributedUptimeStatusLogoUploadButton": "Upload logo", + "distributedUptimeStatusPageAddressLabel": "Your status page address", + "distributedUptimeStatusPageDeleteConfirm": "Yes, delete status page", + "distributedUptimeStatusPageDeleteDescription": "Once deleted, your status page cannot be retrieved.", + "distributedUptimeStatusPageDeleteDialog": "Do you want to delete this status page?", + "distributedUptimeStatusPageNotPublic": "This status page is not public.", + "distributedUptimeStatusPageNotSetUp": "A status page is not set up.", + "distributedUptimeStatusPublishedLabel": "Published and visible to the public", + "distributedUptimeStatusStandardMonitorsDescription": "Attach standard monitors to your status page.", + "distributedUptimeStatusStandardMonitorsHeader": "Standard Monitors", + "distributedUptimeStatusUpt": "UPT", + "distributedUptimeStatusUptBurned": "UPT Burned", + "distributedUptimeStatusUptLogo": "Upt Logo", + "dockerContainerMonitoring": "Docker container monitoring", + "dockerContainerMonitoringDescription": "Check whether your Docker container is running or not.", + "duration": "Duration", + "edit": "Edit", + "editMaintenance": "Edit maintenance", + "editUserPage": { + "form": { + "email": "Email", + "firstName": "First name", + "lastName": "Last name", + "role": "Roles", + "save": "Save" + }, + "table": { + "actionHeader": "Action", + "roleHeader": "Role" + }, + "title": "Edit user", + "toast": { + "successUserUpdate": "User updated successfully", + "validationErrors": "Validation errors" + } + }, + "editing": "Editing...", + "emailSent": "Email sent successfully", + "errorInvalidFieldId": "Invalid field ID provided", + "errorInvalidTypeId": "Invalid notification type provided", "errorPages": { "serverUnreachable": { - "toasts": { - "reconnected": "Successfully reconnected to the server.", - "stillUnreachable": "Server is still unreachable. Please try again later." - }, "alertBox": "Server Connection Error", "description": "We're unable to connect to the server. Please check your internet connection or verify your deployment configuration if the problem persists.", "retryButton": { "default": "Retry connection", "processing": "Connecting..." + }, + "toasts": { + "reconnected": "Successfully reconnected to the server.", + "stillUnreachable": "Server is still unreachable. Please try again later." } } }, - "createNotifications": { - "title": "Create notification channel", - "nameSettings": { - "title": "Name", - "description": "A descriptive name for your integration.", - "nameLabel": "Name", - "namePlaceholder": "e.g. Slack notifications" - }, - "typeSettings": { - "title": "Type", - "description": "Select the type of notification channel you want to create.", - "typeLabel": "Type" - }, - "emailSettings": { - "title": "Email", - "description": "Destination email addresses.", - "emailLabel": "Email address", - "emailPlaceholder": "e.g. john@example.com" - }, - "slackSettings": { - "title": "Slack", - "description": "Configure your Slack webhook here", - "webhookLabel": "Slack webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/..." - }, - "pagerdutySettings": { - "title": "PagerDuty", - "description": "Configure your PagerDuty integration here", - "integrationKeyLabel": "Integration key", - "integrationKeyPlaceholder": "1234567890" - }, - "discordSettings": { - "title": "Discord", - "description": "Configure your Discord webhook here", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "webhookSettings": { - "title": "Webhook", - "description": "Configure your webhook here", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "testNotification": "Test notification", - "dialogDeleteTitle": "Are you sure you want to delete this notification?", - "dialogDeleteConfirm": "Delete" + "expectedValue": "Expected value", + "export": { + "failed": "Failed to export monitors", + "success": "Monitors exported successfully!", + "title": "Export Monitors" }, - "notificationConfig": { - "title": "Notifications", - "description": "Select the notifications channels you want to use" + "failedToSendEmail": "Failed to send email", + "features": "Features", + "frequency": "Frequency", + "friendlyNameInput": "Friendly name", + "friendlyNamePlaceholder": "Maintenance at __ : __ for ___ minutes", + "gb": "GB", + "general": { + "noOptionsFound": "No {{unit}} found" }, - "monitorStatus": { - "checkingEvery": "Checking every {{interval}}", - "withCaptureAgent": "with Capture agent {{version}}", - "up": "up", - "down": "down", - "paused": "paused" + "greeting": { + "append": "The afternoon is your playground—let's make it epic!", + "overview": "Here's an overview of your {{type}} monitors.", + "prepend": "Hey there" }, - "advancedMatching": "Advanced matching", - "sendTestNotifications": "Send test notifications", - "selectAll": "Select all", - "showAdminLoginLink": "Show \"Administrator? Login Here\" link on the status page", + "high": "high", + "host": "Host", + "http": "HTTP", + "https": "HTTPS", + "ignoreTLSError": "Ignore TLS/SSL error", + "ignoreTLSErrorDescription": "Ignore TLS/SSL errors and continue checking the website's availability", + "incidentsOptionsHeader": "Incidents for:", + "incidentsOptionsHeaderFilterAll": "All", + "incidentsOptionsHeaderFilterBy": "Filter by:", + "incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve", + "incidentsOptionsHeaderFilterDown": "Down", + "incidentsOptionsHeaderFilterResolved": "Resolved", + "incidentsOptionsHeaderLastDay": "Last day", + "incidentsOptionsHeaderLastHour": "Last hour", + "incidentsOptionsHeaderLastWeek": "Last week", + "incidentsOptionsHeaderShow": "Show:", + "incidentsOptionsHeaderTotalIncidents": "Total Incidents", + "incidentsOptionsPlaceholderAllServers": "All servers", + "incidentsPageActionResolveAll": "Resolve all incidents", + "incidentsPageActionResolveMonitor": "Resolve monitor incidents", + "incidentsPageTitle": "Incidents", + "incidentsTableActionResolve": "Resolve", + "incidentsTableDateTime": "Date & Time", + "incidentsTableMessage": "Message", + "incidentsTableMonitorName": "Monitor Name", + "incidentsTableNoIncidents": "No incidents recorded", + "incidentsTablePaginationLabel": "incidents", + "incidentsTableResolvedAt": "Resolved at", + "incidentsTableStatus": "Status", + "incidentsTableStatusCode": "Status Code", + "infrastructureAlertNotificationDescription": "Send a notification to user(s) when thresholds exceed a specified percentage.", + "infrastructureAuthorizationSecretLabel": "Authorization secret", + "infrastructureCreateGeneralSettingsDescription": "Here you can select the URL of the host, together with the friendly name and authorization secret to connect to the server agent.", + "infrastructureCreateMonitor": "Create Infrastructure Monitor", + "infrastructureCreateYour": "Create your", + "infrastructureCustomizeAlerts": "Customize alerts", + "infrastructureDisplayNameLabel": "Display name", + "infrastructureEditMonitor": "Save Infrastructure Monitor", + "infrastructureEditYour": "Edit your", + "infrastructureMonitor": { + "fallback": { + "actionButton": "Let's create your first infrastructure monitor!", + "checks": [ + "Track the performance of your servers", + "Identify bottlenecks and optimize usage", + "Ensure reliability with real-time monitoring" + ], + "title": "An infrastructure monitor is used to:" + } + }, + "infrastructureMonitorCreated": "Infrastructure monitor created successfully!", + "infrastructureMonitorUpdated": "Infrastructure monitor updated successfully!", + "infrastructureProtocol": "Protocol", + "infrastructureServerRequirement": "The server you are monitoring must be running the", + "infrastructureServerUrlLabel": "Server URL", + "integrations": "Integrations", + "integrationsDiscord": "Discord", + "integrationsDiscordInfo": "Connect with Discord and view incidents directly in a channel", + "integrationsPrism": "Connect Prism to your favorite service.", + "integrationsSlack": "Slack", + "integrationsSlackInfo": "Connect with Slack and see incidents in a channel", + "integrationsZapier": "Zapier", + "integrationsZapierInfo": "Send all incidents to Zapier, and then see them everywhere", + "invalidFileFormat": "Unsupported file format!", + "invalidFileSize": "File size is too large!", + "inviteNoTokenFound": "No invite token found", + "loginHere": "Login here", "logsPage": { - "title": "Logs", "description": "System logs - last 1000 lines", - "tabs": { - "queue": "Job queue", - "logs": "Server logs", - "diagnostics": "Diagnostics" - }, - "toast": { - "fetchLogsSuccess": "Logs fetched successfully" - }, "logLevelSelect": { "title": "Log level", "values": { "all": "All", - "info": "Info", - "warn": "Warn", + "debug": "Debug", "error": "Error", - "debug": "Debug" + "info": "Info", + "warn": "Warn" } + }, + "tabs": { + "diagnostics": "Diagnostics", + "logs": "Server logs", + "queue": "Job queue" + }, + "title": "Logs", + "toast": { + "fetchLogsSuccess": "Logs fetched successfully" } }, + "low": "low", + "maintenance": "maintenance", + "maintenanceRepeat": "Maintenance Repeat", + "maintenanceTableActionMenuDialogTitle": "Do you really want to remove this maintenance window?", + "maintenanceWindow": { + "fallback": { + "actionButton": "Let's create your first maintenance window!", + "checks": [ + "Mark your maintenance periods", + "Eliminate any misunderstandings", + "Stop sending alerts in maintenance windows" + ], + "title": "A maintenance window is used to:" + } + }, + "maintenanceWindowDescription": "Your pings won't be sent during this time frame", + "maintenanceWindowName": "Maintenance Window Name", + "maskedPageSpeedKeyPlaceholder": "*************************************", + "matchMethod": "Match Method", + "mb": "MB", + "mem": "Mem", + "memory": "Memory", + "memoryUsage": "Memory usage", + "menu": { + "changelog": "Changelog", + "discussions": "Discussions", + "docs": "Docs", + "incidents": "Incidents", + "infrastructure": "Infrastructure", + "integrations": "Integrations", + "logOut": "Log out", + "logs": "Logs", + "maintenance": "Maintenance", + "notifications": "Notifications", + "pagespeed": "Pagespeed", + "password": "Password", + "profile": "Profile", + "settings": "Settings", + "statusPages": "Status pages", + "support": "Support", + "team": "Team", + "uptime": "Uptime" + }, + "message": "Message", + "monitor": "monitor", + "monitorActions": { + "deleteFailed": "Failed to delete monitor", + "deleteSuccess": "Monitor deleted successfully", + "details": "Details", + "export": "Export Monitors", + "import": "Import Monitors", + "title": "Export/Import" + }, + "monitorDisplayName": "Monitor display name", + "monitorHooks": { + "failureAddDemoMonitors": "Failed to add demo monitors", + "successAddDemoMonitors": "Successfully added demo monitors" + }, + "monitorState": { + "active": "Active", + "paused": "Paused", + "resumed": "Resumed" + }, + "monitorStatus": { + "checkingEvery": "Checking every {{interval}}", + "down": "down", + "paused": "paused", + "up": "up", + "withCaptureAgent": "with Capture agent {{version}}" + }, + "monitorStatusDown": "Monitor {name} ({url}) is DOWN and not responding", + "monitorStatusUp": "Monitor {name} ({url}) is now UP and responding", + "monitors": "monitors", + "monitorsToApply": "Monitors to apply maintenance window to", + "ms": "ms", + "navControls": "Controls", + "nextWindow": "Next window", + "notFoundButton": "Go to the main dashboard", + "notificationConfig": { + "description": "Select the notifications channels you want to use", + "title": "Notifications" + }, + "notifications": { + "addOrEditNotifications": "Add or edit notifications", + "create": { + "failed": "Failed to create notification", + "success": "Notification created successfully" + }, + "createButton": "Create notification channel", + "createTitle": "Notification channel", + "delete": { + "failed": "Failed to delete notification", + "success": "Notification deleted successfully" + }, + "discord": { + "description": "To send data to a Discord channel from Checkmate via Discord notifications using webhooks, you can use Discord's incoming Webhooks feature.", + "label": "Discord", + "webhookLabel": "Discord Webhook URL", + "webhookPlaceholder": "https://discord.com/api/webhooks/...", + "webhookRequired": "Discord webhook URL is required" + }, + "edit": { + "failed": "Failed to update notification", + "success": "Notification updated successfully" + }, + "enableNotifications": "Enable {{platform}} notifications", + "fallback": { + "actionButton": "Let's create your first notification channel!", + "checks": [ + "Alert teams about downtime or performance issues", + "Let engineers know when incidents happen", + "Keep administrators informed of system changes" + ], + "title": "A notification channel is used to:" + }, + "fetch": { + "failed": "Failed to fetch notifications", + "success": "Notifications fetched successfully" + }, + "integrationButton": "Notification Integration", + "networkError": "Network error occurred", + "slack": { + "description": "To enable Slack notifications, create a Slack app and enable incoming webhooks. After that, simply provide the webhook URL here.", + "label": "Slack", + "webhookLabel": "Webhook URL", + "webhookPlaceholder": "https://hooks.slack.com/services/...", + "webhookRequired": "Slack webhook URL is required" + }, + "telegram": { + "chatIdLabel": "Your Chat ID", + "chatIdPlaceholder": "-1001234567890", + "description": "To enable Telegram notifications, create a Telegram bot using BotFather, an official bot for creating and managing Telegram bots. Then, get the API token and chat ID and write them down here.", + "fieldsRequired": "Telegram token and chat ID are required", + "label": "Telegram", + "tokenLabel": "Your bot token", + "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + }, + "test": { + "failed": "Failed to send test notification", + "success": "Test notification sent successfully" + }, + "testFailed": "Failed to send test notification", + "testNotification": "Test notification", + "testNotificationDevelop": "Test notification 2", + "testSuccess": "Test notification sent successfully!", + "unsupportedType": "Unsupported notification type", + "webhook": { + "description": "You can set up a custom webhook to receive notifications when incidents occur.", + "label": "Webhooks", + "urlLabel": "Webhook URL", + "urlPlaceholder": "https://your-server.com/webhook", + "urlRequired": "Webhook URL is required" + } + }, + "notifyEmails": "Also notify via email to multiple addresses (coming soon)", + "notifySMS": "Notify via SMS (coming soon)", + "now": "Now", + "os": "OS", + "pageSpeed": { + "fallback": { + "actionButton": "Let's create your first PageSpeed monitor!", + "checks": [ + "Report on the user experience of a page", + "Help analyze webpage speed", + "Give suggestions on how the page can be improved" + ], + "title": "A PageSpeed monitor is used to:" + } + }, + "pageSpeedAddApiKey": "to add your API key.", + "pageSpeedConfigureSettingsDescription": "Here you can select the URL of the host, together with the type of monitor.", + "pageSpeedDetailsPerformanceReport": "Values are estimated and may vary.", + "pageSpeedDetailsPerformanceReportCalculator": "See calculator", + "pageSpeedLearnMoreLink": "Click here", + "pageSpeedLighthouseAPI": "Use the Lighthouse PageSpeed API to monitor your website", + "pageSpeedMonitor": "PageSpeed monitor", + "pageSpeedWarning": "Warning: You haven't added a Google PageSpeed API key yet. Without it, the PageSpeed monitor won't function.", + "passwordPanel": { + "confirmNewPassword": "Confirm new password", + "currentPassword": "Current password", + "enterCurrentPassword": "Enter your current password", + "enterNewPassword": "Enter your new password", + "newPassword": "New password", + "passwordChangedSuccess": "Your password was changed successfully.", + "passwordInputIncorrect": "Your password input was incorrect.", + "passwordRequirements": "New password must contain at least 8 characters and must have at least one uppercase letter, one lowercase letter, one number and one special character.", + "saving": "Saving..." + }, + "pause": "Pause", + "pingMonitoring": "Ping monitoring", + "pingMonitoringDescription": "Check whether your server is available or not.", + "portMonitoring": "Port monitoring", + "portMonitoringDescription": "Check whether your port is open or not.", + "portToMonitor": "Port to monitor", + "publicLink": "Public link", + "publicURL": "Public URL", "queuePage": { - "title": "Queue", - "refreshButton": "Refresh", - "flushButton": "Flush queue", - "jobTable": { - "title": "Jobs currently in queue", - "idHeader": "Monitor ID", - "urlHeader": "URL", - "typeHeader": "Type", - "activeHeader": "Active", - "lockedAtHeader": "Locked at", - "runCountHeader": "Run count", - "failCountHeader": "Fail count", - "lastRunHeader": "Last run at", - "lastFinishedAtHeader": "Last finished at", - "lastRunTookHeader": "Last run took" - }, - "metricsTable": { - "title": "Queue metrics", - "metricHeader": "Metric", - "valueHeader": "Value" - }, "failedJobTable": { - "title": "Failed jobs", + "failCountHeader": "Fail count", + "failReasonHeader": "Fail reason", + "failedAtHeader": "Last failed at", "monitorIdHeader": "Monitor ID", "monitorUrlHeader": "Monitor URL", + "title": "Failed jobs" + }, + "flushButton": "Flush queue", + "jobTable": { + "activeHeader": "Active", "failCountHeader": "Fail count", - "failedAtHeader": "Last failed at", - "failReasonHeader": "Fail reason" - } + "idHeader": "Monitor ID", + "lastFinishedAtHeader": "Last finished at", + "lastRunHeader": "Last run at", + "lastRunTookHeader": "Last run took", + "lockedAtHeader": "Locked at", + "runCountHeader": "Run count", + "title": "Jobs currently in queue", + "typeHeader": "Type", + "urlHeader": "URL" + }, + "metricsTable": { + "metricHeader": "Metric", + "title": "Queue metrics", + "valueHeader": "Value" + }, + "refreshButton": "Refresh", + "title": "Queue" }, - "export": { - "title": "Export Monitors", - "success": "Monitors exported successfully!", - "failed": "Failed to export monitors" - }, - "monitorActions": { - "title": "Export/Import", - "import": "Import Monitors", - "export": "Export Monitors", - "deleteSuccess": "Monitor deleted successfully", - "deleteFailed": "Failed to delete monitor", - "details": "Details" + "remove": "Remove", + "removeLogo": "Remove Logo", + "repeat": "Repeat", + "reset": "Reset", + "response": "RESPONSE", + "responseTime": "Response time", + "resume": "Resume", + "roles": { + "admin": "Admin", + "demoUser": "Demo user", + "superAdmin": "Super admin", + "teamMember": "Team member" }, + "save": "Save", + "selectAll": "Select all", + "sendTestNotifications": "Send test notifications", + "seperateEmails": "You can separate multiple emails with a comma", + "settingsAppearance": "Appearance", + "settingsDisabled": "Disabled", + "settingsDisplayTimezone": "Display timezone", + "settingsEmailUser": "Email user - Username for authentication, overrides email address if specified", + "settingsFailedToClearStats": "Failed to clear stats", + "settingsFailedToDeleteMonitors": "Failed to delete all monitors", + "settingsFailedToSave": "Failed to save settings", + "settingsGeneralSettings": "General settings", + "settingsMonitorsDeleted": "Successfully deleted all monitors", "settingsPage": { "aboutSettings": { "labelDevelopedBy": "Developed by Bluewave Labs", @@ -807,8 +812,8 @@ }, "pageSpeedSettings": { "description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.", - "labelApiKeySet": "API key is set. Click Reset to change it.", "labelApiKey": "PageSpeed API key", + "labelApiKeySet": "API key is set. Click Reset to change it.", "title": "Google PageSpeed API key" }, "saveButtonLabel": "Save", @@ -851,144 +856,139 @@ "title": "Monitor IP/URL on Status Page" } }, - "statusPageCreate": { - "buttonSave": "Save" - }, - "incidentsOptionsHeaderFilterResolved": "Resolved", "settingsSave": "Save", - "statusPageCreateAppearanceTitle": "Appearance", - "confirmPassword": "Confirm password", - "monitorHooks": { - "failureAddDemoMonitors": "Failed to add demo monitors", - "successAddDemoMonitors": "Successfully added demo monitors" + "settingsStatsCleared": "Stats cleared successfully", + "settingsSuccessSaved": "Settings saved successfully", + "settingsTestEmailFailed": "Failed to send test email", + "settingsTestEmailFailedWithReason": "Failed to send test email: {{reason}}", + "settingsTestEmailSuccess": "Test email sent successfully", + "settingsTestEmailUnknownError": "Unknown error", + "showAdminLoginLink": "Show \"Administrator? Login Here\" link on the status page", + "showCharts": "Show charts", + "showUptimePercentage": "Show uptime percentage", + "shown": "Shown", + "starPromptDescription": "See the latest releases and help grow the community on GitHub", + "starPromptTitle": "Star Checkmate", + "startTime": "Start time", + "state": "State", + "status": "Status", + "statusBreadCrumbsDetails": "Details", + "statusBreadCrumbsStatusPages": "Status Pages", + "statusCode": "Status code", + "statusMsg": { + "down": "Your site is down.", + "paused": "Monitoring is paused.", + "pending": "Pending...", + "up": "Your site is up." }, - "settingsAppearance": "Appearance", - "settingsDisplayTimezone": "Display timezone", - "settingsGeneralSettings": "General settings", - "incidentsOptionsHeaderTotalIncidents": "Total Incidents", "statusPage": { - "deleteSuccess": "Status page deleted successfully", - "deleteFailed": "Failed to delete status page", - "createSuccess": "Status page created successfully", - "updateSuccess": "Status page updated successfully", - "generalSettings": "General settings", "contents": "Contents", + "createSuccess": "Status page created successfully", + "deleteFailed": "Failed to delete status page", + "deleteSuccess": "Status page deleted successfully", "fallback": { + "actionButton": "Let's create your first status page!", "checks": [ "Monitor and display the health of your services in real time", "Track multiple services and share their status", "Keep users informed about outages and performance" ], - "title": "A status page is used to:", - "actionButton": "Let's create your first status page!" - } - }, - "testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button", - "incidentsTableResolvedAt": "Resolved at", - "incidentsTableActionResolve": "Resolve", - "checkHooks": { - "failureResolveOne": "Failed to resolve incident.", - "failureResolveAll": "Failed to resolve all incidents.", - "failureResolveMonitor": "Failed to resolve monitor incidents." - }, - "checkFormError": "Please check the form for errors.", - "diagnosticsPage": { - "diagnosticDescription": "System diagnostics", - "statsDescription": "System statistics", - "gauges": { - "heapAllocationTitle": "Heap allocation", - "heapAllocationSubtitle": "% of available memory", - "heapUsageTitle": "Heap usage", - "heapUsageSubtitle": "% of available memory", - "heapUtilizationTitle": "Heap utilization", - "heapUtilizationSubtitle": "% of allocated", - "instantCpuUsageTitle": "Instant CPU usage", - "instantCpuUsageSubtitle": "% of 1s used by CPU" + "title": "A status page is used to:" }, - "stats": { - "eventLoopDelayTitle": "Event loop delay", - "uptimeTitle": "Uptime", - "usedHeapSizeTitle": "Used heap size", - "totalHeapSizeTitle": "Total heap size", - "osMemoryLimitTitle": "OS Memory Limit" - } + "generalSettings": "General settings", + "updateSuccess": "Status page updated successfully" }, - "pageSpeedLighthouseAPI": "Use the Lighthouse PageSpeed API to monitor your website", + "statusPageCreate": { + "buttonSave": "Save" + }, + "statusPageCreateAppearanceDescription": "Define the default look and feel of your public status page.", + "statusPageCreateAppearanceTitle": "Appearance", + "statusPageCreateBasicInfoDescription": "Define company name and the subdomain that your status page points to.", + "statusPageCreateBasicInfoStatusPageAddress": "Your status page address", + "statusPageCreateSelectTimeZoneDescription": "Select the timezone that your status page will be displayed in.", + "statusPageCreateSettings": "If your status page is ready, you can mark it as published.", + "statusPageCreateSettingsCheckboxLabel": "Published and visible to the public", + "statusPageCreateTabsContent": "Status page servers", + "statusPageCreateTabsContentDescription": "You can add any number of servers that you monitor to your status page. You can also reorder them for the best viewing experience.", + "statusPageCreateTabsContentFeaturesDescription": "Show more details on the status page", + "statusPageName": "Status page name", + "statusPageStatus": "A public status page is not set up.", + "statusPageStatusContactAdmin": "Please contact to your administrator", + "statusPageStatusNoPage": "There's no status page here.", + "statusPageStatusNotPublic": "This status page is not public.", + "statusPageStatusServiceStatus": "Service status", + "submit": "Submit", + "teamPanel": { + "cancel": "Cancel", + "email": "Email", + "emailToken": "E-mail token", + "filter": { + "all": "All", + "member": "Member" + }, + "getToken": "Get token", + "inviteDescription": "When you add a new team member, they will get access to all monitors.", + "inviteLink": "Invite link", + "inviteNewTeamMember": "Invite new team member", + "inviteTeamMember": "Invite a team member", + "noMembers": "There are no team members with this role", + "selectRole": "Select role", + "table": { + "created": "Created", + "email": "Email", + "name": "Name", + "role": "Role" + }, + "teamMembers": "Team members" + }, + "testLocale": "testLocale", + "testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button", "time": { - "threeMinutes": "3 minutes", "fiveMinutes": "5 minutes", - "tenMinutes": "10 minutes", - "twentyMinutes": "20 minutes", - "oneHour": "1 hour", "oneDay": "1 day", - "oneWeek": "1 week" + "oneHour": "1 hour", + "oneWeek": "1 week", + "tenMinutes": "10 minutes", + "threeMinutes": "3 minutes", + "twentyMinutes": "20 minutes" }, - "general": { - "noOptionsFound": "No {{unit}} found" - }, - "infrastructureMonitor": { - "fallback": { - "checks": [ - "Track the performance of your servers", - "Identify bottlenecks and optimize usage", - "Ensure reliability with real-time monitoring" - ], - "title": "An infrastructure monitor is used to:", - "actionButton": "Let's create your first infrastructure monitor!" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [ - "Mark your maintenance periods", - "Eliminate any misunderstandings", - "Stop sending alerts in maintenance windows" - ], - "title": "A maintenance window is used to:", - "actionButton": "Let's create your first maintenance window!" - } - }, - "pageSpeed": { - "fallback": { - "checks": [ - "Report on the user experience of a page", - "Help analyze webpage speed", - "Give suggestions on how the page can be improved" - ], - "title": "A PageSpeed monitor is used to:", - "actionButton": "Let's create your first PageSpeed monitor!" - } + "timeZoneInfo": "All dates and times are in GMT+0 time zone.", + "timezone": "Timezone", + "title": "Title", + "tlsErrorIgnored": "TLS/SSL errors ignored", + "total": "Total", + "type": "Type", + "update": "Update", + "uptime": "Uptime", + "uptimeCreate": "The expected value is used to match against response result, and the match determines the status.", + "uptimeCreateJsonPath": "This expression will be evaluated against the reponse JSON data and the result will be used to match against the expected value. See", + "uptimeCreateJsonPathQuery": "for query language documentation.", + "uptimeGeneralInstructions": { + "docker": "Enter the Docker ID of your container. Docker IDs must be the full 64 char Docker ID. You can run docker inspect to get the full container ID.", + "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", + "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard." }, "uptimeMonitor": { "fallback": { + "actionButton": "Let's create your first uptime monitor!", "checks": [ "Check if websites or servers are online & responsive", "Alert teams about downtime or performance issues", "Monitor HTTP endpoints, pings, containers & ports", "Track historical uptime and reliability trends" ], - "title": "An uptime monitor is used to:", - "actionButton": "Let's create your first uptime monitor!" + "title": "An uptime monitor is used to:" } }, - "editUserPage": { - "form": { - "email": "Email", - "firstName": "First name", - "lastName": "Last name", - "role": "Roles", - "save": "Save" - }, - "table": { - "actionHeader": "Action", - "roleHeader": "Role" - }, - "title": "Edit user", - "toast": { - "successUserUpdate": "User updated successfully", - "validationErrors": "Validation errors" - } - }, - "incidentsPageActionResolveMonitor": "Resolve monitor incidents", - "incidentsPageActionResolveAll": "Resolve all incidents" + "url": "URL", + "urlMonitor": "URL to monitor", + "used": "Used", + "webhookSendError": "Error sending webhook notification to {platform}", + "webhookSendSuccess": "Webhook notification sent successfully", + "webhookUnsupportedPlatform": "Unsupported platform: {platform}", + "websiteMonitoring": "Website monitoring", + "websiteMonitoringDescription": "Use HTTP(s) to monitor your website or API endpoint.", + "whenNewIncident": "When there is a new incident,", + "window": "window" } From af010e138d93fd023faf57ca18583f1050892d93 Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Wed, 30 Jul 2025 00:01:27 +0530 Subject: [PATCH 119/226] Some changes in ui --- .../Settings/SettingsGlobalThresholds.jsx | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/client/src/Pages/Settings/SettingsGlobalThresholds.jsx b/client/src/Pages/Settings/SettingsGlobalThresholds.jsx index cd1c50927..96f14e62d 100644 --- a/client/src/Pages/Settings/SettingsGlobalThresholds.jsx +++ b/client/src/Pages/Settings/SettingsGlobalThresholds.jsx @@ -26,17 +26,16 @@ const SettingsGlobalThresholds = ({ temperature: "", }); - // Load existing thresholds from settingsData on first render or when settingsData changes - useEffect(() => { - if (settingsData?.settings?.globalThresholds) { - setThresholds({ - cpu: settingsData.settings.globalThresholds.cpu || "", - memory: settingsData.settings.globalThresholds.memory || "", - disk: settingsData.settings.globalThresholds.disk || "", - temperature: settingsData.settings.globalThresholds.temperature || "", - }); - } - }, [settingsData]); + // Load existing thresholds from settingsData + + if (settingsData?.settings?.globalThresholds) { + setThresholds({ + cpu: settingsData.settings.globalThresholds.cpu || "", + memory: settingsData.settings.globalThresholds.memory || "", + disk: settingsData.settings.globalThresholds.disk || "", + temperature: settingsData.settings.globalThresholds.temperature || "", + }); + } // Handles input change and updates state & parent data const handleChange = (e, min, max) => { From e55d8618bd1ae5acc380abe9c9e93eb45fa98178 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 29 Jul 2025 11:51:06 -0700 Subject: [PATCH 120/226] convert checkModule to class for dependency injection --- server/src/config/services.js | 12 +- server/src/db/mongo/MongoDB.js | 4 +- server/src/db/mongo/modules/checkModule.js | 633 ++++++++---------- server/src/service/business/checkService.js | 16 +- server/src/service/business/monitorService.js | 2 +- .../service/infrastructure/bufferService.js | 2 +- 6 files changed, 300 insertions(+), 369 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index 73955a3b4..c2ecae200 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -32,6 +32,15 @@ import mjml2html from "mjml"; import jwt from "jsonwebtoken"; import crypto from "crypto"; +// DB Modules +import Check from "../db/models/Check.js"; +import HardwareCheck from "../db/models/HardwareCheck.js"; +import PageSpeedCheck from "../db/models/PageSpeedCheck.js"; +import Monitor from "../db/models/Monitor.js"; +import User from "../db/models/User.js"; + +import CheckModule from "../db/mongo/modules/checkModule.js"; + export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); ServiceRegistry.instance = serviceRegistry; @@ -42,7 +51,8 @@ export const initializeServices = async ({ logger, envSettings, settingsService const stringService = new StringService(translationService); // Create DB - const db = new MongoDB({ logger, envSettings }); + const checkModule = new CheckModule({ logger, Check, HardwareCheck, PageSpeedCheck, Monitor, User }); + const db = new MongoDB({ logger, envSettings, checkModule }); await db.connect(); const networkService = new NetworkService(axios, ping, logger, http, Docker, net, stringService, settingsService); diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index d53fea928..ef7179d35 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -69,7 +69,7 @@ import * as diagnosticModule from "./modules/diagnosticModule.js"; class MongoDB { static SERVICE_NAME = "MongoDB"; - constructor({ logger, envSettings }) { + constructor({ logger, envSettings, checkModule }) { this.logger = logger; this.envSettings = envSettings; Object.assign(this, userModule); @@ -78,7 +78,7 @@ class MongoDB { Object.assign(this, monitorModule); Object.assign(this, pageSpeedCheckModule); Object.assign(this, hardwareCheckModule); - Object.assign(this, checkModule); + this.checkModule = checkModule; Object.assign(this, maintenanceWindowModule); Object.assign(this, notificationModule); Object.assign(this, settingsModule); diff --git a/server/src/db/mongo/modules/checkModule.js b/server/src/db/mongo/modules/checkModule.js index 18c3d45ec..f806b5bf5 100755 --- a/server/src/db/mongo/modules/checkModule.js +++ b/server/src/db/mongo/modules/checkModule.js @@ -1,9 +1,3 @@ -import Check from "../../models/Check.js"; -import Monitor from "../../models/Monitor.js"; -import HardwareCheck from "../../models/HardwareCheck.js"; -import PageSpeedCheck from "../../models/PageSpeedCheck.js"; -import User from "../../models/User.js"; -import { logger } from "../../../utils/logger.js"; import { ObjectId } from "mongodb"; import { buildChecksSummaryByTeamIdPipeline } from "./checkModuleQueries.js"; @@ -17,369 +11,296 @@ const dateRangeLookup = { all: undefined, }; -/** - * Create a check for a monitor - * @async - * @param {Object} checkData - * @param {string} checkData.monitorId - * @param {boolean} checkData.status - * @param {number} checkData.responseTime - * @param {number} checkData.statusCode - * @param {string} checkData.message - * @returns {Promise} - * @throws {Error} - */ - -const createCheck = async (checkData) => { - try { - const check = await new Check({ ...checkData }).save(); - return check; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "createCheck"; - throw error; +class CheckModule { + constructor({ logger, Check, HardwareCheck, PageSpeedCheck, Monitor, User }) { + this.logger = logger; + this.Check = Check; + this.HardwareCheck = HardwareCheck; + this.PageSpeedCheck = PageSpeedCheck; + this.Monitor = Monitor; + this.User = User; } -}; -const createChecks = async (checks) => { - try { - await Check.insertMany(checks, { ordered: false }); - } catch (error) { - error.service = SERVICE_NAME; - error.method = "createCheck"; - throw error; - } -}; - -/** - * Get all checks for a monitor - * @async - * @param {string} monitorId - * @returns {Promise>} - * @throws {Error} - */ -const getChecksByMonitor = async ({ monitorId, type, sortOrder, dateRange, filter, ack, page, rowsPerPage, status }) => { - try { - status = status === "true" ? true : status === "false" ? false : undefined; - page = parseInt(page); - rowsPerPage = parseInt(rowsPerPage); - - const ackStage = ack === "true" ? { ack: true } : { $or: [{ ack: false }, { ack: { $exists: false } }] }; - - // Match - const matchStage = { - monitorId: new ObjectId(monitorId), - ...(typeof status !== "undefined" && { status }), - ...(typeof ack !== "undefined" && ackStage), - ...(dateRangeLookup[dateRange] && { - createdAt: { - $gte: dateRangeLookup[dateRange], - }, - }), - }; - - if (filter !== undefined) { - switch (filter) { - case "all": - break; - case "down": - break; - case "resolve": - matchStage.statusCode = 5000; - break; - default: - logger.warn({ - message: "invalid filter", - service: SERVICE_NAME, - method: "getChecks", - }); - break; - } + createChecks = async (checks) => { + try { + await this.Check.insertMany(checks, { ordered: false }); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createCheck"; + throw error; } + }; - //Sort - sortOrder = sortOrder === "asc" ? 1 : -1; + getChecksByMonitor = async ({ monitorId, type, sortOrder, dateRange, filter, ack, page, rowsPerPage, status }) => { + try { + status = status === "true" ? true : status === "false" ? false : undefined; + page = parseInt(page); + rowsPerPage = parseInt(rowsPerPage); - // Pagination - let skip = 0; - if (page && rowsPerPage) { - skip = page * rowsPerPage; - } + const ackStage = ack === "true" ? { ack: true } : { $or: [{ ack: false }, { ack: { $exists: false } }] }; - const checkModels = { - http: Check, - ping: Check, - docker: Check, - port: Check, - pagespeed: PageSpeedCheck, - hardware: HardwareCheck, - }; - - const Model = checkModels[type]; - - const checks = await Model.aggregate([ - { $match: matchStage }, - { $sort: { createdAt: sortOrder } }, - { - $facet: { - summary: [{ $count: "checksCount" }], - checks: [{ $skip: skip }, { $limit: rowsPerPage }], - }, - }, - { - $project: { - checksCount: { - $ifNull: [{ $arrayElemAt: ["$summary.checksCount", 0] }, 0], + // Match + const matchStage = { + monitorId: new ObjectId(monitorId), + ...(typeof status !== "undefined" && { status }), + ...(typeof ack !== "undefined" && ackStage), + ...(dateRangeLookup[dateRange] && { + createdAt: { + $gte: dateRangeLookup[dateRange], }, - checks: { - $ifNull: ["$checks", []], + }), + }; + + if (filter !== undefined) { + switch (filter) { + case "all": + break; + case "down": + break; + case "resolve": + matchStage.statusCode = 5000; + break; + default: + this.logger.warn({ + message: "invalid filter", + service: SERVICE_NAME, + method: "getChecks", + }); + break; + } + } + + //Sort + sortOrder = sortOrder === "asc" ? 1 : -1; + + // Pagination + let skip = 0; + if (page && rowsPerPage) { + skip = page * rowsPerPage; + } + + const checkModels = { + http: this.Check, + ping: this.Check, + docker: this.Check, + port: this.Check, + pagespeed: this.PageSpeedCheck, + hardware: this.HardwareCheck, + }; + + const Model = checkModels[type]; + + const checks = await Model.aggregate([ + { $match: matchStage }, + { $sort: { createdAt: sortOrder } }, + { + $facet: { + summary: [{ $count: "checksCount" }], + checks: [{ $skip: skip }, { $limit: rowsPerPage }], }, }, - }, - ]); - return checks[0]; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getChecks"; - throw error; - } -}; - -const getChecksByTeam = async ({ sortOrder, dateRange, filter, ack, page, rowsPerPage, teamId }) => { - try { - page = parseInt(page); - rowsPerPage = parseInt(rowsPerPage); - - const ackStage = ack === "true" ? { ack: true } : { $or: [{ ack: false }, { ack: { $exists: false } }] }; - - const matchStage = { - teamId: new ObjectId(teamId), - status: false, - ...(typeof ack !== "undefined" && ackStage), - ...(dateRangeLookup[dateRange] && { - createdAt: { - $gte: dateRangeLookup[dateRange], + { + $project: { + checksCount: { + $ifNull: [{ $arrayElemAt: ["$summary.checksCount", 0] }, 0], + }, + checks: { + $ifNull: ["$checks", []], + }, + }, }, - }), - }; - // Add filter to match stage - if (filter !== undefined) { - switch (filter) { - case "all": - break; - case "down": - break; - case "resolve": - matchStage.statusCode = 5000; - break; - default: - logger.warn({ - message: "invalid filter", - service: SERVICE_NAME, - method: "getChecksByTeam", - }); - break; + ]); + return checks[0]; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getChecks"; + throw error; + } + }; + + getChecksByTeam = async ({ sortOrder, dateRange, filter, ack, page, rowsPerPage, teamId }) => { + try { + page = parseInt(page); + rowsPerPage = parseInt(rowsPerPage); + + const ackStage = ack === "true" ? { ack: true } : { $or: [{ ack: false }, { ack: { $exists: false } }] }; + + const matchStage = { + teamId: new ObjectId(teamId), + status: false, + ...(typeof ack !== "undefined" && ackStage), + ...(dateRangeLookup[dateRange] && { + createdAt: { + $gte: dateRangeLookup[dateRange], + }, + }), + }; + // Add filter to match stage + if (filter !== undefined) { + switch (filter) { + case "all": + break; + case "down": + break; + case "resolve": + matchStage.statusCode = 5000; + break; + default: + this.logger.warn({ + message: "invalid filter", + service: SERVICE_NAME, + method: "getChecksByTeam", + }); + break; + } } + + sortOrder = sortOrder === "asc" ? 1 : -1; + + // pagination + let skip = 0; + if (page && rowsPerPage) { + skip = page * rowsPerPage; + } + + const aggregatePipeline = [ + { $match: matchStage }, + { + $unionWith: { + coll: "hardwarechecks", + pipeline: [{ $match: matchStage }], + }, + }, + { + $unionWith: { + coll: "pagespeedchecks", + pipeline: [{ $match: matchStage }], + }, + }, + + { $sort: { createdAt: sortOrder } }, + { + $facet: { + summary: [{ $count: "checksCount" }], + checks: [{ $skip: skip }, { $limit: rowsPerPage }], + }, + }, + { + $project: { + checksCount: { $arrayElemAt: ["$summary.checksCount", 0] }, + checks: "$checks", + }, + }, + ]; + + const checks = await this.Check.aggregate(aggregatePipeline); + return checks[0]; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getChecksByTeam"; + throw error; + } + }; + + ackCheck = async (checkId, teamId, ack) => { + try { + const updatedCheck = await this.Check.findOneAndUpdate({ _id: checkId, teamId: teamId }, { $set: { ack, ackAt: new Date() } }, { new: true }); + + if (!updatedCheck) { + throw new Error("Check not found"); + } + + return updatedCheck; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "ackCheck"; + throw error; + } + }; + + ackAllChecks = async (monitorId, teamId, ack, path) => { + try { + const updatedChecks = await this.Check.updateMany(path === "monitor" ? { monitorId } : { teamId }, { $set: { ack, ackAt: new Date() } }); + return updatedChecks.modifiedCount; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "ackAllChecks"; + throw error; + } + }; + + getChecksSummaryByTeamId = async ({ teamId }) => { + try { + const matchStage = { + teamId: new ObjectId(teamId), + }; + const checks = await this.Check.aggregate(buildChecksSummaryByTeamIdPipeline({ matchStage })); + return checks[0].summary; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getChecksSummaryByTeamId"; + throw error; + } + }; + deleteChecks = async (monitorId) => { + try { + const result = await this.Check.deleteMany({ monitorId }); + return result.deletedCount; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteChecks"; + throw error; + } + }; + + deleteChecksByTeamId = async (teamId) => { + try { + // Find all monitor IDs for this team (only get _id field for efficiency) + const teamMonitors = await this.Monitor.find({ teamId }, { _id: 1 }); + const monitorIds = teamMonitors.map((monitor) => monitor._id); + + // Delete all checks for these monitors in one operation + const deleteResult = await this.Check.deleteMany({ monitorId: { $in: monitorIds } }); + + return deleteResult.deletedCount; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteChecksByTeamId"; + throw error; + } + }; + + updateChecksTTL = async (teamId, ttl) => { + try { + await this.Check.collection.dropIndex("expiry_1"); + } catch (error) { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "updateChecksTTL", + stack: error.stack, + }); } - sortOrder = sortOrder === "asc" ? 1 : -1; - - // pagination - let skip = 0; - if (page && rowsPerPage) { - skip = page * rowsPerPage; + try { + await this.Check.collection.createIndex( + { expiry: 1 }, + { expireAfterSeconds: ttl } // TTL in seconds, adjust as necessary + ); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "updateChecksTTL"; + throw error; } - - const aggregatePipeline = [ - { $match: matchStage }, - { - $unionWith: { - coll: "hardwarechecks", - pipeline: [{ $match: matchStage }], - }, - }, - { - $unionWith: { - coll: "pagespeedchecks", - pipeline: [{ $match: matchStage }], - }, - }, - - { $sort: { createdAt: sortOrder } }, - { - $facet: { - summary: [{ $count: "checksCount" }], - checks: [{ $skip: skip }, { $limit: rowsPerPage }], - }, - }, - { - $project: { - checksCount: { $arrayElemAt: ["$summary.checksCount", 0] }, - checks: "$checks", - }, - }, - ]; - - const checks = await Check.aggregate(aggregatePipeline); - return checks[0]; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getChecksByTeam"; - throw error; - } -}; - -/** - * Update the acknowledgment status of a check - * @async - * @param {string} checkId - The ID of the check to update - * @param {string} teamId - The ID of the team - * @param {boolean} ack - The acknowledgment status to set - * @returns {Promise} - * @throws {Error} - */ -const ackCheck = async (checkId, teamId, ack) => { - try { - const updatedCheck = await Check.findOneAndUpdate({ _id: checkId, teamId: teamId }, { $set: { ack, ackAt: new Date() } }, { new: true }); - - if (!updatedCheck) { - throw new Error("Check not found"); + // Update user + try { + await this.User.updateMany({ teamId: teamId }, { checkTTL: ttl }); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "updateChecksTTL"; + throw error; } + }; +} - return updatedCheck; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "ackCheck"; - throw error; - } -}; - -/** - * Update the acknowledgment status of all checks for a monitor or team - * @async - * @param {string} id - The monitor ID or team ID - * @param {boolean} ack - The acknowledgment status to set - * @param {string} path - The path type ('monitor' or 'team') - * @returns {Promise} - * @throws {Error} - */ -const ackAllChecks = async (monitorId, teamId, ack, path) => { - try { - const updatedChecks = await Check.updateMany(path === "monitor" ? { monitorId } : { teamId }, { $set: { ack, ackAt: new Date() } }); - return updatedChecks.modifiedCount; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "ackAllChecks"; - throw error; - } -}; - -/** - * Get checks and summary by team ID - * @async - * @param {string} teamId - * @returns {Promise} - * @throws {Error} - */ -const getChecksSummaryByTeamId = async ({ teamId }) => { - try { - const matchStage = { - teamId: new ObjectId(teamId), - }; - const checks = await Check.aggregate(buildChecksSummaryByTeamIdPipeline({ matchStage })); - return checks[0].summary; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getChecksSummaryByTeamId"; - throw error; - } -}; - -/** - * Delete all checks for a monitor - * @async - * @param {string} monitorId - * @returns {number} - * @throws {Error} - */ - -const deleteChecks = async (monitorId) => { - try { - const result = await Check.deleteMany({ monitorId }); - return result.deletedCount; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteChecks"; - throw error; - } -}; - -/** - * Delete all checks for a team - * @async - * @param {string} monitorId - * @returns {number} - * @throws {Error} - */ - -const deleteChecksByTeamId = async (teamId) => { - try { - // Find all monitor IDs for this team (only get _id field for efficiency) - const teamMonitors = await Monitor.find({ teamId }, { _id: 1 }); - const monitorIds = teamMonitors.map((monitor) => monitor._id); - - // Delete all checks for these monitors in one operation - const deleteResult = await Check.deleteMany({ monitorId: { $in: monitorIds } }); - - return deleteResult.deletedCount; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteChecksByTeamId"; - throw error; - } -}; - -const updateChecksTTL = async (teamId, ttl) => { - try { - await Check.collection.dropIndex("expiry_1"); - } catch (error) { - logger.error({ - message: error.message, - service: SERVICE_NAME, - method: "updateChecksTTL", - stack: error.stack, - }); - } - - try { - await Check.collection.createIndex( - { expiry: 1 }, - { expireAfterSeconds: ttl } // TTL in seconds, adjust as necessary - ); - } catch (error) { - error.service = SERVICE_NAME; - error.method = "updateChecksTTL"; - throw error; - } - // Update user - try { - await User.updateMany({ teamId: teamId }, { checkTTL: ttl }); - } catch (error) { - error.service = SERVICE_NAME; - error.method = "updateChecksTTL"; - throw error; - } -}; - -export { - createCheck, - createChecks, - getChecksByMonitor, - getChecksByTeam, - ackCheck, - ackAllChecks, - getChecksSummaryByTeamId, - deleteChecks, - deleteChecksByTeamId, - updateChecksTTL, -}; +export default CheckModule; diff --git a/server/src/service/business/checkService.js b/server/src/service/business/checkService.js index 262d8e863..c5a4df88d 100644 --- a/server/src/service/business/checkService.js +++ b/server/src/service/business/checkService.js @@ -34,7 +34,7 @@ class CheckService { } let { type, sortOrder, dateRange, filter, ack, page, rowsPerPage, status } = query; - const result = await this.db.getChecksByMonitor({ + const result = await this.db.checkModule.getChecksByMonitor({ monitorId, type, sortOrder, @@ -55,7 +55,7 @@ class CheckService { throw this.errorService.createBadRequestError("No team ID in request"); } - const checkData = await this.db.getChecksByTeam({ + const checkData = await this.db.checkModule.getChecksByTeam({ sortOrder, dateRange, filter, @@ -72,7 +72,7 @@ class CheckService { throw this.errorService.createBadRequestError("No team ID in request"); } - const summary = await this.db.getChecksSummaryByTeamId({ teamId }); + const summary = await this.db.checkModule.getChecksSummaryByTeamId({ teamId }); return summary; }; @@ -85,7 +85,7 @@ class CheckService { throw this.errorService.createBadRequestError("No team ID in request"); } - const updatedCheck = await this.db.ackCheck(checkId, teamId, ack); + const updatedCheck = await this.db.checkModule.ackCheck(checkId, teamId, ack); return updatedCheck; }; @@ -105,7 +105,7 @@ class CheckService { } } - const updatedChecks = await this.db.ackAllChecks(monitorId, teamId, ack, path); + const updatedChecks = await this.db.checkModule.ackAllChecks(monitorId, teamId, ack, path); return updatedChecks; }; @@ -128,7 +128,7 @@ class CheckService { throw this.errorService.createAuthorizationError(); } - const deletedCount = await this.db.deleteChecks(monitorId); + const deletedCount = await this.db.checkModule.deleteChecks(monitorId); return deletedCount; }; deleteChecksByTeamId = async ({ teamId }) => { @@ -136,14 +136,14 @@ class CheckService { throw this.errorService.createBadRequestError("No team ID in request"); } - const deletedCount = await this.db.deleteChecksByTeamId(teamId); + const deletedCount = await this.db.checkModule.deleteChecksByTeamId(teamId); return deletedCount; }; updateChecksTTL = async ({ teamId, ttl }) => { const SECONDS_PER_DAY = 86400; const newTTL = parseInt(ttl, 10) * SECONDS_PER_DAY; - await this.db.updateChecksTTL(teamId, newTTL); + await this.db.checkModule.updateChecksTTL(teamId, newTTL); }; } diff --git a/server/src/service/business/monitorService.js b/server/src/service/business/monitorService.js index d307e1837..94be6984c 100644 --- a/server/src/service/business/monitorService.js +++ b/server/src/service/business/monitorService.js @@ -153,7 +153,7 @@ class MonitorService { monitors.map(async (monitor) => { try { await this.jobQueue.deleteJob(monitor); - await this.db.deleteChecks(monitor._id); + await this.db.checkModule.deleteChecks(monitor._id); await this.db.deletePageSpeedChecksByMonitorId(monitor._id); await this.db.deleteNotificationsByMonitorId(monitor._id); } catch (error) { diff --git a/server/src/service/infrastructure/bufferService.js b/server/src/service/infrastructure/bufferService.js index d67e917d7..e772c313d 100755 --- a/server/src/service/infrastructure/bufferService.js +++ b/server/src/service/infrastructure/bufferService.js @@ -21,7 +21,7 @@ class BufferService { hardwareChecks: [], }; this.OPERATION_MAP = { - checks: this.db.createChecks, + checks: this.db.checkModule.createChecks, pagespeedChecks: this.db.createPageSpeedChecks, hardwareChecks: this.db.createHardwareChecks, }; From da7d9c02c870856bf721a99d95df91493b189278 Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Wed, 30 Jul 2025 00:25:53 +0530 Subject: [PATCH 121/226] Used Parent State And Set Minor Changes --- client/src/Hooks/monitorHooks.js | 2 +- .../Settings/SettingsGlobalThresholds.jsx | 43 +++++-------------- 2 files changed, 11 insertions(+), 34 deletions(-) diff --git a/client/src/Hooks/monitorHooks.js b/client/src/Hooks/monitorHooks.js index 1d57ffd5f..616a37877 100644 --- a/client/src/Hooks/monitorHooks.js +++ b/client/src/Hooks/monitorHooks.js @@ -302,7 +302,7 @@ const useCreateMonitor = () => { const useFetchGlobalSettings = () => { const [isLoading, setIsLoading] = useState(true); - const [globalSettings, setGlobalSettings] = useState(null); + const [globalSettings, setGlobalSettings] = useState(undefined); useEffect(() => { const fetchGlobalSettings = async () => { try { diff --git a/client/src/Pages/Settings/SettingsGlobalThresholds.jsx b/client/src/Pages/Settings/SettingsGlobalThresholds.jsx index 96f14e62d..b4d2581d2 100644 --- a/client/src/Pages/Settings/SettingsGlobalThresholds.jsx +++ b/client/src/Pages/Settings/SettingsGlobalThresholds.jsx @@ -6,7 +6,6 @@ import TextInput from "../../Components/Inputs/TextInput"; import { useTheme } from "@emotion/react"; import { PropTypes } from "prop-types"; -import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; const SettingsGlobalThresholds = ({ @@ -18,26 +17,7 @@ const SettingsGlobalThresholds = ({ const { t } = useTranslation(); // For language translation const theme = useTheme(); // MUI theme access - // Local state for thresholds - const [thresholds, setThresholds] = useState({ - cpu: "", - memory: "", - disk: "", - temperature: "", - }); - - // Load existing thresholds from settingsData - - if (settingsData?.settings?.globalThresholds) { - setThresholds({ - cpu: settingsData.settings.globalThresholds.cpu || "", - memory: settingsData.settings.globalThresholds.memory || "", - disk: settingsData.settings.globalThresholds.disk || "", - temperature: settingsData.settings.globalThresholds.temperature || "", - }); - } - - // Handles input change and updates state & parent data + // Handles input change and updates parent state const handleChange = (e, min, max) => { const { name, value } = e.target; @@ -47,19 +27,16 @@ const SettingsGlobalThresholds = ({ (!isNaN(numValue) && isFinite(numValue) && numValue >= min && numValue <= max); if (isValidNumber) { - const updatedThresholds = { - ...thresholds, - [name]: value, - }; - - setThresholds(updatedThresholds); - setSettingsData({ - ...settingsData, + setSettingsData((prev) => ({ + ...prev, settings: { - ...settingsData.settings, - globalThresholds: updatedThresholds, + ...prev.settings, + globalThresholds: { + ...prev.settings?.globalThresholds, + [name]: value, + }, }, - }); + })); } }; @@ -98,7 +75,7 @@ const SettingsGlobalThresholds = ({ label={label} placeholder={`${min} - ${max}`} type="number" - value={thresholds[name]} + value={settingsData?.settings?.globalThresholds?.[name] || ""} onChange={(e) => handleChange(e, min, max)} /> ))} From ca02ed9d18b82709648b402db426038de573c348 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 29 Jul 2025 19:53:11 -0700 Subject: [PATCH 122/226] refactor invite module into class --- server/src/config/services.js | 5 +- server/src/db/mongo/MongoDB.js | 16 +-- server/src/db/mongo/modules/inviteModule.js | 133 +++++++------------ server/src/service/business/inviteService.js | 6 +- server/src/service/business/userService.js | 2 +- 5 files changed, 60 insertions(+), 102 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index c2ecae200..ae3a4d27b 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -38,7 +38,9 @@ import HardwareCheck from "../db/models/HardwareCheck.js"; import PageSpeedCheck from "../db/models/PageSpeedCheck.js"; import Monitor from "../db/models/Monitor.js"; import User from "../db/models/User.js"; +import InviteToken from "../db/models/InviteToken.js"; +import InviteModule from "../db/mongo/modules/inviteModule.js"; import CheckModule from "../db/mongo/modules/checkModule.js"; export const initializeServices = async ({ logger, envSettings, settingsService }) => { @@ -52,7 +54,8 @@ export const initializeServices = async ({ logger, envSettings, settingsService // Create DB const checkModule = new CheckModule({ logger, Check, HardwareCheck, PageSpeedCheck, Monitor, User }); - const db = new MongoDB({ logger, envSettings, checkModule }); + const inviteModule = new InviteModule({ InviteToken, crypto, stringService }); + const db = new MongoDB({ logger, envSettings, checkModule, inviteModule }); await db.connect(); const networkService = new NetworkService(axios, ping, logger, http, Docker, net, stringService, settingsService); diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index ef7179d35..1126b5ca6 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -7,12 +7,6 @@ import AppSettings from "../models/AppSettings.js"; import * as userModule from "./modules/userModule.js"; -//**************************************** -// Invite Token Operations -//**************************************** - -import * as inviteModule from "./modules/inviteModule.js"; - //**************************************** // Recovery Operations //**************************************** @@ -35,12 +29,6 @@ import * as pageSpeedCheckModule from "./modules/pageSpeedCheckModule.js"; //**************************************** import * as hardwareCheckModule from "./modules/hardwareCheckModule.js"; -//**************************************** -// Checks -//**************************************** - -import * as checkModule from "./modules/checkModule.js"; - //**************************************** // Maintenance Window //**************************************** @@ -69,11 +57,11 @@ import * as diagnosticModule from "./modules/diagnosticModule.js"; class MongoDB { static SERVICE_NAME = "MongoDB"; - constructor({ logger, envSettings, checkModule }) { + constructor({ logger, envSettings, checkModule, inviteModule }) { this.logger = logger; this.envSettings = envSettings; Object.assign(this, userModule); - Object.assign(this, inviteModule); + this.inviteModule = inviteModule; Object.assign(this, recoveryModule); Object.assign(this, monitorModule); Object.assign(this, pageSpeedCheckModule); diff --git a/server/src/db/mongo/modules/inviteModule.js b/server/src/db/mongo/modules/inviteModule.js index 89d8ad8d7..444e51efd 100755 --- a/server/src/db/mongo/modules/inviteModule.js +++ b/server/src/db/mongo/modules/inviteModule.js @@ -1,89 +1,56 @@ -import InviteToken from "../../models/InviteToken.js"; -import crypto from "crypto"; -import ServiceRegistry from "../../../service/system/serviceRegistry.js"; -import StringService from "../../../service/system/stringService.js"; - const SERVICE_NAME = "inviteModule"; -/** - * Request an invite token for a user. - * - * This function deletes any existing invite tokens for the user's email, - * generates a new token, saves it, and then returns the new token. - * - * @param {Object} userData - The user data. - * @param {string} userData.email - The user's email. - * @param {mongoose.Schema.Types.ObjectId} userData.teamId - The ID of the team. - * @param {Array} userData.role - The user's role(s). - * @param {Date} [userData.expiry=Date.now] - The expiry date of the token. Defaults to the current date and time. - * @returns {Promise} The invite token. - * @throws {Error} If there is an error. - */ -const requestInviteToken = async (userData) => { - try { - await InviteToken.deleteMany({ email: userData.email }); - userData.token = crypto.randomBytes(32).toString("hex"); - let inviteToken = new InviteToken(userData); - await inviteToken.save(); - return inviteToken; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "requestInviteToken"; - throw error; - } -}; -/** - * Retrieves an invite token - * - * This function searches for an invite token in the database and deletes it. - * If the invite token is not found, it throws an error. - * - * @param {string} token - The invite token to search for. - * @returns {Promise} The invite token data. - * @throws {Error} If the invite token is not found or there is another error. - */ -const getInviteToken = async (token) => { - const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); - try { - const invite = await InviteToken.findOne({ - token, - }); - if (invite === null) { - throw new Error(stringService.authInviteNotFound); +class InviteModule { + constructor({ InviteToken, crypto, stringService }) { + this.InviteToken = InviteToken; + this.crypto = crypto; + this.stringService = stringService; + } + + requestInviteToken = async (userData) => { + try { + await this.InviteToken.deleteMany({ email: userData.email }); + userData.token = this.crypto.randomBytes(32).toString("hex"); + let inviteToken = new this.InviteToken(userData); + await inviteToken.save(); + return inviteToken; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "requestInviteToken"; + throw error; } - return invite; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getInviteToken"; - throw error; - } -}; + }; -/** - * Retrieves and deletes an invite token - * - * This function searches for an invite token in the database and deletes it. - * If the invite token is not found, it throws an error. - * - * @param {string} token - The invite token to search for. - * @returns {Promise} The invite token data. - * @throws {Error} If the invite token is not found or there is another error. - */ -const getInviteTokenAndDelete = async (token) => { - const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); - try { - const invite = await InviteToken.findOneAndDelete({ - token, - }); - if (invite === null) { - throw new Error(stringService.authInviteNotFound); + getInviteToken = async (token) => { + try { + const invite = await this.InviteToken.findOne({ + token, + }); + if (invite === null) { + throw new Error(this.stringService.authInviteNotFound); + } + return invite; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getInviteToken"; + throw error; } - return invite; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getInviteTokenAndDelete"; - throw error; - } -}; + }; + getInviteTokenAndDelete = async (token) => { + try { + const invite = await this.InviteToken.findOneAndDelete({ + token, + }); + if (invite === null) { + throw new Error(this.stringService.authInviteNotFound); + } + return invite; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getInviteTokenAndDelete"; + throw error; + } + }; +} -export { requestInviteToken, getInviteToken, getInviteTokenAndDelete }; +export default InviteModule; diff --git a/server/src/service/business/inviteService.js b/server/src/service/business/inviteService.js index 161045971..369ed195a 100644 --- a/server/src/service/business/inviteService.js +++ b/server/src/service/business/inviteService.js @@ -17,12 +17,12 @@ class InviteService { getInviteToken = async ({ invite, teamId }) => { invite.teamId = teamId; - const inviteToken = await this.db.requestInviteToken(invite); + const inviteToken = await this.db.inviteModule.requestInviteToken(invite); return inviteToken; }; sendInviteEmail = async ({ inviteRequest, firstName }) => { - const inviteToken = await this.db.requestInviteToken({ ...inviteRequest }); + const inviteToken = await this.db.inviteModule.requestInviteToken({ ...inviteRequest }); const { clientHost } = this.settingsService.getSettings(); const html = await this.emailService.buildEmail("employeeActivationTemplate", { @@ -36,7 +36,7 @@ class InviteService { }; verifyInviteToken = async ({ inviteToken }) => { - const invite = await this.db.getInviteToken(inviteToken); + const invite = await this.db.inviteModule.getInviteToken(inviteToken); return invite; }; } diff --git a/server/src/service/business/userService.js b/server/src/service/business/userService.js index d0d1a1205..5eb776376 100644 --- a/server/src/service/business/userService.js +++ b/server/src/service/business/userService.js @@ -31,7 +31,7 @@ class UserService { // If superAdmin exists, a token should be attached to all further register requests const superAdminExists = await this.db.checkSuperadmin(); if (superAdminExists) { - const invitedUser = await this.db.getInviteTokenAndDelete(user.inviteToken); + const invitedUser = await this.db.inviteModule.getInviteTokenAndDelete(user.inviteToken); user.role = invitedUser.role; user.teamId = invitedUser.teamId; } else { From e04ac77ddd11bf53fb456ddd525ec2fc183fc0be Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Wed, 30 Jul 2025 16:39:41 +0530 Subject: [PATCH 123/226] Ran npm start format --- .../Settings/SettingsGlobalThresholds.jsx | 190 +++++++++--------- client/src/Pages/Settings/index.jsx | 5 +- client/src/Validation/validation.js | 15 +- client/src/locales/en.json | 4 +- 4 files changed, 107 insertions(+), 107 deletions(-) diff --git a/client/src/Pages/Settings/SettingsGlobalThresholds.jsx b/client/src/Pages/Settings/SettingsGlobalThresholds.jsx index b4d2581d2..acc101ab4 100644 --- a/client/src/Pages/Settings/SettingsGlobalThresholds.jsx +++ b/client/src/Pages/Settings/SettingsGlobalThresholds.jsx @@ -1,95 +1,95 @@ -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import ConfigBox from "../../Components/ConfigBox"; -import TextInput from "../../Components/Inputs/TextInput"; - -import { useTheme } from "@emotion/react"; -import { PropTypes } from "prop-types"; -import { useTranslation } from "react-i18next"; - -const SettingsGlobalThresholds = ({ - isAdmin, - HEADING_SX, - settingsData, - setSettingsData, -}) => { - const { t } = useTranslation(); // For language translation - const theme = useTheme(); // MUI theme access - - // Handles input change and updates parent state - const handleChange = (e, min, max) => { - const { name, value } = e.target; - - const numValue = parseFloat(value); - const isValidNumber = - value === "" || - (!isNaN(numValue) && isFinite(numValue) && numValue >= min && numValue <= max); - - if (isValidNumber) { - setSettingsData((prev) => ({ - ...prev, - settings: { - ...prev.settings, - globalThresholds: { - ...prev.settings?.globalThresholds, - [name]: value, - }, - }, - })); - } - }; - - // Only render this section for admins - if (!isAdmin) return null; - - return ( - - {/* Header and description */} - - - {t("settingsPage.globalThresholds.title", "Global Thresholds")} - - - {t( - "settingsPage.globalThresholds.description", - "Configure global CPU, Memory, Disk, and Temperature thresholds." - )} - - - - {/* Threshold inputs */} - - {[ - ["CPU Threshold (%)", "cpu", 1, 100], - ["Memory Threshold (%)", "memory", 1, 100], - ["Disk Threshold (%)", "disk", 1, 100], - ["Temperature Threshold (°C)", "temperature", 1, 150], - ].map(([label, name, min, max]) => ( - handleChange(e, min, max)} - /> - ))} - - - ); -}; - -// Prop types -SettingsGlobalThresholds.propTypes = { - isAdmin: PropTypes.bool, - HEADING_SX: PropTypes.object, - settingsData: PropTypes.object, - setSettingsData: PropTypes.func, -}; - -export default SettingsGlobalThresholds; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import ConfigBox from "../../Components/ConfigBox"; +import TextInput from "../../Components/Inputs/TextInput"; + +import { useTheme } from "@emotion/react"; +import { PropTypes } from "prop-types"; +import { useTranslation } from "react-i18next"; + +const SettingsGlobalThresholds = ({ + isAdmin, + HEADING_SX, + settingsData, + setSettingsData, +}) => { + const { t } = useTranslation(); // For language translation + const theme = useTheme(); // MUI theme access + + // Handles input change and updates parent state + const handleChange = (e, min, max) => { + const { name, value } = e.target; + + const numValue = parseFloat(value); + const isValidNumber = + value === "" || + (!isNaN(numValue) && isFinite(numValue) && numValue >= min && numValue <= max); + + if (isValidNumber) { + setSettingsData((prev) => ({ + ...prev, + settings: { + ...prev.settings, + globalThresholds: { + ...prev.settings?.globalThresholds, + [name]: value, + }, + }, + })); + } + }; + + // Only render this section for admins + if (!isAdmin) return null; + + return ( + + {/* Header and description */} + + + {t("settingsPage.globalThresholds.title", "Global Thresholds")} + + + {t( + "settingsPage.globalThresholds.description", + "Configure global CPU, Memory, Disk, and Temperature thresholds." + )} + + + + {/* Threshold inputs */} + + {[ + ["CPU Threshold (%)", "cpu", 1, 100], + ["Memory Threshold (%)", "memory", 1, 100], + ["Disk Threshold (%)", "disk", 1, 100], + ["Temperature Threshold (°C)", "temperature", 1, 150], + ].map(([label, name, min, max]) => ( + handleChange(e, min, max)} + /> + ))} + + + ); +}; + +// Prop types +SettingsGlobalThresholds.propTypes = { + isAdmin: PropTypes.bool, + HEADING_SX: PropTypes.object, + settingsData: PropTypes.object, + setSettingsData: PropTypes.func, +}; + +export default SettingsGlobalThresholds; diff --git a/client/src/Pages/Settings/index.jsx b/client/src/Pages/Settings/index.jsx index ab529eb68..92f58b4c0 100644 --- a/client/src/Pages/Settings/index.jsx +++ b/client/src/Pages/Settings/index.jsx @@ -49,7 +49,7 @@ const Settings = () => { setIsApiKeySet, setIsEmailPasswordSet, }); - + const [addDemoMonitors, isAddingDemoMonitors] = useAddDemoMonitors(); const [isSaving, saveError, saveSettings] = useSaveSettings({ @@ -62,7 +62,6 @@ const Settings = () => { const [deleteAllMonitors, isDeletingMonitors] = useDeleteAllMonitors(); const [deleteMonitorStats, isDeletingMonitorStats] = useDeleteMonitorStats(); - // Setup const isAdmin = useIsAdmin(); const theme = useTheme(); @@ -152,7 +151,7 @@ const Settings = () => { error.details.forEach((err) => { newErrors[err.path[0]] = err.message; }); - + setErrors(newErrors); } saveSettings(settingsData?.settings); diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index 22ea042d7..9c6699d4b 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -302,15 +302,16 @@ const settingsValidation = joi.object({ systemEmailIgnoreTLS: joi.boolean(), systemEmailRequireTLS: joi.boolean(), systemEmailRejectUnauthorized: joi.boolean(), - globalThresholds: joi.object({ - cpu: joi.number().min(1).max(100).allow("").optional(), - memory: joi.number().min(1).max(100).allow("").optional(), - disk: joi.number().min(1).max(100).allow("").optional(), - temperature: joi.number().min(1).max(150).allow("").optional(), - }).optional(), + globalThresholds: joi + .object({ + cpu: joi.number().min(1).max(100).allow("").optional(), + memory: joi.number().min(1).max(100).allow("").optional(), + disk: joi.number().min(1).max(100).allow("").optional(), + temperature: joi.number().min(1).max(150).allow("").optional(), + }) + .optional(), }); - const dayjsValidator = (value, helpers) => { if (!dayjs(value).isValid()) { return helpers.error("any.invalid"); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 5c69ecd81..86362d3c7 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -838,8 +838,8 @@ }, "title": "Settings", "globalThresholds": { - "title": "Global Thresholds", - "description": "Configure global CPU, Memory, Disk, and Temperature thresholds. If a value is provided, it will automatically be enabled for monitoring." + "title": "Global Thresholds", + "description": "Configure global CPU, Memory, Disk, and Temperature thresholds. If a value is provided, it will automatically be enabled for monitoring." }, "uiSettings": { "description": "Switch between light and dark mode, or change user interface language.", From d4ecb26ffe8b2de2f77f7e099a8379b4cf512266 Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Wed, 30 Jul 2025 18:30:04 +0530 Subject: [PATCH 124/226] Updated UseEffect --- client/src/Pages/Infrastructure/Create/index.jsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx index 3159a3529..9b8f4f767 100644 --- a/client/src/Pages/Infrastructure/Create/index.jsx +++ b/client/src/Pages/Infrastructure/Create/index.jsx @@ -93,8 +93,8 @@ const CreateInfrastructureMonitor = () => { // Populate form fields if editing useEffect(() => { - if (!isCreate || globalSettingsLoading) return; - const globalThresholds = globalSettings?.data?.settings?.globalThresholds || {}; + if (monitor == undefined && (!isCreate || globalSettingsLoading)) return; + const globalThresholds = globalSettings?.data?.settings?.globalThresholds; // Define default empty string for all thresholds const defaultThresholds = { @@ -106,6 +106,10 @@ const CreateInfrastructureMonitor = () => { setInfrastructureMonitor((prev) => ({ ...prev, + url: monitor != undefined ? monitor.url.replace(/^https?:\/\//, "") : "", + name: monitor != undefined ? monitor.name || "" : "", + notifications: monitor != undefined ? monitor.notifications : [], + interval: monitor != undefined ? monitor.interval / MS_PER_MINUTE : 0.25, cpu: globalThresholds.cpu != null, usage_cpu: globalThresholds.cpu != null @@ -130,7 +134,7 @@ const CreateInfrastructureMonitor = () => { ? globalThresholds.temperature.toString() : defaultThresholds.temperature, })); - }, [isCreate, globalSettings]); + }, [isCreate, globalSettings, monitor, globalSettingsLoading]); // Handlers const onSubmit = async (event) => { From 0ecbf48f5d78f1c0704149bbd1bc94cc425b6dac Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Wed, 30 Jul 2025 19:18:24 +0530 Subject: [PATCH 125/226] client/src/Pages/Infrastructure/Create/index.jsx --- .../src/Pages/Infrastructure/Create/index.jsx | 73 ++++++++++--------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/client/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx index 9b8f4f767..be9cb6062 100644 --- a/client/src/Pages/Infrastructure/Create/index.jsx +++ b/client/src/Pages/Infrastructure/Create/index.jsx @@ -93,10 +93,7 @@ const CreateInfrastructureMonitor = () => { // Populate form fields if editing useEffect(() => { - if (monitor == undefined && (!isCreate || globalSettingsLoading)) return; const globalThresholds = globalSettings?.data?.settings?.globalThresholds; - - // Define default empty string for all thresholds const defaultThresholds = { cpu: "", memory: "", @@ -104,37 +101,45 @@ const CreateInfrastructureMonitor = () => { temperature: "", }; - setInfrastructureMonitor((prev) => ({ - ...prev, - url: monitor != undefined ? monitor.url.replace(/^https?:\/\//, "") : "", - name: monitor != undefined ? monitor.name || "" : "", - notifications: monitor != undefined ? monitor.notifications : [], - interval: monitor != undefined ? monitor.interval / MS_PER_MINUTE : 0.25, - cpu: globalThresholds.cpu != null, - usage_cpu: - globalThresholds.cpu != null - ? globalThresholds.cpu.toString() - : defaultThresholds.cpu, - - memory: globalThresholds.memory != null, - usage_memory: - globalThresholds.memory != null - ? globalThresholds.memory.toString() - : defaultThresholds.memory, - - disk: globalThresholds.disk != null, - usage_disk: - globalThresholds.disk != null - ? globalThresholds.disk.toString() - : defaultThresholds.disk, - - temperature: globalThresholds.temperature != null, - usage_temperature: - globalThresholds.temperature != null - ? globalThresholds.temperature.toString() - : defaultThresholds.temperature, - })); - }, [isCreate, globalSettings, monitor, globalSettingsLoading]); + if (isCreate) { + // If global settings are not loaded yet, use default thresholds + if (globalSettingsLoading) return; + // Create mode: use global thresholds + setInfrastructureMonitor((prev) => ({ + ...prev, + url: "", + name: "", + notifications: [], + interval: 0.25, + cpu: globalThresholds?.cpu != null, + usage_cpu: globalThresholds?.cpu?.toString() || defaultThresholds.cpu, + memory: globalThresholds?.memory != null, + usage_memory: globalThresholds?.memory?.toString() || defaultThresholds.memory, + disk: globalThresholds?.disk != null, + usage_disk: globalThresholds?.disk?.toString() || defaultThresholds.disk, + temperature: globalThresholds?.temperature != null, + usage_temperature: + globalThresholds?.temperature?.toString() || defaultThresholds.temperature, + })); + } else if (monitor) { + // Edit mode: use monitor only, ignore global thresholds + setInfrastructureMonitor((prev) => ({ + ...prev, + url: monitor.url?.replace(/^https?:\/\//, "") || "", + name: monitor.name || "", + notifications: monitor.notifications || [], + interval: monitor.interval / MS_PER_MINUTE, + cpu: !!monitor.thresholds?.usage_cpu ?? false, + usage_cpu: monitor.thresholds?.usage_cpu * 100?.toString() || "", + memory: !!monitor.thresholds?.usage_memory ?? false, + usage_memory: monitor.thresholds?.usage_memory * 100?.toString() || "", + disk: !!monitor.thresholds?.usage_disk ?? false, + usage_disk: monitor.thresholds?.usage_disk * 100?.toString() || "", + temperature: !!monitor.thresholds?.usage_temperature ?? false, + usage_temperature: monitor.thresholds?.usage_temperature * 100?.toString() || "", + })); + } + }, [isCreate, monitor, globalSettings, globalSettingsLoading]); // Handlers const onSubmit = async (event) => { From 5e46a9f2d51e4b93bf0e56b1dee5cceb72df3752 Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Wed, 30 Jul 2025 19:38:23 +0530 Subject: [PATCH 126/226] Pushed global thresholds inside create if branch --- client/src/Pages/Infrastructure/Create/index.jsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx index be9cb6062..73a11c40f 100644 --- a/client/src/Pages/Infrastructure/Create/index.jsx +++ b/client/src/Pages/Infrastructure/Create/index.jsx @@ -93,18 +93,18 @@ const CreateInfrastructureMonitor = () => { // Populate form fields if editing useEffect(() => { - const globalThresholds = globalSettings?.data?.settings?.globalThresholds; - const defaultThresholds = { - cpu: "", - memory: "", - disk: "", - temperature: "", - }; - if (isCreate) { // If global settings are not loaded yet, use default thresholds if (globalSettingsLoading) return; // Create mode: use global thresholds + const globalThresholds = globalSettings?.data?.settings?.globalThresholds; + const defaultThresholds = { + cpu: "", + memory: "", + disk: "", + temperature: "", + }; + setInfrastructureMonitor((prev) => ({ ...prev, url: "", From 78a5b2e6667fc1f15986f4f9f318f856112f4397 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 30 Jul 2025 09:18:16 -0700 Subject: [PATCH 127/226] fix crypto dependency --- server/src/service/business/userService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/service/business/userService.js b/server/src/service/business/userService.js index 5eb776376..4e6a41ab0 100644 --- a/server/src/service/business/userService.js +++ b/server/src/service/business/userService.js @@ -36,7 +36,7 @@ class UserService { user.teamId = invitedUser.teamId; } else { // This is the first account, create JWT secret to use if one is not supplied by env - const jwtSecret = crypto.randomBytes(64).toString("hex"); + const jwtSecret = this.crypto.randomBytes(64).toString("hex"); await this.db.updateAppSettings({ jwtSecret }); } From 753a904c947cf99f0664ca64c64a08490558af40 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 30 Jul 2025 10:20:33 -0700 Subject: [PATCH 128/226] refacotr status page module into class --- server/src/config/services.js | 7 +- .../src/controllers/statusPageController.js | 10 +- server/src/db/mongo/MongoDB.js | 9 +- .../src/db/mongo/modules/statusPageModule.js | 541 +++++++++--------- server/src/service/business/monitorService.js | 2 +- 5 files changed, 282 insertions(+), 287 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index ae3a4d27b..f5c9b3066 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -33,15 +33,19 @@ import jwt from "jsonwebtoken"; import crypto from "crypto"; // DB Modules +import { NormalizeData } from "../utils/dataUtils.js"; + import Check from "../db/models/Check.js"; import HardwareCheck from "../db/models/HardwareCheck.js"; import PageSpeedCheck from "../db/models/PageSpeedCheck.js"; import Monitor from "../db/models/Monitor.js"; import User from "../db/models/User.js"; import InviteToken from "../db/models/InviteToken.js"; +import StatusPage from "../db/models/StatusPage.js"; import InviteModule from "../db/mongo/modules/inviteModule.js"; import CheckModule from "../db/mongo/modules/checkModule.js"; +import StatusPageModule from "../db/mongo/modules/statusPageModule.js"; export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); @@ -55,7 +59,8 @@ export const initializeServices = async ({ logger, envSettings, settingsService // Create DB const checkModule = new CheckModule({ logger, Check, HardwareCheck, PageSpeedCheck, Monitor, User }); const inviteModule = new InviteModule({ InviteToken, crypto, stringService }); - const db = new MongoDB({ logger, envSettings, checkModule, inviteModule }); + const statusPageModule = new StatusPageModule({ StatusPage, NormalizeData, stringService }); + const db = new MongoDB({ logger, envSettings, checkModule, inviteModule, statusPageModule }); await db.connect(); const networkService = new NetworkService(axios, ping, logger, http, Docker, net, stringService, settingsService); diff --git a/server/src/controllers/statusPageController.js b/server/src/controllers/statusPageController.js index 85211c7ff..1cbcb49a2 100755 --- a/server/src/controllers/statusPageController.js +++ b/server/src/controllers/statusPageController.js @@ -19,7 +19,7 @@ class StatusPageController extends BaseController { await imageValidation.validateAsync(req.file); const { _id, teamId } = req.user; - const statusPage = await this.db.createStatusPage({ + const statusPage = await this.db.statusPageModule.createStatusPage({ statusPageData: req.body, image: req.file, userId: _id, @@ -39,7 +39,7 @@ class StatusPageController extends BaseController { await createStatusPageBodyValidation.validateAsync(req.body); await imageValidation.validateAsync(req.file); - const statusPage = await this.db.updateStatusPage(req.body, req.file); + const statusPage = await this.db.statusPageModule.updateStatusPage(req.body, req.file); if (statusPage === null) { throw this.errorService.createNotFoundError(this.stringService.statusPageNotFound); } @@ -54,7 +54,7 @@ class StatusPageController extends BaseController { getStatusPage = this.asyncHandler( async (req, res) => { - const statusPage = await this.db.getStatusPage(); + const statusPage = await this.db.statusPageModule.getStatusPage(); return res.success({ msg: this.stringService.statusPageByUrl, data: statusPage, @@ -69,7 +69,7 @@ class StatusPageController extends BaseController { await getStatusPageParamValidation.validateAsync(req.params); await getStatusPageQueryValidation.validateAsync(req.query); - const statusPage = await this.db.getStatusPageByUrl(req.params.url, req.query.type); + const statusPage = await this.db.statusPageModule.getStatusPageByUrl(req.params.url); return res.success({ msg: this.stringService.statusPageByUrl, data: statusPage, @@ -82,7 +82,7 @@ class StatusPageController extends BaseController { getStatusPagesByTeamId = this.asyncHandler( async (req, res) => { const teamId = req.user.teamId; - const statusPages = await this.db.getStatusPagesByTeamId(teamId); + const statusPages = await this.db.statusPageModule.getStatusPagesByTeamId(teamId); return res.success({ msg: this.stringService.statusPageByTeamId, diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index 1126b5ca6..9235892a8 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -44,11 +44,6 @@ import * as notificationModule from "./modules/notificationModule.js"; //**************************************** import * as settingsModule from "./modules/settingsModule.js"; -//**************************************** -// Status Page -//**************************************** -import * as statusPageModule from "./modules/statusPageModule.js"; - //**************************************** // Diagnostic //**************************************** @@ -57,7 +52,7 @@ import * as diagnosticModule from "./modules/diagnosticModule.js"; class MongoDB { static SERVICE_NAME = "MongoDB"; - constructor({ logger, envSettings, checkModule, inviteModule }) { + constructor({ logger, envSettings, checkModule, inviteModule, statusPageModule }) { this.logger = logger; this.envSettings = envSettings; Object.assign(this, userModule); @@ -70,7 +65,7 @@ class MongoDB { Object.assign(this, maintenanceWindowModule); Object.assign(this, notificationModule); Object.assign(this, settingsModule); - Object.assign(this, statusPageModule); + this.statusPageModule = statusPageModule; Object.assign(this, diagnosticModule); } diff --git a/server/src/db/mongo/modules/statusPageModule.js b/server/src/db/mongo/modules/statusPageModule.js index e814c20f5..10710d0f7 100755 --- a/server/src/db/mongo/modules/statusPageModule.js +++ b/server/src/db/mongo/modules/statusPageModule.js @@ -1,292 +1,287 @@ -import StatusPage from "../../models/StatusPage.js"; -import { NormalizeData } from "../../../utils/dataUtils.js"; -import ServiceRegistry from "../../../service/system/serviceRegistry.js"; -import StringService from "../../../service/system/stringService.js"; +// import StatusPage from "../../models/StatusPage.js"; +// import { NormalizeData } from "../../../utils/dataUtils.js"; +// import ServiceRegistry from "../../../service/system/serviceRegistry.js"; +// import StringService from "../../../service/system/stringService.js"; const SERVICE_NAME = "statusPageModule"; -const createStatusPage = async ({ statusPageData, image, userId, teamId }) => { - const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); - - try { - const statusPage = new StatusPage({ - ...statusPageData, - userId, - teamId, - }); - if (image) { - statusPage.logo = { - data: image.buffer, - contentType: image.mimetype, - }; - } - await statusPage.save(); - return statusPage; - } catch (error) { - if (error?.code === 11000) { - // Handle duplicate URL errors - error.status = 400; - error.message = stringService.statusPageUrlNotUnique; - } - error.service = SERVICE_NAME; - error.method = "createStatusPage"; - throw error; +class StatusPageModule { + constructor({ StatusPage, NormalizeData, stringService }) { + this.StatusPage = StatusPage; + this.NormalizeData = NormalizeData; + this.stringService = stringService; } -}; -const updateStatusPage = async (statusPageData, image) => { - try { - if (image) { - statusPageData.logo = { - data: image.buffer, - contentType: image.mimetype, - }; - } else { - statusPageData.logo = null; - } - - if (statusPageData.deleteSubmonitors === "true") { - statusPageData.subMonitors = []; - } - const statusPage = await StatusPage.findOneAndUpdate({ url: statusPageData.url }, statusPageData, { - new: true, - }); - - return statusPage; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "updateStatusPage"; - throw error; - } -}; - -const getStatusPageByUrl = async (url, type) => { - // TODO This is deprecated, can remove and have controller call getStatusPage - try { - return getStatusPage(url); - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getStatusPageByUrl"; - throw error; - } -}; - -const getStatusPagesByTeamId = async (teamId) => { - try { - const statusPages = await StatusPage.find({ teamId }); - return statusPages; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getStatusPagesByTeamId"; - throw error; - } -}; - -const getStatusPage = async (url) => { - const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); - - try { - const preliminaryStatusPage = await StatusPage.findOne({ url }); - if (!preliminaryStatusPage) { - const error = new Error(stringService.statusPageNotFound); - error.status = 404; + createStatusPage = async ({ statusPageData, image, userId, teamId }) => { + try { + const statusPage = new this.StatusPage({ + ...statusPageData, + userId, + teamId, + }); + if (image) { + statusPage.logo = { + data: image.buffer, + contentType: image.mimetype, + }; + } + await statusPage.save(); + return statusPage; + } catch (error) { + if (error?.code === 11000) { + // Handle duplicate URL errors + error.status = 400; + error.message = this.stringService.statusPageUrlNotUnique; + } + error.service = SERVICE_NAME; + error.method = "createStatusPage"; throw error; } + }; - if (!preliminaryStatusPage.monitors || preliminaryStatusPage.monitors.length === 0) { - const { _id, color, companyName, isPublished, logo, originalMonitors, showCharts, showUptimePercentage, timezone, showAdminLoginLink, url } = - preliminaryStatusPage; - return { - statusPage: { - _id, - color, - companyName, - isPublished, - logo, - originalMonitors, - showCharts, - showUptimePercentage, - timezone, - showAdminLoginLink, - url, - }, - monitors: [], - }; + updateStatusPage = async (statusPageData, image) => { + try { + if (image) { + statusPageData.logo = { + data: image.buffer, + contentType: image.mimetype, + }; + } else { + statusPageData.logo = null; + } + + if (statusPageData.deleteSubmonitors === "true") { + statusPageData.subMonitors = []; + } + const statusPage = await this.StatusPage.findOneAndUpdate({ url: statusPageData.url }, statusPageData, { + new: true, + }); + + return statusPage; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "updateStatusPage"; + throw error; } + }; - const statusPageQuery = await StatusPage.aggregate([ - { $match: { url: url } }, - { - $set: { - originalMonitors: "$monitors", - }, - }, - { - $lookup: { - from: "monitors", - localField: "monitors", - foreignField: "_id", - as: "monitors", - }, - }, - { - $unwind: { - path: "$monitors", - preserveNullAndEmptyArrays: true, - }, - }, - { - $lookup: { - from: "maintenancewindows", - let: { monitorId: "$monitors._id" }, - pipeline: [{ $match: { $expr: { $eq: ["$monitorId", "$$monitorId"] } } }], - as: "monitors.maintenanceWindows", - }, - }, - { - $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"], - }, - "monitors.isMaintenance": { - $reduce: { - input: "$monitors.maintenanceWindows", - initialValue: false, - in: { - $or: [ - "$$value", - { - $and: [{ $eq: ["$$this.active", true] }, { $lte: ["$$this.start", "$$NOW"] }, { $gte: ["$$this.end", "$$NOW"] }], - }, - ], - }, - }, - }, - }, - }, - { $match: { "monitors.orderIndex": { $ne: -1 } } }, - { $sort: { "monitors.orderIndex": 1 } }, + getStatusPageByUrl = async (url) => { + // TODO This is deprecated, can remove and have controller call getStatusPage + try { + return this.getStatusPage(url); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getStatusPageByUrl"; + throw error; + } + }; - { - $group: { - _id: "$_id", - statusPage: { $first: "$$ROOT" }, - monitors: { $push: "$monitors" }, - }, - }, - { - $project: { + getStatusPagesByTeamId = async (teamId) => { + try { + const statusPages = await this.StatusPage.find({ teamId }); + return statusPages; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getStatusPagesByTeamId"; + throw error; + } + }; + + getStatusPage = async (url) => { + try { + const preliminaryStatusPage = await this.StatusPage.findOne({ url }); + if (!preliminaryStatusPage) { + const error = new Error(this.stringService.statusPageNotFound); + error.status = 404; + throw error; + } + + if (!preliminaryStatusPage.monitors || preliminaryStatusPage.monitors.length === 0) { + const { _id, color, companyName, isPublished, logo, originalMonitors, showCharts, showUptimePercentage, timezone, showAdminLoginLink, url } = + preliminaryStatusPage; + return { statusPage: { - _id: 1, - color: 1, - companyName: 1, - isPublished: 1, - logo: 1, - originalMonitors: 1, - showCharts: 1, - showUptimePercentage: 1, - timezone: 1, - showAdminLoginLink: 1, - url: 1, + _id, + color, + companyName, + isPublished, + logo, + originalMonitors, + showCharts, + showUptimePercentage, + timezone, + showAdminLoginLink, + url, }, - monitors: { - _id: 1, - userId: 1, - teamId: 1, - name: 1, - description: 1, - status: 1, - type: 1, - ignoreTlsErrors: 1, - jsonPath: 1, - expectedValue: 1, - matchMethod: 1, - url: 1, - port: 1, - isActive: 1, - interval: 1, - uptimePercentage: 1, - notifications: 1, - secret: 1, - thresholds: 1, - alertThreshold: 1, - cpuAlertThreshold: 1, - memoryAlertThreshold: 1, - diskAlertThreshold: 1, - tempAlertThreshold: 1, - checks: 1, - isMaintenance: 1, - createdAt: 1, - updatedAt: 1, + monitors: [], + }; + } + + const statusPageQuery = await this.StatusPage.aggregate([ + { $match: { url: url } }, + { + $set: { + originalMonitors: "$monitors", }, }, - }, - ]); - if (!statusPageQuery.length) { - const error = new Error(stringService.statusPageNotFound); - error.status = 404; + { + $lookup: { + from: "monitors", + localField: "monitors", + foreignField: "_id", + as: "monitors", + }, + }, + { + $unwind: { + path: "$monitors", + preserveNullAndEmptyArrays: true, + }, + }, + { + $lookup: { + from: "maintenancewindows", + let: { monitorId: "$monitors._id" }, + pipeline: [{ $match: { $expr: { $eq: ["$monitorId", "$$monitorId"] } } }], + as: "monitors.maintenanceWindows", + }, + }, + { + $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"], + }, + "monitors.isMaintenance": { + $reduce: { + input: "$monitors.maintenanceWindows", + initialValue: false, + in: { + $or: [ + "$$value", + { + $and: [{ $eq: ["$$this.active", true] }, { $lte: ["$$this.start", "$$NOW"] }, { $gte: ["$$this.end", "$$NOW"] }], + }, + ], + }, + }, + }, + }, + }, + { $match: { "monitors.orderIndex": { $ne: -1 } } }, + { $sort: { "monitors.orderIndex": 1 } }, + + { + $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, + showAdminLoginLink: 1, + url: 1, + }, + monitors: { + _id: 1, + userId: 1, + teamId: 1, + name: 1, + description: 1, + status: 1, + type: 1, + ignoreTlsErrors: 1, + jsonPath: 1, + expectedValue: 1, + matchMethod: 1, + url: 1, + port: 1, + isActive: 1, + interval: 1, + uptimePercentage: 1, + notifications: 1, + secret: 1, + thresholds: 1, + alertThreshold: 1, + cpuAlertThreshold: 1, + memoryAlertThreshold: 1, + diskAlertThreshold: 1, + tempAlertThreshold: 1, + checks: 1, + isMaintenance: 1, + createdAt: 1, + updatedAt: 1, + }, + }, + }, + ]); + if (!statusPageQuery.length) { + const error = new Error(this.stringService.statusPageNotFound); + error.status = 404; + throw error; + } + + const { statusPage, monitors } = statusPageQuery[0]; + + const normalizedMonitors = monitors.map((monitor) => { + return { + ...monitor, + checks: this.NormalizeData(monitor.checks, 10, 100), + }; + }); + + return { statusPage, monitors: normalizedMonitors }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getStatusPageByUrl"; throw error; } + }; - const { statusPage, monitors } = statusPageQuery[0]; + deleteStatusPage = async (url) => { + try { + await this.StatusPage.deleteOne({ url }); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteStatusPage"; + throw error; + } + }; + deleteStatusPagesByMonitorId = async (monitorId) => { + try { + await this.StatusPage.deleteMany({ monitors: { $in: [monitorId] } }); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteStatusPageByMonitorId"; + throw error; + } + }; +} - 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"; - throw error; - } -}; - -const deleteStatusPage = async (url) => { - try { - await StatusPage.deleteOne({ url }); - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteStatusPage"; - throw error; - } -}; - -const deleteStatusPagesByMonitorId = async (monitorId) => { - try { - await StatusPage.deleteMany({ monitors: { $in: [monitorId] } }); - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteStatusPageByMonitorId"; - throw error; - } -}; - -export { - createStatusPage, - updateStatusPage, - getStatusPagesByTeamId, - getStatusPage, - getStatusPageByUrl, - deleteStatusPage, - deleteStatusPagesByMonitorId, -}; +export default StatusPageModule; diff --git a/server/src/service/business/monitorService.js b/server/src/service/business/monitorService.js index 94be6984c..f90c21470 100644 --- a/server/src/service/business/monitorService.js +++ b/server/src/service/business/monitorService.js @@ -143,7 +143,7 @@ class MonitorService { await this.verifyTeamAccess({ teamId, monitorId }); const monitor = await this.db.deleteMonitor({ teamId, monitorId }); await this.jobQueue.deleteJob(monitor); - await this.db.deleteStatusPagesByMonitorId(monitor._id); + await this.db.statusPageModule.deleteStatusPagesByMonitorId(monitor._id); return monitor; }; From 8adad64248902e85f0a0efb13ed2fa3deea0a8e5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 30 Jul 2025 10:41:53 -0700 Subject: [PATCH 129/226] refactor module into class --- server/src/config/services.js | 9 +- server/src/db/mongo/MongoDB.js | 10 +- server/src/db/mongo/modules/userModule.js | 396 ++++++++------------- server/src/service/business/userService.js | 22 +- 4 files changed, 174 insertions(+), 263 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index f5c9b3066..8ac7c72e2 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -34,7 +34,10 @@ import crypto from "crypto"; // DB Modules import { NormalizeData } from "../utils/dataUtils.js"; +import { GenerateAvatarImage } from "../utils/imageProcessing.js"; +import { ParseBoolean } from "../utils/utils.js"; +// Models import Check from "../db/models/Check.js"; import HardwareCheck from "../db/models/HardwareCheck.js"; import PageSpeedCheck from "../db/models/PageSpeedCheck.js"; @@ -42,10 +45,12 @@ import Monitor from "../db/models/Monitor.js"; import User from "../db/models/User.js"; import InviteToken from "../db/models/InviteToken.js"; import StatusPage from "../db/models/StatusPage.js"; +import Team from "../db/models/Team.js"; import InviteModule from "../db/mongo/modules/inviteModule.js"; import CheckModule from "../db/mongo/modules/checkModule.js"; import StatusPageModule from "../db/mongo/modules/statusPageModule.js"; +import UserModule from "../db/mongo/modules/userModule.js"; export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); @@ -60,7 +65,9 @@ export const initializeServices = async ({ logger, envSettings, settingsService const checkModule = new CheckModule({ logger, Check, HardwareCheck, PageSpeedCheck, Monitor, User }); const inviteModule = new InviteModule({ InviteToken, crypto, stringService }); const statusPageModule = new StatusPageModule({ StatusPage, NormalizeData, stringService }); - const db = new MongoDB({ logger, envSettings, checkModule, inviteModule, statusPageModule }); + const userModule = new UserModule({ User, Team, GenerateAvatarImage, ParseBoolean, stringService }); + const db = new MongoDB({ logger, envSettings, checkModule, inviteModule, statusPageModule, userModule }); + await db.connect(); const networkService = new NetworkService(axios, ping, logger, http, Docker, net, stringService, settingsService); diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index 9235892a8..158f68f7c 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -1,12 +1,6 @@ import mongoose from "mongoose"; import AppSettings from "../models/AppSettings.js"; -//**************************************** -// User Operations -//**************************************** - -import * as userModule from "./modules/userModule.js"; - //**************************************** // Recovery Operations //**************************************** @@ -52,10 +46,10 @@ import * as diagnosticModule from "./modules/diagnosticModule.js"; class MongoDB { static SERVICE_NAME = "MongoDB"; - constructor({ logger, envSettings, checkModule, inviteModule, statusPageModule }) { + constructor({ logger, envSettings, checkModule, inviteModule, statusPageModule, userModule }) { this.logger = logger; this.envSettings = envSettings; - Object.assign(this, userModule); + this.userModule = userModule; this.inviteModule = inviteModule; Object.assign(this, recoveryModule); Object.assign(this, monitorModule); diff --git a/server/src/db/mongo/modules/userModule.js b/server/src/db/mongo/modules/userModule.js index 974f2d2ed..83b3b15f9 100755 --- a/server/src/db/mongo/modules/userModule.js +++ b/server/src/db/mongo/modules/userModule.js @@ -1,261 +1,171 @@ -import UserModel from "../../models/User.js"; -import TeamModel from "../../models/Team.js"; -import { GenerateAvatarImage } from "../../../utils/imageProcessing.js"; - -const DUPLICATE_KEY_CODE = 11000; // MongoDB error code for duplicate key -import { ParseBoolean } from "../../../utils/utils.js"; -import ServiceRegistry from "../../../service/system/serviceRegistry.js"; -import StringService from "../../../service/system/stringService.js"; const SERVICE_NAME = "userModule"; +const DUPLICATE_KEY_CODE = 11000; // MongoDB error code for duplicate key -const checkSuperadmin = async () => { - const superAdmin = await UserModel.findOne({ role: "superadmin" }); - if (superAdmin !== null) { - return true; +class UserModule { + constructor({ User, Team, GenerateAvatarImage, ParseBoolean, stringService }) { + this.User = User; + this.Team = Team; + this.GenerateAvatarImage = GenerateAvatarImage; + this.ParseBoolean = ParseBoolean; + this.stringService = stringService; } - return false; -}; -/** - * Insert a User - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @returns {Promise} - * @throws {Error} - */ -const insertUser = async (userData, imageFile, generateAvatarImage = GenerateAvatarImage) => { - const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); - try { - if (imageFile) { - // 1. Save the full size image - userData.profileImage = { - data: imageFile.buffer, - contentType: imageFile.mimetype, - }; + checkSuperadmin = async () => { + try { + const superAdmin = await this.User.findOne({ role: "superadmin" }); + if (superAdmin !== null) { + return true; + } + return false; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "checkSuperadmin"; + throw error; + } + }; - // 2. Get the avatar sized image - const avatar = await generateAvatarImage(imageFile); - userData.avatarImage = avatar; + insertUser = async (userData, imageFile) => { + try { + if (imageFile) { + // 1. Save the full size image + userData.profileImage = { + data: imageFile.buffer, + contentType: imageFile.mimetype, + }; + + // 2. Get the avatar sized image + const avatar = await this.GenerateAvatarImage(imageFile); + userData.avatarImage = avatar; + } + + // Handle creating team if superadmin + if (userData.role.includes("superadmin")) { + const team = new this.Team({ + email: userData.email, + }); + userData.teamId = team._id; + userData.checkTTL = 60 * 60 * 24 * 30; + await team.save(); + } + + const newUser = new this.User(userData); + await newUser.save(); + return await this.User.findOne({ _id: newUser._id }).select("-password").select("-profileImage"); // .select() doesn't work with create, need to save then find + } catch (error) { + if (error.code === DUPLICATE_KEY_CODE) { + error.message = this.stringService.dbUserExists; + } + error.service = SERVICE_NAME; + error.method = "insertUser"; + throw error; + } + }; + getUserByEmail = async (email) => { + try { + // Need the password to be able to compare, removed .select() + // We can strip the hash before returning the user + const user = await this.User.findOne({ email: email }).select("-profileImage"); + if (!user) { + throw new Error(this.stringService.dbUserNotFound); + } + return user; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getUserByEmail"; + throw error; + } + }; + + updateUser = async ({ userId, user, file }) => { + if (!userId) { + throw new Error("No user in request"); } - // Handle creating team if superadmin - if (userData.role.includes("superadmin")) { - const team = new TeamModel({ - email: userData.email, - }); - userData.teamId = team._id; - userData.checkTTL = 60 * 60 * 24 * 30; - await team.save(); + try { + const candidateUser = { ...user }; + + if (this.ParseBoolean(candidateUser.deleteProfileImage) === true) { + candidateUser.profileImage = null; + candidateUser.avatarImage = null; + } else if (file) { + // 1. Save the full size image + candidateUser.profileImage = { + data: file.buffer, + contentType: file.mimetype, + }; + + // 2. Get the avatar sized image + const avatar = await this.GenerateAvatarImage(file); + candidateUser.avatarImage = avatar; + } + + const updatedUser = await this.User.findByIdAndUpdate( + userId, + candidateUser, + { new: true } // Returns updated user instead of pre-update user + ) + .select("-password") + .select("-profileImage"); + return updatedUser; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "updateUser"; + throw error; } - - const newUser = new UserModel(userData); - await newUser.save(); - return await UserModel.findOne({ _id: newUser._id }).select("-password").select("-profileImage"); // .select() doesn't work with create, need to save then find - } catch (error) { - if (error.code === DUPLICATE_KEY_CODE) { - error.message = stringService.dbUserExists; + }; + deleteUser = async (userId) => { + try { + const deletedUser = await this.User.findByIdAndDelete(userId); + if (!deletedUser) { + throw new Error(this.stringService.dbUserNotFound); + } + return deletedUser; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteUser"; + throw error; } - error.service = SERVICE_NAME; - error.method = "insertUser"; - throw error; - } -}; + }; -/** - * Get User by Email - * Gets a user by Email. Not sure if we'll ever need this except for login. - * If not needed except for login, we can move password comparison here - * Throws error if user not found - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @returns {Promise} - * @throws {Error} - */ -const getUserByEmail = async (email) => { - const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); - - try { - // Need the password to be able to compare, removed .select() - // We can strip the hash before returning the user - const user = await UserModel.findOne({ email: email }).select("-profileImage"); - if (!user) { - throw new Error(stringService.dbUserNotFound); + getAllUsers = async () => { + try { + const users = await this.User.find().select("-password").select("-profileImage"); + return users; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getAllUsers"; + throw error; } - return user; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getUserByEmail"; - throw error; - } -}; + }; -/** - * Update a user by ID - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @returns {Promise} - * @throws {Error} - */ + getUserById = async (roles, userId) => { + try { + if (!roles.includes("superadmin")) { + throw new Error("User is not a superadmin"); + } -const updateUser = async ({ userId, user, file }) => { - if (!userId) { - throw new Error("No user in request"); - } + const user = await this.User.findById(userId).select("-password").select("-profileImage"); + if (!user) { + throw new Error("User not found"); + } - try { - const candidateUser = { ...user }; - - if (ParseBoolean(candidateUser.deleteProfileImage) === true) { - candidateUser.profileImage = null; - candidateUser.avatarImage = null; - } else if (file) { - // 1. Save the full size image - candidateUser.profileImage = { - data: file.buffer, - contentType: file.mimetype, - }; - - // 2. Get the avatar sized image - const avatar = await GenerateAvatarImage(file); - candidateUser.avatarImage = avatar; + return user; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getUserById"; + throw error; } + }; - // ****************************************** - // End handling profile image - // ****************************************** - - const updatedUser = await UserModel.findByIdAndUpdate( - userId, - candidateUser, - { new: true } // Returns updated user instead of pre-update user - ) - .select("-password") - .select("-profileImage"); - return updatedUser; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "updateUser"; - throw error; - } -}; - -/** - * Delete a user by ID - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @returns {Promise} - * @throws {Error} - */ -const deleteUser = async (userId) => { - const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); - - try { - const deletedUser = await UserModel.findByIdAndDelete(userId); - if (!deletedUser) { - throw new Error(stringService.dbUserNotFound); + editUserById = async (userId, user) => { + try { + await this.User.findByIdAndUpdate(userId, user, { new: true }).select("-password").select("-profileImage"); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "editUserById"; + throw error; } - return deletedUser; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteUser"; - throw error; - } -}; + }; +} -/** - * Delete a user by ID - * @async - * @param {string} teamId - * @returns {void} - * @throws {Error} - */ -const deleteTeam = async (teamId) => { - try { - await TeamModel.findByIdAndDelete(teamId); - return true; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteTeam"; - throw error; - } -}; - -const deleteAllOtherUsers = async () => { - try { - await UserModel.deleteMany({ role: { $ne: "superadmin" } }); - return true; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteAllOtherUsers"; - throw error; - } -}; - -const getAllUsers = async () => { - try { - const users = await UserModel.find().select("-password").select("-profileImage"); - return users; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getAllUsers"; - throw error; - } -}; - -const logoutUser = async (userId) => { - try { - await UserModel.updateOne({ _id: userId }, { $unset: { authToken: null } }); - return true; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "logoutUser"; - throw error; - } -}; - -const getUserById = async (roles, userId) => { - try { - if (!roles.includes("superadmin")) { - throw new Error("User is not a superadmin"); - } - - const user = await UserModel.findById(userId).select("-password").select("-profileImage"); - if (!user) { - throw new Error("User not found"); - } - - return user; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getUserById"; - throw error; - } -}; - -const editUserById = async (userId, user) => { - try { - await UserModel.findByIdAndUpdate(userId, user, { new: true }).select("-password").select("-profileImage"); - } catch (error) { - error.service = SERVICE_NAME; - error.method = "editUserById"; - throw error; - } -}; - -export { - checkSuperadmin, - insertUser, - getUserByEmail, - updateUser, - deleteUser, - deleteTeam, - deleteAllOtherUsers, - getAllUsers, - logoutUser, - getUserById, - editUserById, -}; +export default UserModule; diff --git a/server/src/service/business/userService.js b/server/src/service/business/userService.js index 4e6a41ab0..04c14c477 100644 --- a/server/src/service/business/userService.js +++ b/server/src/service/business/userService.js @@ -29,7 +29,7 @@ class UserService { registerUser = async (user, file) => { // Create a new user // If superAdmin exists, a token should be attached to all further register requests - const superAdminExists = await this.db.checkSuperadmin(); + const superAdminExists = await this.db.userModule.checkSuperadmin(); if (superAdminExists) { const invitedUser = await this.db.inviteModule.getInviteTokenAndDelete(user.inviteToken); user.role = invitedUser.role; @@ -40,7 +40,7 @@ class UserService { await this.db.updateAppSettings({ jwtSecret }); } - const newUser = await this.db.insertUser({ ...user }, file); + const newUser = await this.db.userModule.insertUser({ ...user }, file); this.logger.debug({ message: "New user created", @@ -83,7 +83,7 @@ class UserService { loginUser = async (email, password) => { // Check if user exists - const user = await this.db.getUserByEmail(email); + const user = await this.db.userModule.getUserByEmail(email); // Compare password const match = await user.comparePassword(password); if (match !== true) { @@ -110,7 +110,7 @@ class UserService { // Add user email to body for DB operation updates.email = currentUser.email; // Get user - const user = await this.db.getUserByEmail(currentUser.email); + const user = await this.db.userModule.getUserByEmail(currentUser.email); // Compare passwords const match = await user.comparePassword(updates?.password); // If not a match, throw a 403 @@ -122,17 +122,17 @@ class UserService { updates.password = updates.newPassword; } - const updatedUser = await this.db.updateUser({ userId: currentUser?._id, user: updates, file: file }); + const updatedUser = await this.db.userModule.updateUser({ userId: currentUser?._id, user: updates, file: file }); return updatedUser; }; checkSuperadminExists = async () => { - const superAdminExists = await this.db.checkSuperadmin(); + const superAdminExists = await this.db.userModule.checkSuperadmin(); return superAdminExists; }; requestRecovery = async (email) => { - const user = await this.db.getUserByEmail(email); + const user = await this.db.userModule.getUserByEmail(email); const recoveryToken = await this.db.requestRecoveryToken(email); const name = user.firstName; const { clientHost } = this.settingsService.getSettings(); @@ -195,21 +195,21 @@ class UserService { )); } // 6. Delete the user by id - await this.db.deleteUser(userId); + await this.db.userModule.deleteUser(userId); }; getAllUsers = async () => { - const users = await this.db.getAllUsers(); + const users = await this.db.userModule.getAllUsers(); return users; }; getUserById = async (roles, userId) => { - const user = await this.db.getUserById(roles, userId); + const user = await this.db.userModule.getUserById(roles, userId); return user; }; editUserById = async (userId, user) => { - await this.db.editUserById(userId, user); + await this.db.userModule.editUserById(userId, user); }; } export default UserService; From 2d6fdcb1e2cced589f62ebd96e9fdba3331cf131 Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Wed, 30 Jul 2025 23:14:35 +0530 Subject: [PATCH 130/226] Made code readable --- .../src/Pages/Infrastructure/Create/index.jsx | 61 +++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/client/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx index 73a11c40f..d9817aae3 100644 --- a/client/src/Pages/Infrastructure/Create/index.jsx +++ b/client/src/Pages/Infrastructure/Create/index.jsx @@ -96,8 +96,9 @@ const CreateInfrastructureMonitor = () => { if (isCreate) { // If global settings are not loaded yet, use default thresholds if (globalSettingsLoading) return; + // Create mode: use global thresholds - const globalThresholds = globalSettings?.data?.settings?.globalThresholds; + const globalThresholds = globalSettings?.data?.settings?.globalThresholds || {}; const defaultThresholds = { cpu: "", memory: "", @@ -111,15 +112,26 @@ const CreateInfrastructureMonitor = () => { name: "", notifications: [], interval: 0.25, - cpu: globalThresholds?.cpu != null, - usage_cpu: globalThresholds?.cpu?.toString() || defaultThresholds.cpu, - memory: globalThresholds?.memory != null, - usage_memory: globalThresholds?.memory?.toString() || defaultThresholds.memory, - disk: globalThresholds?.disk != null, - usage_disk: globalThresholds?.disk?.toString() || defaultThresholds.disk, - temperature: globalThresholds?.temperature != null, + cpu: globalThresholds.cpu != null, + usage_cpu: + globalThresholds.cpu != null + ? globalThresholds.cpu.toString() + : defaultThresholds.cpu, + memory: globalThresholds.memory != null, + usage_memory: + globalThresholds.memory != null + ? globalThresholds.memory.toString() + : defaultThresholds.memory, + disk: globalThresholds.disk != null, + usage_disk: + globalThresholds.disk != null + ? globalThresholds.disk.toString() + : defaultThresholds.disk, + temperature: globalThresholds.temperature != null, usage_temperature: - globalThresholds?.temperature?.toString() || defaultThresholds.temperature, + globalThresholds.temperature != null + ? globalThresholds.temperature.toString() + : defaultThresholds.temperature, })); } else if (monitor) { // Edit mode: use monitor only, ignore global thresholds @@ -129,15 +141,30 @@ const CreateInfrastructureMonitor = () => { name: monitor.name || "", notifications: monitor.notifications || [], interval: monitor.interval / MS_PER_MINUTE, - cpu: !!monitor.thresholds?.usage_cpu ?? false, - usage_cpu: monitor.thresholds?.usage_cpu * 100?.toString() || "", - memory: !!monitor.thresholds?.usage_memory ?? false, - usage_memory: monitor.thresholds?.usage_memory * 100?.toString() || "", - disk: !!monitor.thresholds?.usage_disk ?? false, - usage_disk: monitor.thresholds?.usage_disk * 100?.toString() || "", - temperature: !!monitor.thresholds?.usage_temperature ?? false, - usage_temperature: monitor.thresholds?.usage_temperature * 100?.toString() || "", + cpu: monitor.thresholds?.usage_cpu != null, + usage_cpu: + monitor.thresholds?.usage_cpu != null + ? (monitor.thresholds.usage_cpu * 100).toString() + : "", + memory: monitor.thresholds?.usage_memory != null, + usage_memory: + monitor.thresholds?.usage_memory != null + ? (monitor.thresholds.usage_memory * 100).toString() + : "", + disk: monitor.thresholds?.usage_disk != null, + usage_disk: + monitor.thresholds?.usage_disk != null + ? (monitor.thresholds.usage_disk * 100).toString() + : "", + temperature: monitor.thresholds?.usage_temperature != null, + usage_temperature: + monitor.thresholds?.usage_temperature != null + ? (monitor.thresholds.usage_temperature * 100).toString() + : "", + secret: monitor.secret || "", })); + + setHttps(monitor.url?.startsWith("https")); } }, [isCreate, monitor, globalSettings, globalSettingsLoading]); From 8bacbcde893a3049f47d5ac4e7a0a2a74bd18f67 Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Wed, 30 Jul 2025 23:35:51 +0530 Subject: [PATCH 131/226] Removed Unnecassary command --- client/src/Pages/Infrastructure/Create/index.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx index d9817aae3..f2833d52f 100644 --- a/client/src/Pages/Infrastructure/Create/index.jsx +++ b/client/src/Pages/Infrastructure/Create/index.jsx @@ -164,7 +164,6 @@ const CreateInfrastructureMonitor = () => { secret: monitor.secret || "", })); - setHttps(monitor.url?.startsWith("https")); } }, [isCreate, monitor, globalSettings, globalSettingsLoading]); From 00f5eaf44d0c3e1823a9d3572ab30f300d1305c9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 30 Jul 2025 11:27:29 -0700 Subject: [PATCH 132/226] module -> class --- server/src/config/services.js | 5 +- server/src/db/mongo/MongoDB.js | 9 +- .../db/mongo/modules/hardwareCheckModule.js | 84 ++++--------------- .../service/infrastructure/bufferService.js | 2 +- 4 files changed, 24 insertions(+), 76 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index 8ac7c72e2..54f226051 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -51,7 +51,7 @@ import InviteModule from "../db/mongo/modules/inviteModule.js"; import CheckModule from "../db/mongo/modules/checkModule.js"; import StatusPageModule from "../db/mongo/modules/statusPageModule.js"; import UserModule from "../db/mongo/modules/userModule.js"; - +import HardwareCheckModule from "../db/mongo/modules/hardwareCheckModule.js"; export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); ServiceRegistry.instance = serviceRegistry; @@ -66,7 +66,8 @@ export const initializeServices = async ({ logger, envSettings, settingsService const inviteModule = new InviteModule({ InviteToken, crypto, stringService }); const statusPageModule = new StatusPageModule({ StatusPage, NormalizeData, stringService }); const userModule = new UserModule({ User, Team, GenerateAvatarImage, ParseBoolean, stringService }); - const db = new MongoDB({ logger, envSettings, checkModule, inviteModule, statusPageModule, userModule }); + const hardwareCheckModule = new HardwareCheckModule({ HardwareCheck, Monitor, logger }); + const db = new MongoDB({ logger, envSettings, checkModule, inviteModule, statusPageModule, userModule, hardwareCheckModule }); await db.connect(); diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index 158f68f7c..077aa5b73 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -18,11 +18,6 @@ import * as monitorModule from "./modules/monitorModule.js"; import * as pageSpeedCheckModule from "./modules/pageSpeedCheckModule.js"; -//**************************************** -// Hardware Checks -//**************************************** -import * as hardwareCheckModule from "./modules/hardwareCheckModule.js"; - //**************************************** // Maintenance Window //**************************************** @@ -46,7 +41,7 @@ import * as diagnosticModule from "./modules/diagnosticModule.js"; class MongoDB { static SERVICE_NAME = "MongoDB"; - constructor({ logger, envSettings, checkModule, inviteModule, statusPageModule, userModule }) { + constructor({ logger, envSettings, checkModule, inviteModule, statusPageModule, userModule, hardwareCheckModule }) { this.logger = logger; this.envSettings = envSettings; this.userModule = userModule; @@ -54,7 +49,7 @@ class MongoDB { Object.assign(this, recoveryModule); Object.assign(this, monitorModule); Object.assign(this, pageSpeedCheckModule); - Object.assign(this, hardwareCheckModule); + this.hardwareCheckModule = hardwareCheckModule; this.checkModule = checkModule; Object.assign(this, maintenanceWindowModule); Object.assign(this, notificationModule); diff --git a/server/src/db/mongo/modules/hardwareCheckModule.js b/server/src/db/mongo/modules/hardwareCheckModule.js index 9cb228bc4..448e72fed 100755 --- a/server/src/db/mongo/modules/hardwareCheckModule.js +++ b/server/src/db/mongo/modules/hardwareCheckModule.js @@ -1,70 +1,22 @@ -import HardwareCheck from "../../models/HardwareCheck.js"; -import Monitor from "../../models/Monitor.js"; -import { logger } from "../../../utils/logger.js"; - const SERVICE_NAME = "hardwareCheckModule"; -const createHardwareCheck = async (hardwareCheckData) => { - try { - const { monitorId, status } = hardwareCheckData; - const n = (await HardwareCheck.countDocuments({ monitorId })) + 1; - const monitor = await Monitor.findById(monitorId); - if (!monitor) { - logger.error({ - message: "Monitor not found", - service: SERVICE_NAME, - method: "createHardwareCheck", - details: `monitor ID: ${monitorId}`, - }); - return null; +class HardwareCheckModule { + constructor({ HardwareCheck, Monitor, logger }) { + this.HardwareCheck = HardwareCheck; + this.Monitor = Monitor; + this.logger = logger; + } + + createHardwareChecks = async (hardwareChecks) => { + try { + await this.HardwareCheck.insertMany(hardwareChecks, { ordered: false }); + return true; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createHardwareChecks"; + throw error; } + }; +} - let newUptimePercentage; - if (monitor.uptimePercentage === undefined) { - newUptimePercentage = status === true ? 1 : 0; - } else { - newUptimePercentage = (monitor.uptimePercentage * (n - 1) + (status === true ? 1 : 0)) / n; - } - - await Monitor.findOneAndUpdate({ _id: monitorId }, { uptimePercentage: newUptimePercentage }); - - const hardwareCheck = await new HardwareCheck({ - ...hardwareCheckData, - }).save(); - return hardwareCheck; - } catch (error) { - logger.error({ - message: "Error creating hardware check", - service: SERVICE_NAME, - method: "createHardwareCheck", - stack: error.stack, - }); - error.service = SERVICE_NAME; - error.method = "createHardwareCheck"; - throw error; - } -}; - -const createHardwareChecks = async (hardwareChecks) => { - try { - await HardwareCheck.insertMany(hardwareChecks, { ordered: false }); - return true; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "createHardwareChecks"; - throw error; - } -}; - -const deleteHardwareChecksByMonitorId = async (monitorId) => { - try { - const result = await HardwareCheck.deleteMany({ monitorId }); - return result.deletedCount; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteHardwareChecksByMonitorId"; - throw error; - } -}; - -export { createHardwareCheck, createHardwareChecks, deleteHardwareChecksByMonitorId }; +export default HardwareCheckModule; diff --git a/server/src/service/infrastructure/bufferService.js b/server/src/service/infrastructure/bufferService.js index e772c313d..b29cf533b 100755 --- a/server/src/service/infrastructure/bufferService.js +++ b/server/src/service/infrastructure/bufferService.js @@ -23,7 +23,7 @@ class BufferService { this.OPERATION_MAP = { checks: this.db.checkModule.createChecks, pagespeedChecks: this.db.createPageSpeedChecks, - hardwareChecks: this.db.createHardwareChecks, + hardwareChecks: this.db.hardwareCheckModule.createHardwareChecks, }; this.scheduleNextFlush(); From 585fb4f700eb803012550decef691eec7c7b7f8f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 30 Jul 2025 11:36:19 -0700 Subject: [PATCH 133/226] module -> class --- server/src/config/services.js | 15 +- server/src/db/mongo/MongoDB.js | 4 +- .../mongo/modules/maintenanceWindowModule.js | 278 ++++++------------ .../business/maintenanceWindowService.js | 12 +- .../SuperSimpleQueueHelper.js | 2 +- 5 files changed, 119 insertions(+), 192 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index 54f226051..4e8a6ad5b 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -46,12 +46,15 @@ import User from "../db/models/User.js"; import InviteToken from "../db/models/InviteToken.js"; import StatusPage from "../db/models/StatusPage.js"; import Team from "../db/models/Team.js"; +import MaintenanceWindow from "../db/models/MaintenanceWindow.js"; import InviteModule from "../db/mongo/modules/inviteModule.js"; import CheckModule from "../db/mongo/modules/checkModule.js"; import StatusPageModule from "../db/mongo/modules/statusPageModule.js"; import UserModule from "../db/mongo/modules/userModule.js"; import HardwareCheckModule from "../db/mongo/modules/hardwareCheckModule.js"; +import MaintenanceWindowModule from "../db/mongo/modules/maintenanceWindowModule.js"; + export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); ServiceRegistry.instance = serviceRegistry; @@ -67,7 +70,17 @@ export const initializeServices = async ({ logger, envSettings, settingsService const statusPageModule = new StatusPageModule({ StatusPage, NormalizeData, stringService }); const userModule = new UserModule({ User, Team, GenerateAvatarImage, ParseBoolean, stringService }); const hardwareCheckModule = new HardwareCheckModule({ HardwareCheck, Monitor, logger }); - const db = new MongoDB({ logger, envSettings, checkModule, inviteModule, statusPageModule, userModule, hardwareCheckModule }); + const maintenanceWindowModule = new MaintenanceWindowModule({ MaintenanceWindow }); + const db = new MongoDB({ + logger, + envSettings, + checkModule, + inviteModule, + statusPageModule, + userModule, + hardwareCheckModule, + maintenanceWindowModule, + }); await db.connect(); diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index 077aa5b73..cd79789a3 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -41,7 +41,7 @@ import * as diagnosticModule from "./modules/diagnosticModule.js"; class MongoDB { static SERVICE_NAME = "MongoDB"; - constructor({ logger, envSettings, checkModule, inviteModule, statusPageModule, userModule, hardwareCheckModule }) { + constructor({ logger, envSettings, checkModule, inviteModule, statusPageModule, userModule, hardwareCheckModule, maintenanceWindowModule }) { this.logger = logger; this.envSettings = envSettings; this.userModule = userModule; @@ -51,7 +51,7 @@ class MongoDB { Object.assign(this, pageSpeedCheckModule); this.hardwareCheckModule = hardwareCheckModule; this.checkModule = checkModule; - Object.assign(this, maintenanceWindowModule); + this.maintenanceWindowModule = maintenanceWindowModule; Object.assign(this, notificationModule); Object.assign(this, settingsModule); this.statusPageModule = statusPageModule; diff --git a/server/src/db/mongo/modules/maintenanceWindowModule.js b/server/src/db/mongo/modules/maintenanceWindowModule.js index f7fea4636..61523c9f0 100755 --- a/server/src/db/mongo/modules/maintenanceWindowModule.js +++ b/server/src/db/mongo/modules/maintenanceWindowModule.js @@ -1,192 +1,106 @@ -import MaintenanceWindow from "../../models/MaintenanceWindow.js"; const SERVICE_NAME = "maintenanceWindowModule"; -/** - * Asynchronously creates a new MaintenanceWindow document and saves it to the database. - * If the maintenance window is a one-time event, the expiry field is set to the same value as the end field. - * @async - * @function createMaintenanceWindow - * @param {Object} maintenanceWindowData - The data for the new MaintenanceWindow document. - * @param {mongoose.Schema.Types.ObjectId} maintenanceWindowData.monitorId - The ID of the monitor. - * @param {Boolean} maintenanceWindowData.active - Indicates whether the maintenance window is active. - * @param {Boolean} maintenanceWindowData.oneTime - Indicates whether the maintenance window is a one-time event. - * @param {Date} maintenanceWindowData.start - The start date and time of the maintenance window. - * @param {Date} maintenanceWindowData.end - The end date and time of the maintenance window. - * @returns {Promise} The saved MaintenanceWindow document. - * @throws {Error} If there is an error saving the document. - */ -const createMaintenanceWindow = async (maintenanceWindowData) => { - try { - const maintenanceWindow = new MaintenanceWindow({ - ...maintenanceWindowData, - }); +class MaintenanceWindowModule { + constructor({ MaintenanceWindow }) { + this.MaintenanceWindow = MaintenanceWindow; + } + createMaintenanceWindow = async (maintenanceWindowData) => { + try { + const maintenanceWindow = new this.MaintenanceWindow({ + ...maintenanceWindowData, + }); - // If the maintenance window is a one time window, set the expiry to the end date - if (maintenanceWindowData.oneTime) { - maintenanceWindow.expiry = maintenanceWindowData.end; + // If the maintenance window is a one time window, set the expiry to the end date + if (maintenanceWindowData.oneTime) { + maintenanceWindow.expiry = maintenanceWindowData.end; + } + const result = await maintenanceWindow.save(); + return result; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createMaintenanceWindow"; + throw error; } - const result = await maintenanceWindow.save(); - return result; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "createMaintenanceWindow"; - throw error; - } -}; - -const getMaintenanceWindowById = async ({ id, teamId }) => { - try { - const maintenanceWindow = await MaintenanceWindow.findOne({ - _id: id, - teamId: teamId, - }); - return maintenanceWindow; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getMaintenanceWindowById"; - throw error; - } -}; - -/** - * Asynchronously retrieves all MaintenanceWindow documents associated with a specific team ID. - * @async - * @function getMaintenanceWindowByUserId - * @param {String} teamId - The ID of the team. - * @param {Object} query - The request body. - * @returns {Promise>} An array of MaintenanceWindow documents. - * @throws {Error} If there is an error retrieving the documents. - */ -const getMaintenanceWindowsByTeamId = async (teamId, query) => { - try { - let { active, page, rowsPerPage, field, order } = query || {}; - const maintenanceQuery = { teamId }; - - if (active !== undefined) maintenanceQuery.active = active; - - const maintenanceWindowCount = await MaintenanceWindow.countDocuments(maintenanceQuery); - - // Pagination - let skip = 0; - if (page && rowsPerPage) { - skip = page * rowsPerPage; + }; + getMaintenanceWindowById = async ({ id, teamId }) => { + try { + const maintenanceWindow = await this.MaintenanceWindow.findOne({ + _id: id, + teamId: teamId, + }); + return maintenanceWindow; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMaintenanceWindowById"; + throw error; } + }; - // Sorting - let sort = {}; - if (field !== undefined && order !== undefined) { - sort[field] = order === "asc" ? 1 : -1; + getMaintenanceWindowsByTeamId = async (teamId, query) => { + try { + let { active, page, rowsPerPage, field, order } = query || {}; + const maintenanceQuery = { teamId }; + + if (active !== undefined) maintenanceQuery.active = active; + + const maintenanceWindowCount = await this.MaintenanceWindow.countDocuments(maintenanceQuery); + + // Pagination + let skip = 0; + if (page && rowsPerPage) { + skip = page * rowsPerPage; + } + + // Sorting + let sort = {}; + if (field !== undefined && order !== undefined) { + sort[field] = order === "asc" ? 1 : -1; + } + + const maintenanceWindows = await this.MaintenanceWindow.find(maintenanceQuery).skip(skip).limit(rowsPerPage).sort(sort); + + return { maintenanceWindows, maintenanceWindowCount }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMaintenanceWindowByUserId"; + throw error; } + }; + getMaintenanceWindowsByMonitorId = async ({ monitorId, teamId }) => { + try { + const maintenanceWindows = await this.MaintenanceWindow.find({ + monitorId: monitorId, + teamId: teamId, + }); + return maintenanceWindows; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMaintenanceWindowsByMonitorId"; + throw error; + } + }; - const maintenanceWindows = await MaintenanceWindow.find(maintenanceQuery).skip(skip).limit(rowsPerPage).sort(sort); + deleteMaintenanceWindowById = async ({ id, teamId }) => { + try { + const maintenanceWindow = await this.MaintenanceWindow.findOneAndDelete({ _id: id, teamId }); + return maintenanceWindow; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteMaintenanceWindowById"; + throw error; + } + }; - return { maintenanceWindows, maintenanceWindowCount }; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getMaintenanceWindowByUserId"; - throw error; - } -}; + editMaintenanceWindowById = async ({ id, body }) => { + try { + const editedMaintenanceWindow = await this.MaintenanceWindow.findByIdAndUpdate(id, body, { new: true }); + return editedMaintenanceWindow; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "editMaintenanceWindowById"; + throw error; + } + }; +} -/** - * Asynchronously retrieves all MaintenanceWindow documents associated with a specific monitor ID. - * @async - * @function getMaintenanceWindowsByMonitorId - * @param {mongoose.Schema.Types.ObjectId} monitorId - The ID of the monitor. - * @returns {Promise>} An array of MaintenanceWindow documents. - * @throws {Error} If there is an error retrieving the documents. - */ -const getMaintenanceWindowsByMonitorId = async ({ monitorId, teamId }) => { - try { - const maintenanceWindows = await MaintenanceWindow.find({ - monitorId: monitorId, - teamId: teamId, - }); - return maintenanceWindows; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getMaintenanceWindowsByMonitorId"; - throw error; - } -}; - -/** - * Asynchronously deletes a MaintenanceWindow document by its ID. - * @async - * @function deleteMaintenanceWindowById - * @param {mongoose.Schema.Types.ObjectId} maintenanceWindowId - The ID of the MaintenanceWindow document to delete. - * @returns {Promise} The deleted MaintenanceWindow document. - * @throws {Error} If there is an error deleting the document. - */ -const deleteMaintenanceWindowById = async ({ id, teamId }) => { - try { - const maintenanceWindow = await MaintenanceWindow.findOneAndDelete({ _id: id, teamId }); - return maintenanceWindow; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteMaintenanceWindowById"; - throw error; - } -}; - -/** - * Asynchronously deletes all MaintenanceWindow documents associated with a specific monitor ID. - * @async - * @function deleteMaintenanceWindowByMonitorId - * @param {mongoose.Schema.Types.ObjectId} monitorId - The ID of the monitor. - * @returns {Promise} The result of the delete operation. This object contains information about the operation, such as the number of documents deleted. - * @throws {Error} If there is an error deleting the documents. - * @example - */ -const deleteMaintenanceWindowByMonitorId = async (monitorId) => { - try { - const result = await MaintenanceWindow.deleteMany({ monitorId: monitorId }); - return result; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteMaintenanceWindowByMonitorId"; - throw error; - } -}; - -/** - * Asynchronously deletes all MaintenanceWindow documents associated with a specific user ID. - * @async - * @function deleteMaintenanceWindowByUserId - * @param {String} userId - The ID of the user. - * @returns {Promise} The result of the delete operation. This object contains information about the operation, such as the number of documents deleted. - * @throws {Error} If there is an error deleting the documents. - * @example - */ -const deleteMaintenanceWindowByUserId = async (userId) => { - try { - const result = await MaintenanceWindow.deleteMany({ userId: userId }); - return result; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteMaintenanceWindowByUserId"; - throw error; - } -}; - -const editMaintenanceWindowById = async ({ id, body, teamId }) => { - try { - const editedMaintenanceWindow = await MaintenanceWindow.findByIdAndUpdate(id, body, { new: true }); - return editedMaintenanceWindow; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "editMaintenanceWindowById"; - throw error; - } -}; - -export { - createMaintenanceWindow, - getMaintenanceWindowById, - getMaintenanceWindowsByTeamId, - getMaintenanceWindowsByMonitorId, - deleteMaintenanceWindowById, - deleteMaintenanceWindowByMonitorId, - deleteMaintenanceWindowByUserId, - editMaintenanceWindowById, -}; +export default MaintenanceWindowModule; diff --git a/server/src/service/business/maintenanceWindowService.js b/server/src/service/business/maintenanceWindowService.js index ec0fa5345..1e8c09739 100644 --- a/server/src/service/business/maintenanceWindowService.js +++ b/server/src/service/business/maintenanceWindowService.js @@ -25,7 +25,7 @@ class MaintenanceWindowService { } const dbTransactions = monitorIds.map((monitorId) => { - return this.db.createMaintenanceWindow({ + return this.db.maintenanceWindowModule.createMaintenanceWindow({ teamId, monitorId, name: body.name, @@ -39,26 +39,26 @@ class MaintenanceWindowService { }; getMaintenanceWindowById = async ({ id, teamId }) => { - const maintenanceWindow = await this.db.getMaintenanceWindowById({ id, teamId }); + const maintenanceWindow = await this.db.maintenanceWindowModule.getMaintenanceWindowById({ id, teamId }); return maintenanceWindow; }; getMaintenanceWindowsByTeamId = async ({ teamId, query }) => { - const maintenanceWindows = await this.db.getMaintenanceWindowsByTeamId(teamId, query); + const maintenanceWindows = await this.db.maintenanceWindowModule.getMaintenanceWindowsByTeamId(teamId, query); return maintenanceWindows; }; getMaintenanceWindowsByMonitorId = async ({ monitorId, teamId }) => { - const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId({ monitorId, teamId }); + const maintenanceWindows = await this.db.maintenanceWindowModule.getMaintenanceWindowsByMonitorId({ monitorId, teamId }); return maintenanceWindows; }; deleteMaintenanceWindow = async ({ id, teamId }) => { - await this.db.deleteMaintenanceWindowById({ id, teamId }); + await this.db.maintenanceWindowModule.deleteMaintenanceWindowById({ id, teamId }); }; editMaintenanceWindow = async ({ id, teamId, body }) => { - const editedMaintenanceWindow = await this.db.editMaintenanceWindowById({ id, body, teamId }); + const editedMaintenanceWindow = await this.db.maintenanceWindowModule.editMaintenanceWindowById({ id, body, teamId }); return editedMaintenanceWindow; }; } diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js index 639e5183b..ca2e5f642 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js @@ -67,7 +67,7 @@ class SuperSimpleQueueHelper { }; async isInMaintenanceWindow(monitorId) { - const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(monitorId); + const maintenanceWindows = await this.db.maintenanceWindowModule.getMaintenanceWindowsByMonitorId(monitorId); // Check for active maintenance window: const maintenanceWindowIsActive = maintenanceWindows.reduce((acc, window) => { if (window.active) { From de742068a740dc5a7e4df7f97fa23b822d860db9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 30 Jul 2025 12:25:12 -0700 Subject: [PATCH 134/226] module -> class --- server/src/config/services.js | 23 +- server/src/controllers/monitorController.js | 4 +- .../src/controllers/notificationController.js | 2 +- server/src/db/mongo/MongoDB.js | 24 +- server/src/db/mongo/modules/monitorModule.js | 1207 +++++++---------- server/src/service/business/checkService.js | 6 +- .../business/maintenanceWindowService.js | 2 +- server/src/service/business/monitorService.js | 20 +- server/src/service/business/userService.js | 2 +- .../SuperSimpleQueue/SuperSimpleQueue.js | 2 +- .../service/infrastructure/statusService.js | 2 +- 11 files changed, 580 insertions(+), 714 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index 4e8a6ad5b..44fb6491b 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -32,8 +32,11 @@ import mjml2html from "mjml"; import jwt from "jsonwebtoken"; import crypto from "crypto"; +import { fileURLToPath } from "url"; +import { ObjectId } from "mongodb"; + // DB Modules -import { NormalizeData } from "../utils/dataUtils.js"; +import { NormalizeData, NormalizeDataUptimeDetails } from "../utils/dataUtils.js"; import { GenerateAvatarImage } from "../utils/imageProcessing.js"; import { ParseBoolean } from "../utils/utils.js"; @@ -47,6 +50,7 @@ import InviteToken from "../db/models/InviteToken.js"; import StatusPage from "../db/models/StatusPage.js"; import Team from "../db/models/Team.js"; import MaintenanceWindow from "../db/models/MaintenanceWindow.js"; +import MonitorStats from "../db/models/MonitorStats.js"; import InviteModule from "../db/mongo/modules/inviteModule.js"; import CheckModule from "../db/mongo/modules/checkModule.js"; @@ -54,7 +58,7 @@ import StatusPageModule from "../db/mongo/modules/statusPageModule.js"; import UserModule from "../db/mongo/modules/userModule.js"; import HardwareCheckModule from "../db/mongo/modules/hardwareCheckModule.js"; import MaintenanceWindowModule from "../db/mongo/modules/maintenanceWindowModule.js"; - +import MonitorModule from "../db/mongo/modules/monitorModule.js"; export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); ServiceRegistry.instance = serviceRegistry; @@ -71,6 +75,20 @@ export const initializeServices = async ({ logger, envSettings, settingsService const userModule = new UserModule({ User, Team, GenerateAvatarImage, ParseBoolean, stringService }); const hardwareCheckModule = new HardwareCheckModule({ HardwareCheck, Monitor, logger }); const maintenanceWindowModule = new MaintenanceWindowModule({ MaintenanceWindow }); + const monitorModule = new MonitorModule({ + Monitor, + MonitorStats, + Check, + PageSpeedCheck, + HardwareCheck, + stringService, + fs, + path, + fileURLToPath, + ObjectId, + NormalizeData, + NormalizeDataUptimeDetails, + }); const db = new MongoDB({ logger, envSettings, @@ -80,6 +98,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService userModule, hardwareCheckModule, maintenanceWindowModule, + monitorModule, }); await db.connect(); diff --git a/server/src/controllers/monitorController.js b/server/src/controllers/monitorController.js index 13890e5ea..a4f391087 100755 --- a/server/src/controllers/monitorController.js +++ b/server/src/controllers/monitorController.js @@ -32,7 +32,7 @@ class MonitorController extends BaseController { } async verifyTeamAccess(teamId, monitorId) { - const monitor = await this.db.getMonitorById(monitorId); + const monitor = await this.db.monitorModule.getMonitorById(monitorId); if (!monitor.teamId.equals(teamId)) { throw this.errorService.createAuthorizationError(); } @@ -150,7 +150,7 @@ class MonitorController extends BaseController { await getCertificateParamValidation.validateAsync(req.params); const { monitorId } = req.params; - const monitor = await this.db.getMonitorById(monitorId); + const monitor = await this.db.monitorModule.getMonitorById(monitorId); const certificate = await fetchMonitorCertificate(sslChecker, monitor); return res.success({ diff --git a/server/src/controllers/notificationController.js b/server/src/controllers/notificationController.js index 081968e3d..bffb8ee6d 100755 --- a/server/src/controllers/notificationController.js +++ b/server/src/controllers/notificationController.js @@ -158,7 +158,7 @@ class NotificationController extends BaseController { throw this.errorService.createBadRequestError("Team ID is required"); } - const monitor = await this.db.getMonitorById(monitorId); + const monitor = await this.db.monitorModule.getMonitorById(monitorId); if (!monitor.teamId.equals(teamId)) { throw this.errorService.createAuthorizationError(); diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index cd79789a3..f73db6163 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -6,23 +6,12 @@ import AppSettings from "../models/AppSettings.js"; //**************************************** import * as recoveryModule from "./modules/recoveryModule.js"; -//**************************************** -// Monitors -//**************************************** - -import * as monitorModule from "./modules/monitorModule.js"; - //**************************************** // Page Speed Checks //**************************************** import * as pageSpeedCheckModule from "./modules/pageSpeedCheckModule.js"; -//**************************************** -// Maintenance Window -//**************************************** -import * as maintenanceWindowModule from "./modules/maintenanceWindowModule.js"; - //**************************************** // Notifications //**************************************** @@ -41,7 +30,17 @@ import * as diagnosticModule from "./modules/diagnosticModule.js"; class MongoDB { static SERVICE_NAME = "MongoDB"; - constructor({ logger, envSettings, checkModule, inviteModule, statusPageModule, userModule, hardwareCheckModule, maintenanceWindowModule }) { + constructor({ + logger, + envSettings, + checkModule, + inviteModule, + statusPageModule, + userModule, + hardwareCheckModule, + maintenanceWindowModule, + monitorModule, + }) { this.logger = logger; this.envSettings = envSettings; this.userModule = userModule; @@ -52,6 +51,7 @@ class MongoDB { this.hardwareCheckModule = hardwareCheckModule; this.checkModule = checkModule; this.maintenanceWindowModule = maintenanceWindowModule; + this.monitorModule = monitorModule; Object.assign(this, notificationModule); Object.assign(this, settingsModule); this.statusPageModule = statusPageModule; diff --git a/server/src/db/mongo/modules/monitorModule.js b/server/src/db/mongo/modules/monitorModule.js index 6e2e80f49..332122df4 100755 --- a/server/src/db/mongo/modules/monitorModule.js +++ b/server/src/db/mongo/modules/monitorModule.js @@ -1,15 +1,14 @@ -import Monitor from "../../models/Monitor.js"; -import MonitorStats from "../../models/MonitorStats.js"; -import Check from "../../models/Check.js"; -import PageSpeedCheck from "../../models/PageSpeedCheck.js"; -import HardwareCheck from "../../models/HardwareCheck.js"; -import { NormalizeData, NormalizeDataUptimeDetails } from "../../../utils/dataUtils.js"; -import ServiceRegistry from "../../../service/system/serviceRegistry.js"; -import StringService from "../../../service/system/stringService.js"; -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; -import { ObjectId } from "mongodb"; +// import Monitor from "../../models/Monitor.js"; +// import MonitorStats from "../../models/MonitorStats.js"; +// import Check from "../../models/Check.js"; +// import PageSpeedCheck from "../../models/PageSpeedCheck.js"; +// import HardwareCheck from "../../models/HardwareCheck.js"; +// import ServiceRegistry from "../../../service/system/serviceRegistry.js"; +// import StringService from "../../../service/system/stringService.js"; +// import fs from "fs"; +// import path from "path"; +// import { fileURLToPath } from "url"; +// import { ObjectId } from "mongodb"; import { buildUptimeDetailsPipeline, @@ -20,476 +19,351 @@ import { buildMonitorsWithChecksByTeamIdPipeline, buildFilteredMonitorsByTeamIdPipeline, } from "./monitorModuleQueries.js"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const demoMonitorsPath = path.resolve(__dirname, "../../../utils/demoMonitors.json"); -const demoMonitors = JSON.parse(fs.readFileSync(demoMonitorsPath, "utf8")); const SERVICE_NAME = "monitorModule"; -const CHECK_MODEL_LOOKUP = { - http: Check, - ping: Check, - docker: Check, - port: Check, - pagespeed: PageSpeedCheck, - hardware: HardwareCheck, -}; +class MonitorModule { + constructor({ + Monitor, + MonitorStats, + Check, + PageSpeedCheck, + HardwareCheck, + stringService, + fs, + path, + fileURLToPath, + ObjectId, + NormalizeData, + NormalizeDataUptimeDetails, + }) { + this.Monitor = Monitor; + this.MonitorStats = MonitorStats; + this.Check = Check; + this.PageSpeedCheck = PageSpeedCheck; + this.HardwareCheck = HardwareCheck; + this.stringService = stringService; + this.fs = fs; + this.path = path; + this.fileURLToPath = fileURLToPath; + this.ObjectId = ObjectId; + this.NormalizeData = NormalizeData; + this.NormalizeDataUptimeDetails = NormalizeDataUptimeDetails; -/** - * Get all monitors - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @returns {Promise>} - * @throws {Error} - */ -const getAllMonitors = async (req, res) => { - try { - const monitors = await Monitor.find(); - return monitors; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getAllMonitors"; - throw error; + this.CHECK_MODEL_LOOKUP = { + http: Check, + ping: Check, + docker: Check, + port: Check, + pagespeed: PageSpeedCheck, + hardware: HardwareCheck, + }; + + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + + this.demoMonitorsPath = path.resolve(__dirname, "../../../utils/demoMonitors.json"); } -}; -/** - * Function to calculate uptime duration based on the most recent check. - * @param {Array} checks Array of check objects. - * @returns {number} Uptime duration in ms. - */ -const calculateUptimeDuration = (checks) => { - if (!checks || checks.length === 0) { - return 0; - } - const latestCheck = new Date(checks[0].createdAt); - let latestDownCheck = 0; - - for (let i = checks.length - 1; i >= 0; i--) { - if (checks[i].status === false) { - latestDownCheck = new Date(checks[i].createdAt); - break; + // Helper + calculateUptimeDuration = (checks) => { + if (!checks || checks.length === 0) { + return 0; } - } + const latestCheck = new Date(checks[0].createdAt); + let latestDownCheck = 0; - // If no down check is found, uptime is from the last check to now - if (latestDownCheck === 0) { - return Date.now() - new Date(checks[checks.length - 1].createdAt); - } + for (let i = checks.length - 1; i >= 0; i--) { + if (checks[i].status === false) { + latestDownCheck = new Date(checks[i].createdAt); + break; + } + } - // Otherwise the uptime is from the last check to the last down check - return latestCheck - latestDownCheck; -}; + // If no down check is found, uptime is from the last check to now + if (latestDownCheck === 0) { + return Date.now() - new Date(checks[checks.length - 1].createdAt); + } -/** - * Helper function to get duration since last check - * @param {Array} checks Array of check objects. - * @returns {number} Timestamp of the most recent check. - */ -const getLastChecked = (checks) => { - if (!checks || checks.length === 0) { - return 0; // Handle case when no checks are available - } - // Data is sorted newest->oldest, so last check is the most recent - return new Date() - new Date(checks[0].createdAt); -}; - -/** - * Helper function to get latestResponseTime - * @param {Array} checks Array of check objects. - * @returns {number} Timestamp of the most recent check. - */ -const getLatestResponseTime = (checks) => { - if (!checks || checks.length === 0) { - return 0; - } - - return checks[0]?.responseTime ?? 0; -}; - -/** - * Helper function to get average response time - * @param {Array} checks Array of check objects. - * @returns {number} Timestamp of the most recent check. - */ -const getAverageResponseTime = (checks) => { - if (!checks || checks.length === 0) { - return 0; - } - - const validChecks = checks.filter((check) => typeof check.responseTime === "number"); - if (validChecks.length === 0) { - return 0; - } - const aggResponseTime = validChecks.reduce((sum, check) => { - return sum + check.responseTime; - }, 0); - return aggResponseTime / validChecks.length; -}; - -/** - * Helper function to get percentage 24h uptime - * @param {Array} checks Array of check objects. - * @returns {number} Timestamp of the most recent check. - */ - -const getUptimePercentage = (checks) => { - if (!checks || checks.length === 0) { - return 0; - } - const upCount = checks.reduce((count, check) => { - return check.status === true ? count + 1 : count; - }, 0); - return (upCount / checks.length) * 100; -}; - -/** - * Helper function to get all incidents - * @param {Array} checks Array of check objects. - * @returns {number} Timestamp of the most recent check. - */ - -const getIncidents = (checks) => { - if (!checks || checks.length === 0) { - return 0; // Handle case when no checks are available - } - return checks.reduce((acc, check) => { - return check.status === false ? (acc += 1) : acc; - }, 0); -}; - -/** - * Get date range parameters - * @param {string} dateRange - 'day' | 'week' | 'month' | 'all' - * @returns {Object} Start and end dates - */ -const getDateRange = (dateRange) => { - const startDates = { - recent: new Date(new Date().setHours(new Date().getHours() - 2)), - day: new Date(new Date().setDate(new Date().getDate() - 1)), - week: new Date(new Date().setDate(new Date().getDate() - 7)), - month: new Date(new Date().setMonth(new Date().getMonth() - 1)), - all: new Date(0), - }; - return { - start: startDates[dateRange], - end: new Date(), - }; -}; - -/** - * Get checks for a monitor - * @param {string} monitorId - Monitor ID - * @param {Object} model - Check model to use - * @param {Object} dateRange - Date range parameters - * @param {number} sortOrder - Sort order (1 for ascending, -1 for descending) - * @returns {Promise} All checks and date-ranged checks - */ -const getMonitorChecks = async (monitorId, model, dateRange, sortOrder) => { - const indexSpec = { - monitorId: 1, - createdAt: sortOrder, // This will be 1 or -1 + // Otherwise the uptime is from the last check to the last down check + return latestCheck - latestDownCheck; }; - const [checksAll, checksForDateRange] = await Promise.all([ - model.find({ monitorId }).sort({ createdAt: sortOrder }).hint(indexSpec).lean(), - model - .find({ - monitorId, - createdAt: { $gte: dateRange.start, $lte: dateRange.end }, - }) - .hint(indexSpec) - .lean(), - ]); + // Helper + getLastChecked = (checks) => { + if (!checks || checks.length === 0) { + return 0; // Handle case when no checks are available + } + // Data is sorted newest->oldest, so last check is the most recent + return new Date() - new Date(checks[0].createdAt); + }; + getLatestResponseTime = (checks) => { + if (!checks || checks.length === 0) { + return 0; + } - return { checksAll, checksForDateRange }; -}; + return checks[0]?.responseTime ?? 0; + }; -/** - * Process checks for display - * @param {Array} checks - Checks to process - * @param {number} numToDisplay - Number of checks to display - * @param {boolean} normalize - Whether to normalize the data - * @returns {Array} Processed checks - */ -const processChecksForDisplay = (normalizeData, checks, numToDisplay, normalize) => { - let processedChecks = checks; - if (numToDisplay && checks.length > numToDisplay) { - const n = Math.ceil(checks.length / numToDisplay); - processedChecks = checks.filter((_, index) => index % n === 0); - } - return normalize ? normalizeData(processedChecks, 1, 100) : processedChecks; -}; + // Helper + getAverageResponseTime = (checks) => { + if (!checks || checks.length === 0) { + return 0; + } -/** - * Get time-grouped checks based on date range - * @param {Array} checks Array of check objects - * @param {string} dateRange 'day' | 'week' | 'month' - * @returns {Object} Grouped checks by time period - */ -const groupChecksByTime = (checks, dateRange) => { - return checks.reduce((acc, check) => { - // Validate the date - const checkDate = new Date(check.createdAt); - if (Number.isNaN(checkDate.getTime()) || checkDate.getTime() === 0) { + const validChecks = checks.filter((check) => typeof check.responseTime === "number"); + if (validChecks.length === 0) { + return 0; + } + const aggResponseTime = validChecks.reduce((sum, check) => { + return sum + check.responseTime; + }, 0); + return aggResponseTime / validChecks.length; + }; + + // Helper + getUptimePercentage = (checks) => { + if (!checks || checks.length === 0) { + return 0; + } + const upCount = checks.reduce((count, check) => { + return check.status === true ? count + 1 : count; + }, 0); + return (upCount / checks.length) * 100; + }; + + // Helper + getIncidents = (checks) => { + if (!checks || checks.length === 0) { + return 0; // Handle case when no checks are available + } + return checks.reduce((acc, check) => { + return check.status === false ? (acc += 1) : acc; + }, 0); + }; + + // Helper + getDateRange = (dateRange) => { + const startDates = { + recent: new Date(new Date().setHours(new Date().getHours() - 2)), + day: new Date(new Date().setDate(new Date().getDate() - 1)), + week: new Date(new Date().setDate(new Date().getDate() - 7)), + month: new Date(new Date().setMonth(new Date().getMonth() - 1)), + all: new Date(0), + }; + return { + start: startDates[dateRange], + end: new Date(), + }; + }; + + //Helper + getMonitorChecks = async (monitorId, model, dateRange, sortOrder) => { + const indexSpec = { + monitorId: 1, + createdAt: sortOrder, // This will be 1 or -1 + }; + + const [checksAll, checksForDateRange] = await Promise.all([ + model.find({ monitorId }).sort({ createdAt: sortOrder }).hint(indexSpec).lean(), + model + .find({ + monitorId, + createdAt: { $gte: dateRange.start, $lte: dateRange.end }, + }) + .hint(indexSpec) + .lean(), + ]); + + return { checksAll, checksForDateRange }; + }; + + // Helper + processChecksForDisplay = (normalizeData, checks, numToDisplay, normalize) => { + let processedChecks = checks; + if (numToDisplay && checks.length > numToDisplay) { + const n = Math.ceil(checks.length / numToDisplay); + processedChecks = checks.filter((_, index) => index % n === 0); + } + return normalize ? normalizeData(processedChecks, 1, 100) : processedChecks; + }; + + // Helper + groupChecksByTime = (checks, dateRange) => { + return checks.reduce((acc, check) => { + // Validate the date + const checkDate = new Date(check.createdAt); + if (Number.isNaN(checkDate.getTime()) || checkDate.getTime() === 0) { + return acc; + } + + const time = dateRange === "day" ? checkDate.setMinutes(0, 0, 0) : checkDate.toISOString().split("T")[0]; + + if (!acc[time]) { + acc[time] = { time, checks: [] }; + } + acc[time].checks.push(check); return acc; - } - - const time = dateRange === "day" ? checkDate.setMinutes(0, 0, 0) : checkDate.toISOString().split("T")[0]; - - if (!acc[time]) { - acc[time] = { time, checks: [] }; - } - acc[time].checks.push(check); - return acc; - }, {}); -}; - -/** - * Calculate aggregate stats for a group of checks - * @param {Object} group Group of checks - * @returns {Object} Stats for the group - */ -const calculateGroupStats = (group) => { - const totalChecks = group.checks.length; - - const checksWithResponseTime = group.checks.filter((check) => typeof check.responseTime === "number" && !Number.isNaN(check.responseTime)); - - return { - time: group.time, - uptimePercentage: getUptimePercentage(group.checks), - totalChecks, - totalIncidents: group.checks.filter((check) => !check.status).length, - avgResponseTime: - checksWithResponseTime.length > 0 - ? checksWithResponseTime.reduce((sum, check) => sum + check.responseTime, 0) / checksWithResponseTime.length - : 0, + }, {}); }; -}; -/** - * Get uptime details by monitor ID - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @returns {Promise} - * @throws {Error} - */ -const getUptimeDetailsById = async ({ monitorId, dateRange, normalize }) => { - try { - const dates = getDateRange(dateRange); - const formatLookup = { - recent: "%Y-%m-%dT%H:%M:00Z", - day: "%Y-%m-%dT%H:00:00Z", - week: "%Y-%m-%dT%H:00:00Z", - month: "%Y-%m-%dT00:00:00Z", + // Helper + calculateGroupStats = (group) => { + const totalChecks = group.checks.length; + + const checksWithResponseTime = group.checks.filter((check) => typeof check.responseTime === "number" && !Number.isNaN(check.responseTime)); + + return { + time: group.time, + uptimePercentage: this.getUptimePercentage(group.checks), + totalChecks, + totalIncidents: group.checks.filter((check) => !check.status).length, + avgResponseTime: + checksWithResponseTime.length > 0 + ? checksWithResponseTime.reduce((sum, check) => sum + check.responseTime, 0) / checksWithResponseTime.length + : 0, }; + }; - const dateString = formatLookup[dateRange]; - - const results = await Check.aggregate(buildUptimeDetailsPipeline(monitorId, dates, dateString)); - - const monitorData = results[0]; - - monitorData.groupedUpChecks = NormalizeDataUptimeDetails(monitorData.groupedUpChecks, 10, 100); - - monitorData.groupedDownChecks = NormalizeDataUptimeDetails(monitorData.groupedDownChecks, 10, 100); - - const normalizedGroupChecks = NormalizeDataUptimeDetails(monitorData.groupedChecks, 10, 100); - - monitorData.groupedChecks = normalizedGroupChecks; - const monitorStats = await MonitorStats.findOne({ monitorId }); - return { monitorData, monitorStats }; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getUptimeDetailsById"; - throw error; - } -}; - -/** - * Get stats by monitor ID - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @returns {Promise} - * @throws {Error} - */ -const getMonitorStatsById = async ({ monitorId, limit, sortOrder, dateRange, numToDisplay, normalize }) => { - const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); - try { - // Get monitor, if we can't find it, abort with error - const monitor = await Monitor.findById(monitorId); - if (monitor === null || monitor === undefined) { - throw new Error(stringService.getDbFindMonitorById(monitorId)); - } - - // Get query params - const sort = sortOrder === "asc" ? 1 : -1; - - // Get Checks for monitor in date range requested - const model = CHECK_MODEL_LOOKUP[monitor.type]; - const dates = getDateRange(dateRange); - const { checksAll, checksForDateRange } = await getMonitorChecks(monitorId, model, dates, sort); - - // Build monitor stats - const monitorStats = { - ...monitor.toObject(), - uptimeDuration: calculateUptimeDuration(checksAll), - lastChecked: getLastChecked(checksAll), - latestResponseTime: getLatestResponseTime(checksAll), - periodIncidents: getIncidents(checksForDateRange), - periodTotalChecks: checksForDateRange.length, - checks: processChecksForDisplay(NormalizeData, checksForDateRange, numToDisplay, normalize), - }; - - if (monitor.type === "http" || monitor.type === "ping" || monitor.type === "docker" || monitor.type === "port") { - // HTTP/PING Specific stats - monitorStats.periodAvgResponseTime = getAverageResponseTime(checksForDateRange); - monitorStats.periodUptime = getUptimePercentage(checksForDateRange); - const groupedChecks = groupChecksByTime(checksForDateRange, dateRange); - monitorStats.aggregateData = Object.values(groupedChecks).map(calculateGroupStats); - } - - return monitorStats; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getMonitorStatsById"; - throw error; - } -}; - -const getHardwareDetailsById = async ({ monitorId, dateRange }) => { - try { - const monitor = await Monitor.findById(monitorId); - const dates = getDateRange(dateRange); - const formatLookup = { - recent: "%Y-%m-%dT%H:%M:00Z", - day: "%Y-%m-%dT%H:00:00Z", - week: "%Y-%m-%dT%H:00:00Z", - month: "%Y-%m-%dT00:00:00Z", - }; - const dateString = formatLookup[dateRange]; - const hardwareStats = await HardwareCheck.aggregate(buildHardwareDetailsPipeline(monitor, dates, dateString)); - - const monitorStats = { - ...monitor.toObject(), - stats: hardwareStats[0], - }; - return monitorStats; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getHardwareDetailsById"; - throw error; - } -}; - -/** - * Get a monitor by ID - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @returns {Promise} - * @throws {Error} - */ -const getMonitorById = async (monitorId) => { - const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); - try { - const monitor = await Monitor.findById(monitorId); - if (monitor === null || monitor === undefined) { - const error = new Error(stringService.getDbFindMonitorById(monitorId)); - error.status = 404; + getAllMonitors = async () => { + try { + const monitors = await this.Monitor.find(); + return monitors; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getAllMonitors"; throw error; } + }; - return monitor; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getMonitorById"; - throw error; - } -}; + getMonitorById = async (monitorId) => { + try { + const monitor = await this.Monitor.findById(monitorId); + if (monitor === null || monitor === undefined) { + const error = new Error(this.stringService.getDbFindMonitorById(monitorId)); + error.status = 404; + throw error; + } -const getMonitorsByIds = async (monitorIds) => { - try { - const objectIds = monitorIds.map((id) => new ObjectId(id)); - return await Monitor.find({ _id: { $in: objectIds } }, { _id: 1, teamId: 1 }).lean(); - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getMonitorsByIds"; - throw error; - } -}; - -const getMonitorsByTeamId = async ({ limit, type, page, rowsPerPage, filter, field, order, teamId }) => { - limit = parseInt(limit); - page = parseInt(page); - rowsPerPage = parseInt(rowsPerPage); - if (field === undefined) { - field = "name"; - order = "asc"; - } - // Build match stage - const matchStage = { teamId: new ObjectId(teamId) }; - if (type !== undefined) { - matchStage.type = Array.isArray(type) ? { $in: type } : type; - } - - const summaryResult = await Monitor.aggregate(buildMonitorSummaryByTeamIdPipeline({ matchStage })); - const summary = summaryResult[0]; - - const monitors = await Monitor.aggregate(buildMonitorsByTeamIdPipeline({ matchStage, field, order })); - - const filteredMonitors = await Monitor.aggregate( - buildFilteredMonitorsByTeamIdPipeline({ - matchStage, - filter, - page, - rowsPerPage, - field, - order, - limit, - type, - }) - ); - - const normalizedFilteredMonitors = filteredMonitors.map((monitor) => { - if (!monitor.checks) { return monitor; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMonitorById"; + throw error; } - monitor.checks = NormalizeData(monitor.checks, 10, 100); - return monitor; - }); + }; - return { summary, monitors, filteredMonitors: normalizedFilteredMonitors }; -}; - -const getMonitorsAndSummaryByTeamId = async ({ type, explain, teamId }) => { - try { - const matchStage = { teamId: new ObjectId(teamId) }; - if (type !== undefined) { - matchStage.type = Array.isArray(type) ? { $in: type } : type; + getMonitorsByIds = async (monitorIds) => { + try { + const objectIds = monitorIds.map((id) => new this.ObjectId(id)); + return await this.Monitor.find({ _id: { $in: objectIds } }, { _id: 1, teamId: 1 }).lean(); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMonitorsByIds"; + throw error; } + }; + getUptimeDetailsById = async ({ monitorId, dateRange }) => { + try { + const dates = this.getDateRange(dateRange); + const formatLookup = { + recent: "%Y-%m-%dT%H:%M:00Z", + day: "%Y-%m-%dT%H:00:00Z", + week: "%Y-%m-%dT%H:00:00Z", + month: "%Y-%m-%dT00:00:00Z", + }; - if (explain === true) { - return Monitor.aggregate(buildMonitorsAndSummaryByTeamIdPipeline({ matchStage })).explain("executionStats"); + const dateString = formatLookup[dateRange]; + + const results = await this.Check.aggregate(buildUptimeDetailsPipeline(monitorId, dates, dateString)); + + const monitorData = results[0]; + + monitorData.groupedUpChecks = this.NormalizeDataUptimeDetails(monitorData.groupedUpChecks, 10, 100); + + monitorData.groupedDownChecks = this.NormalizeDataUptimeDetails(monitorData.groupedDownChecks, 10, 100); + + const normalizedGroupChecks = this.NormalizeDataUptimeDetails(monitorData.groupedChecks, 10, 100); + + monitorData.groupedChecks = normalizedGroupChecks; + const monitorStats = await this.MonitorStats.findOne({ monitorId }); + return { monitorData, monitorStats }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getUptimeDetailsById"; + throw error; } + }; - const queryResult = await Monitor.aggregate(buildMonitorsAndSummaryByTeamIdPipeline({ matchStage })); - const { monitors, summary } = queryResult?.[0] ?? {}; - return { monitors, summary }; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getMonitorsAndSummaryByTeamId"; - throw error; - } -}; + getMonitorStatsById = async ({ monitorId, sortOrder, dateRange, numToDisplay, normalize }) => { + try { + // Get monitor, if we can't find it, abort with error + const monitor = await this.Monitor.findById(monitorId); + if (monitor === null || monitor === undefined) { + throw new Error(this.stringService.getDbFindMonitorById(monitorId)); + } -const getMonitorsWithChecksByTeamId = async ({ limit, type, page, rowsPerPage, filter, field, order, teamId, explain }) => { - try { + // Get query params + const sort = sortOrder === "asc" ? 1 : -1; + + // Get Checks for monitor in date range requested + const model = this.CHECK_MODEL_LOOKUP[monitor.type]; + const dates = this.getDateRange(dateRange); + const { checksAll, checksForDateRange } = await this.getMonitorChecks(monitorId, model, dates, sort); + + // Build monitor stats + const monitorStats = { + ...monitor.toObject(), + uptimeDuration: this.calculateUptimeDuration(checksAll), + lastChecked: this.getLastChecked(checksAll), + latestResponseTime: this.getLatestResponseTime(checksAll), + periodIncidents: this.getIncidents(checksForDateRange), + periodTotalChecks: checksForDateRange.length, + checks: this.processChecksForDisplay(this.NormalizeData, checksForDateRange, numToDisplay, normalize), + }; + + if (monitor.type === "http" || monitor.type === "ping" || monitor.type === "docker" || monitor.type === "port") { + // HTTP/PING Specific stats + monitorStats.periodAvgResponseTime = this.getAverageResponseTime(checksForDateRange); + monitorStats.periodUptime = this.getUptimePercentage(checksForDateRange); + const groupedChecks = this.groupChecksByTime(checksForDateRange, dateRange); + monitorStats.aggregateData = Object.values(groupedChecks).map(this.calculateGroupStats); + } + + return monitorStats; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMonitorStatsById"; + throw error; + } + }; + + getHardwareDetailsById = async ({ monitorId, dateRange }) => { + try { + const monitor = await this.Monitor.findById(monitorId); + const dates = this.getDateRange(dateRange); + const formatLookup = { + recent: "%Y-%m-%dT%H:%M:00Z", + day: "%Y-%m-%dT%H:00:00Z", + week: "%Y-%m-%dT%H:00:00Z", + month: "%Y-%m-%dT00:00:00Z", + }; + const dateString = formatLookup[dateRange]; + const hardwareStats = await this.HardwareCheck.aggregate(buildHardwareDetailsPipeline(monitor, dates, dateString)); + + const monitorStats = { + ...monitor.toObject(), + stats: hardwareStats[0], + }; + return monitorStats; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getHardwareDetailsById"; + throw error; + } + }; + + getMonitorsByTeamId = async ({ limit, type, page, rowsPerPage, filter, field, order, teamId }) => { limit = parseInt(limit); page = parseInt(page); rowsPerPage = parseInt(rowsPerPage); @@ -498,28 +372,18 @@ const getMonitorsWithChecksByTeamId = async ({ limit, type, page, rowsPerPage, f order = "asc"; } // Build match stage - const matchStage = { teamId: new ObjectId(teamId) }; + const matchStage = { teamId: new this.ObjectId(teamId) }; if (type !== undefined) { matchStage.type = Array.isArray(type) ? { $in: type } : type; } - if (explain === true) { - return Monitor.aggregate( - buildMonitorsWithChecksByTeamIdPipeline({ - matchStage, - filter, - page, - rowsPerPage, - field, - order, - limit, - type, - }) - ).explain("executionStats"); - } + const summaryResult = await this.Monitor.aggregate(buildMonitorSummaryByTeamIdPipeline({ matchStage })); + const summary = summaryResult[0]; - const queryResult = await Monitor.aggregate( - buildMonitorsWithChecksByTeamIdPipeline({ + const monitors = await this.Monitor.aggregate(buildMonitorsByTeamIdPipeline({ matchStage, field, order })); + + const filteredMonitors = await this.Monitor.aggregate( + buildFilteredMonitorsByTeamIdPipeline({ matchStage, filter, page, @@ -530,237 +394,220 @@ const getMonitorsWithChecksByTeamId = async ({ limit, type, page, rowsPerPage, f type, }) ); - const monitors = queryResult[0]?.monitors; - const count = queryResult[0]?.count; - const normalizedFilteredMonitors = monitors.map((monitor) => { + + const normalizedFilteredMonitors = filteredMonitors.map((monitor) => { if (!monitor.checks) { return monitor; } - monitor.checks = NormalizeData(monitor.checks, 10, 100); + monitor.checks = this.NormalizeData(monitor.checks, 10, 100); return monitor; }); - return { count, monitors: normalizedFilteredMonitors }; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getMonitorsWithChecksByTeamId"; - throw error; - } -}; -/** - * Create a monitor - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @returns {Promise} - * @throws {Error} - */ -const createMonitor = async ({ body, teamId, userId }) => { - try { - const monitor = new Monitor({ ...body, teamId, userId }); - const saved = await monitor.save(); - return saved; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "createMonitor"; - throw error; - } -}; + return { summary, monitors, filteredMonitors: normalizedFilteredMonitors }; + }; -/** - * Create bulk monitors - * @async - * @param {Express.Request} req - * @returns {Promise} - * @throws {Error} - */ -const createBulkMonitors = async (req) => { - try { - const monitors = req.map((item) => new Monitor({ ...item, notifications: undefined })); - await Monitor.bulkSave(monitors); - return monitors; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "createBulkMonitors"; - throw error; - } -}; + getMonitorsAndSummaryByTeamId = async ({ type, explain, teamId }) => { + try { + const matchStage = { teamId: new this.ObjectId(teamId) }; + if (type !== undefined) { + matchStage.type = Array.isArray(type) ? { $in: type } : type; + } -/** - * Delete a monitor by ID - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @returns {Promise} - * @throws {Error} - */ -const deleteMonitor = async ({ teamId, monitorId }) => { - const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); - try { - const deletedMonitor = await Monitor.findOneAndDelete({ _id: monitorId, teamId }); + if (explain === true) { + return this.Monitor.aggregate(buildMonitorsAndSummaryByTeamIdPipeline({ matchStage })).explain("executionStats"); + } - if (!deletedMonitor) { - throw new Error(stringService.getDbFindMonitorById(monitorId)); + const queryResult = await this.Monitor.aggregate(buildMonitorsAndSummaryByTeamIdPipeline({ matchStage })); + const { monitors, summary } = queryResult?.[0] ?? {}; + return { monitors, summary }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMonitorsAndSummaryByTeamId"; + throw error; } + }; - return deletedMonitor; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteMonitor"; - throw error; - } -}; + getMonitorsWithChecksByTeamId = async ({ limit, type, page, rowsPerPage, filter, field, order, teamId, explain }) => { + try { + limit = parseInt(limit); + page = parseInt(page); + rowsPerPage = parseInt(rowsPerPage); + if (field === undefined) { + field = "name"; + order = "asc"; + } + // Build match stage + const matchStage = { teamId: new this.ObjectId(teamId) }; + if (type !== undefined) { + matchStage.type = Array.isArray(type) ? { $in: type } : type; + } -/** - * DELETE ALL MONITORS (TEMP) - */ + if (explain === true) { + return this.Monitor.aggregate( + buildMonitorsWithChecksByTeamIdPipeline({ + matchStage, + filter, + page, + rowsPerPage, + field, + order, + limit, + type, + }) + ).explain("executionStats"); + } -const deleteAllMonitors = async (teamId) => { - try { - const monitors = await Monitor.find({ teamId }); - const { deletedCount } = await Monitor.deleteMany({ teamId }); + const queryResult = await this.Monitor.aggregate( + buildMonitorsWithChecksByTeamIdPipeline({ + matchStage, + filter, + page, + rowsPerPage, + field, + order, + limit, + type, + }) + ); + const monitors = queryResult[0]?.monitors; + const count = queryResult[0]?.count; + const normalizedFilteredMonitors = monitors.map((monitor) => { + if (!monitor.checks) { + return monitor; + } + monitor.checks = this.NormalizeData(monitor.checks, 10, 100); + return monitor; + }); + return { count, monitors: normalizedFilteredMonitors }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMonitorsWithChecksByTeamId"; + throw error; + } + }; + createMonitor = async ({ body, teamId, userId }) => { + try { + const monitor = new this.Monitor({ ...body, teamId, userId }); + const saved = await monitor.save(); + return saved; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createMonitor"; + throw error; + } + }; - return { monitors, deletedCount }; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteAllMonitors"; - throw error; - } -}; + createBulkMonitors = async (req) => { + try { + const monitors = req.map((item) => new this.Monitor({ ...item, notifications: undefined })); + await this.Monitor.bulkSave(monitors); + return monitors; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createBulkMonitors"; + throw error; + } + }; -/** - * Delete all monitors associated with a user ID - * @async - * @param {string} userId - The ID of the user whose monitors are to be deleted. - * @returns {Promise} A promise that resolves when the operation is complete. - */ -const deleteMonitorsByUserId = async (userId) => { - try { - const result = await Monitor.deleteMany({ userId: userId }); - return result; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteMonitorsByUserId"; - throw error; - } -}; + deleteMonitor = async ({ teamId, monitorId }) => { + try { + const deletedMonitor = await this.Monitor.findOneAndDelete({ _id: monitorId, teamId }); -/** - * Edit a monitor by ID - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @returns {Promise} - * @throws {Error} - */ -const editMonitor = async ({ monitorId, body }) => { - try { - const editedMonitor = await Monitor.findByIdAndUpdate(monitorId, body, { - new: true, - }); - return editedMonitor; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "editMonitor"; - throw error; - } -}; + if (!deletedMonitor) { + throw new Error(this.stringService.getDbFindMonitorById(monitorId)); + } -const addDemoMonitors = async (userId, teamId) => { - try { - const demoMonitorsToInsert = demoMonitors.map((monitor) => { - return { - userId, - teamId, - name: monitor.name, - description: monitor.name, - type: "http", - url: monitor.url, - interval: 60000, - }; - }); - const insertedMonitors = await Monitor.insertMany(demoMonitorsToInsert); - return insertedMonitors; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "addDemoMonitors"; - throw error; - } -}; + return deletedMonitor; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteMonitor"; + throw error; + } + }; -const pauseMonitor = async ({ monitorId }) => { - try { - const monitor = await Monitor.findOneAndUpdate( - { _id: monitorId }, - [ - { - $set: { - isActive: { $not: "$isActive" }, - status: "$$REMOVE", + deleteAllMonitors = async (teamId) => { + try { + const monitors = await this.Monitor.find({ teamId }); + const { deletedCount } = await this.Monitor.deleteMany({ teamId }); + + return { monitors, deletedCount }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteAllMonitors"; + throw error; + } + }; + + deleteMonitorsByUserId = async (userId) => { + try { + const result = await this.Monitor.deleteMany({ userId: userId }); + return result; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteMonitorsByUserId"; + throw error; + } + }; + + editMonitor = async ({ monitorId, body }) => { + try { + const editedMonitor = await this.Monitor.findByIdAndUpdate(monitorId, body, { + new: true, + }); + return editedMonitor; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "editMonitor"; + throw error; + } + }; + + addDemoMonitors = async (userId, teamId) => { + try { + const demoMonitors = JSON.parse(this.fs.readFileSync(this.demoMonitorsPath, "utf8")); + + const demoMonitorsToInsert = demoMonitors.map((monitor) => { + return { + userId, + teamId, + name: monitor.name, + description: monitor.name, + type: "http", + url: monitor.url, + interval: 60000, + }; + }); + const insertedMonitors = await this.Monitor.insertMany(demoMonitorsToInsert); + return insertedMonitors; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "addDemoMonitors"; + throw error; + } + }; + + pauseMonitor = async ({ monitorId }) => { + try { + const monitor = await this.Monitor.findOneAndUpdate( + { _id: monitorId }, + [ + { + $set: { + isActive: { $not: "$isActive" }, + status: "$$REMOVE", + }, }, - }, - ], - { new: true } - ); + ], + { new: true } + ); - return monitor; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "pauseMonitor"; - throw error; - } -}; + return monitor; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "pauseMonitor"; + throw error; + } + }; +} -export { - getAllMonitors, - getMonitorStatsById, - getMonitorById, - getMonitorsByIds, - getMonitorsByTeamId, - getMonitorsAndSummaryByTeamId, - getMonitorsWithChecksByTeamId, - getUptimeDetailsById, - createMonitor, - createBulkMonitors, - deleteMonitor, - deleteAllMonitors, - deleteMonitorsByUserId, - editMonitor, - addDemoMonitors, - getHardwareDetailsById, - pauseMonitor, -}; - -// Helper functions -export { - calculateUptimeDuration, - getLastChecked, - getLatestResponseTime, - getAverageResponseTime, - getUptimePercentage, - getIncidents, - getDateRange, - getMonitorChecks, - processChecksForDisplay, - groupChecksByTime, - calculateGroupStats, -}; - -// limit 25 -// page 1 -// rowsPerPage 25 -// filter undefined -// field name -// order asc -// skip 25 -// sort { name: 1 } -// filteredMonitors [] - -// limit 25 -// page NaN -// rowsPerPage 25 -// filter undefined -// field name -// order asc -// skip 0 -// sort { name: 1 } +export default MonitorModule; diff --git a/server/src/service/business/checkService.js b/server/src/service/business/checkService.js index c5a4df88d..7966fdbb4 100644 --- a/server/src/service/business/checkService.js +++ b/server/src/service/business/checkService.js @@ -23,7 +23,7 @@ class CheckService { throw this.errorService.createBadRequestError("No team ID in request"); } - const monitor = await this.db.getMonitorById(monitorId); + const monitor = await this.db.monitorModule.getMonitorById(monitorId); if (!monitor) { throw this.errorService.createNotFoundError("Monitor not found"); @@ -95,7 +95,7 @@ class CheckService { throw this.errorService.createBadRequestError("No monitor ID in request"); } - const monitor = await this.db.getMonitorById(monitorId); + const monitor = await this.db.monitorModule.getMonitorById(monitorId); if (!monitor) { throw this.errorService.createNotFoundError("Monitor not found"); } @@ -118,7 +118,7 @@ class CheckService { throw this.errorService.createBadRequestError("No team ID in request"); } - const monitor = await this.db.getMonitorById(monitorId); + const monitor = await this.db.monitorModule.getMonitorById(monitorId); if (!monitor) { throw this.errorService.createNotFoundError("Monitor not found"); diff --git a/server/src/service/business/maintenanceWindowService.js b/server/src/service/business/maintenanceWindowService.js index 1e8c09739..9d52702e9 100644 --- a/server/src/service/business/maintenanceWindowService.js +++ b/server/src/service/business/maintenanceWindowService.js @@ -16,7 +16,7 @@ class MaintenanceWindowService { createMaintenanceWindow = async ({ teamId, body }) => { const monitorIds = body.monitors; - const monitors = await this.db.getMonitorsByIds(monitorIds); + const monitors = await this.db.monitorModule.getMonitorsByIds(monitorIds); const unauthorizedMonitors = monitors.filter((monitor) => !monitor.teamId.equals(teamId)); diff --git a/server/src/service/business/monitorService.js b/server/src/service/business/monitorService.js index f90c21470..e9254948a 100644 --- a/server/src/service/business/monitorService.js +++ b/server/src/service/business/monitorService.js @@ -20,20 +20,20 @@ class MonitorService { } verifyTeamAccess = async ({ teamId, monitorId }) => { - const monitor = await this.db.getMonitorById(monitorId); + const monitor = await this.db.monitorModule.getMonitorById(monitorId); if (!monitor?.teamId?.equals(teamId)) { throw this.errorService.createAuthorizationError(); } }; getAllMonitors = async () => { - const monitors = await this.db.getAllMonitors(); + const monitors = await this.db.monitorModule.getAllMonitors(); return monitors; }; getUptimeDetailsById = async ({ teamId, monitorId, dateRange, normalize }) => { await this.verifyTeamAccess({ teamId, monitorId }); - const data = await this.db.getUptimeDetailsById({ + const data = await this.db.monitorModule.getUptimeDetailsById({ monitorId, dateRange, normalize, @@ -44,7 +44,7 @@ class MonitorService { getMonitorStatsById = async ({ teamId, monitorId, limit, sortOrder, dateRange, numToDisplay, normalize }) => { await this.verifyTeamAccess({ teamId, monitorId }); - const monitorStats = await this.db.getMonitorStatsById({ + const monitorStats = await this.db.monitorModule.getMonitorStatsById({ monitorId, limit, sortOrder, @@ -58,14 +58,14 @@ class MonitorService { getHardwareDetailsById = async ({ teamId, monitorId, dateRange }) => { await this.verifyTeamAccess({ teamId, monitorId }); - const monitor = await this.db.getHardwareDetailsById({ monitorId, dateRange }); + const monitor = await this.db.monitorModule.getHardwareDetailsById({ monitorId, dateRange }); return monitor; }; getMonitorById = async ({ teamId, monitorId }) => { await this.verifyTeamAccess({ teamId, monitorId }); - const monitor = await this.db.getMonitorById(monitorId); + const monitor = await this.db.monitorModule.getMonitorById(monitorId); return monitor; }; @@ -203,7 +203,7 @@ class MonitorService { }; getMonitorsByTeamId = async ({ teamId, limit, type, page, rowsPerPage, filter, field, order }) => { - const monitors = await this.db.getMonitorsByTeamId({ + const monitors = await this.db.monitorModule.getMonitorsByTeamId({ limit, type, page, @@ -217,7 +217,7 @@ class MonitorService { }; getMonitorsAndSummaryByTeamId = async ({ teamId, type, explain }) => { - const result = await this.db.getMonitorsAndSummaryByTeamId({ + const result = await this.db.monitorModule.getMonitorsAndSummaryByTeamId({ type, explain, teamId, @@ -226,7 +226,7 @@ class MonitorService { }; getMonitorsWithChecksByTeamId = async ({ teamId, limit, type, page, rowsPerPage, filter, field, order, explain }) => { - const result = await this.db.getMonitorsWithChecksByTeamId({ + const result = await this.db.monitorModule.getMonitorsWithChecksByTeamId({ limit, type, page, @@ -241,7 +241,7 @@ class MonitorService { }; exportMonitorsToCSV = async ({ teamId }) => { - const monitors = await this.db.getMonitorsByTeamId({ teamId }); + const monitors = await this.db.monitorModule.getMonitorsByTeamId({ teamId }); if (!monitors || monitors.length === 0) { throw this.errorService.createNotFoundError("No monitors to export"); diff --git a/server/src/service/business/userService.js b/server/src/service/business/userService.js index 04c14c477..3ffa4c957 100644 --- a/server/src/service/business/userService.js +++ b/server/src/service/business/userService.js @@ -181,7 +181,7 @@ class UserService { } // 1. Find all the monitors associated with the team ID if superadmin - const result = await this.db.getMonitorsByTeamId({ + const result = await this.db.monitorModule.getMonitorsByTeamId({ teamId: teamId, }); diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js index f5661c9e6..60bcb0754 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js @@ -33,7 +33,7 @@ class SuperSimpleQueue { this.scheduler.start(); this.scheduler.addTemplate("monitor-job", this.helper.getMonitorJob()); - const monitors = await this.db.getAllMonitors(); + const monitors = await this.db.monitorModule.getAllMonitors(); for (const monitor of monitors) { await this.addJob(monitor._id, monitor); } diff --git a/server/src/service/infrastructure/statusService.js b/server/src/service/infrastructure/statusService.js index 3e2330380..672eb55fc 100755 --- a/server/src/service/infrastructure/statusService.js +++ b/server/src/service/infrastructure/statusService.js @@ -122,7 +122,7 @@ class StatusService { this.insertCheck(networkResponse); try { const { monitorId, status, code } = networkResponse; - const monitor = await this.db.getMonitorById(monitorId); + const monitor = await this.db.monitorModule.getMonitorById(monitorId); // Update running stats this.updateRunningStats({ monitor, networkResponse }); From 16ebe4b1aba71ff3f8bd2b534dd93732663ae2a5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 30 Jul 2025 15:06:19 -0700 Subject: [PATCH 135/226] module -> class --- server/src/config/services.js | 5 ++ server/src/db/mongo/MongoDB.js | 2 + .../db/mongo/modules/networkCheckModule.js | 66 ++++++++----------- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index 44fb6491b..762a85d26 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -51,6 +51,7 @@ import StatusPage from "../db/models/StatusPage.js"; import Team from "../db/models/Team.js"; import MaintenanceWindow from "../db/models/MaintenanceWindow.js"; import MonitorStats from "../db/models/MonitorStats.js"; +import NetworkCheck from "../db/models/NetworkCheck.js"; import InviteModule from "../db/mongo/modules/inviteModule.js"; import CheckModule from "../db/mongo/modules/checkModule.js"; @@ -59,6 +60,8 @@ import UserModule from "../db/mongo/modules/userModule.js"; import HardwareCheckModule from "../db/mongo/modules/hardwareCheckModule.js"; import MaintenanceWindowModule from "../db/mongo/modules/maintenanceWindowModule.js"; import MonitorModule from "../db/mongo/modules/monitorModule.js"; +import NetworkCheckModule from "../db/mongo/modules/networkCheckModule.js"; + export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); ServiceRegistry.instance = serviceRegistry; @@ -89,6 +92,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService NormalizeData, NormalizeDataUptimeDetails, }); + const networkCheckModule = new NetworkCheckModule({ NetworkCheck }); const db = new MongoDB({ logger, envSettings, @@ -99,6 +103,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService hardwareCheckModule, maintenanceWindowModule, monitorModule, + networkCheckModule, }); await db.connect(); diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index f73db6163..96f8062c3 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -40,6 +40,7 @@ class MongoDB { hardwareCheckModule, maintenanceWindowModule, monitorModule, + networkCheckModule, }) { this.logger = logger; this.envSettings = envSettings; @@ -56,6 +57,7 @@ class MongoDB { Object.assign(this, settingsModule); this.statusPageModule = statusPageModule; Object.assign(this, diagnosticModule); + this.networkCheckModule = networkCheckModule; } get serviceName() { diff --git a/server/src/db/mongo/modules/networkCheckModule.js b/server/src/db/mongo/modules/networkCheckModule.js index 12a86e73b..917e68440 100644 --- a/server/src/db/mongo/modules/networkCheckModule.js +++ b/server/src/db/mongo/modules/networkCheckModule.js @@ -1,44 +1,30 @@ -import NetworkCheck from "../../models/NetworkCheck.js"; - const SERVICE_NAME = "networkCheckModule"; -/** - * Creates and saves a new network check document to the database. - * @async - * @param {object} networkCheckData - The data for the new network check. This should conform to the NetworkCheckSchema. - * @param {string} networkCheckData.monitorId - The ID of the monitor associated with this check. - * @returns {Promise} A promise that resolves to the newly created network check document. - * @throws {Error} Throws an error if the database operation fails. - */ -const createNetworkCheck = async (networkCheckData) => { - try { - const networkCheck = await new NetworkCheck(networkCheckData); - await networkCheck.save(); - return networkCheck; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "createNetworkCheck"; - throw error; +class NetworkCheckModule { + constructor({ NetworkCheck }) { + this.NetworkCheck = NetworkCheck; } -}; + createNetworkCheck = async (networkCheckData) => { + try { + const networkCheck = await new this.NetworkCheck(networkCheckData); + await networkCheck.save(); + return networkCheck; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createNetworkCheck"; + throw error; + } + }; + getNetworkChecksByMonitorId = async (monitorId, limit = 100) => { + try { + const networkChecks = await this.NetworkCheck.find({ monitorId }).sort({ createdAt: -1 }).limit(limit); + return networkChecks; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getNetworkChecksByMonitorId"; + throw error; + } + }; +} -/** - * Retrieves a list of network checks for a specific monitor, sorted by most recent. - * @async - * @param {string} monitorId - The ID of the monitor to retrieve checks for. - * @param {number} [limit=100] - The maximum number of checks to return. Defaults to 100. - * @returns {Promise>} A promise that resolves to an array of network check documents. - * @throws {Error} Throws an error if the database operation fails. - */ -const getNetworkChecksByMonitorId = async (monitorId, limit = 100) => { - try { - const networkChecks = await NetworkCheck.find({ monitorId }).sort({ createdAt: -1 }).limit(limit); - return networkChecks; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getNetworkChecksByMonitorId"; - throw error; - } -}; - -export { createNetworkCheck, getNetworkChecksByMonitorId }; +export default NetworkCheckModule; From 7bf96f6194a39f17e42137c884b00979d1cfd4b3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 30 Jul 2025 15:14:36 -0700 Subject: [PATCH 136/226] module -> class --- server/src/config/services.js | 5 + .../src/controllers/notificationController.js | 14 +- server/src/db/mongo/MongoDB.js | 3 +- .../db/mongo/modules/notificationModule.js | 213 ++++++++---------- server/src/service/business/monitorService.js | 2 +- .../infrastructure/notificationService.js | 2 +- 6 files changed, 110 insertions(+), 129 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index 762a85d26..2704baad0 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -52,6 +52,7 @@ import Team from "../db/models/Team.js"; import MaintenanceWindow from "../db/models/MaintenanceWindow.js"; import MonitorStats from "../db/models/MonitorStats.js"; import NetworkCheck from "../db/models/NetworkCheck.js"; +import Notification from "../db/models/Notification.js"; import InviteModule from "../db/mongo/modules/inviteModule.js"; import CheckModule from "../db/mongo/modules/checkModule.js"; @@ -61,6 +62,7 @@ import HardwareCheckModule from "../db/mongo/modules/hardwareCheckModule.js"; import MaintenanceWindowModule from "../db/mongo/modules/maintenanceWindowModule.js"; import MonitorModule from "../db/mongo/modules/monitorModule.js"; import NetworkCheckModule from "../db/mongo/modules/networkCheckModule.js"; +import NotificationModule from "../db/mongo/modules/notificationModule.js"; export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); @@ -93,6 +95,8 @@ export const initializeServices = async ({ logger, envSettings, settingsService NormalizeDataUptimeDetails, }); const networkCheckModule = new NetworkCheckModule({ NetworkCheck }); + const notificationModule = new NotificationModule({ Notification, Monitor }); + const db = new MongoDB({ logger, envSettings, @@ -104,6 +108,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService maintenanceWindowModule, monitorModule, networkCheckModule, + notificationModule, }); await db.connect(); diff --git a/server/src/controllers/notificationController.js b/server/src/controllers/notificationController.js index bffb8ee6d..e00dd5b6f 100755 --- a/server/src/controllers/notificationController.js +++ b/server/src/controllers/notificationController.js @@ -53,7 +53,7 @@ class NotificationController extends BaseController { body.userId = userId; body.teamId = teamId; - const notification = await this.db.createNotification(body); + const notification = await this.db.notificationModule.createNotification(body); return res.success({ msg: "Notification created successfully", data: notification, @@ -70,7 +70,7 @@ class NotificationController extends BaseController { throw this.errorService.createBadRequestError("Team ID is required"); } - const notifications = await this.db.getNotificationsByTeamId(teamId); + const notifications = await this.db.notificationModule.getNotificationsByTeamId(teamId); return res.success({ msg: "Notifications fetched successfully", @@ -88,12 +88,12 @@ class NotificationController extends BaseController { throw this.errorService.createBadRequestError("Team ID is required"); } - const notification = await this.db.getNotificationById(req.params.id); + const notification = await this.db.notificationModule.getNotificationById(req.params.id); if (!notification.teamId.equals(teamId)) { throw this.errorService.createAuthorizationError(); } - await this.db.deleteNotificationById(req.params.id); + await this.db.notificationModule.deleteNotificationById(req.params.id); return res.success({ msg: "Notification deleted successfully", }); @@ -104,7 +104,7 @@ class NotificationController extends BaseController { getNotificationById = this.asyncHandler( async (req, res) => { - const notification = await this.db.getNotificationById(req.params.id); + const notification = await this.db.notificationModule.getNotificationById(req.params.id); const teamId = req?.user?.teamId; if (!teamId) { @@ -134,13 +134,13 @@ class NotificationController extends BaseController { throw this.errorService.createBadRequestError("Team ID is required"); } - const notification = await this.db.getNotificationById(req.params.id); + const notification = await this.db.notificationModule.getNotificationById(req.params.id); if (!notification.teamId.equals(teamId)) { throw this.errorService.createAuthorizationError(); } - const editedNotification = await this.db.editNotification(req.params.id, req.body); + const editedNotification = await this.db.notificationModule.editNotification(req.params.id, req.body); return res.success({ msg: "Notification updated successfully", data: editedNotification, diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index 96f8062c3..2e13e0a42 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -41,6 +41,7 @@ class MongoDB { maintenanceWindowModule, monitorModule, networkCheckModule, + notificationModule, }) { this.logger = logger; this.envSettings = envSettings; @@ -53,7 +54,7 @@ class MongoDB { this.checkModule = checkModule; this.maintenanceWindowModule = maintenanceWindowModule; this.monitorModule = monitorModule; - Object.assign(this, notificationModule); + this.notificationModule = notificationModule; Object.assign(this, settingsModule); this.statusPageModule = statusPageModule; Object.assign(this, diagnosticModule); diff --git a/server/src/db/mongo/modules/notificationModule.js b/server/src/db/mongo/modules/notificationModule.js index 2348a6321..3c6edb54a 100755 --- a/server/src/db/mongo/modules/notificationModule.js +++ b/server/src/db/mongo/modules/notificationModule.js @@ -1,125 +1,100 @@ -import Notification from "../../models/Notification.js"; -import Monitor from "../../models/Monitor.js"; +// import Notification from "../../models/Notification.js"; +// import Monitor from "../../models/Monitor.js"; const SERVICE_NAME = "notificationModule"; -/** - * Creates a new notification. - * @param {Object} notificationData - The data for the new notification. - * @param {mongoose.Types.ObjectId} notificationData.monitorId - The ID of the monitor. - * @param {string} notificationData.type - The type of the notification (e.g., "email", "sms"). - * @param {string} [notificationData.address] - The address for the notification (if applicable). - * @param {string} [notificationData.phone] - The phone number for the notification (if applicable). - * @returns {Promise} The created notification. - * @throws Will throw an error if the notification cannot be created. - */ -const createNotification = async (notificationData) => { - try { - const notification = await new Notification({ ...notificationData }).save(); - return notification; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "createNotification"; - throw error; - } -}; -const getNotificationsByTeamId = async (teamId) => { - try { - const notifications = await Notification.find({ teamId }); - return notifications; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getNotificationsByTeamId"; - throw error; +class NotificationModule { + constructor({ Notification, Monitor }) { + this.Notification = Notification; + this.Monitor = Monitor; } -}; - -const getNotificationsByIds = async (notificationIds) => { - try { - const notifications = await Notification.find({ _id: { $in: notificationIds } }); - return notifications; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getNotificationsByIds"; - throw error; - } -}; - -/** - * Retrieves notifications by monitor ID. - * @param {mongoose.Types.ObjectId} monitorId - The ID of the monitor. - * @returns {Promise>} An array of notifications. - * @throws Will throw an error if the notifications cannot be retrieved. - */ -const getNotificationsByMonitorId = async (monitorId) => { - try { - const notifications = await Notification.find({ monitorId }); - return notifications; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getNotificationsByMonitorId"; - throw error; - } -}; - -const deleteNotificationsByMonitorId = async (monitorId) => { - try { - const result = await Notification.deleteMany({ monitorId }); - return result.deletedCount; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteNotificationsByMonitorId"; - throw error; - } -}; - -const deleteNotificationById = async (id) => { - try { - const notification = await Notification.findById(id); - if (!notification) { - throw new Error("Notification not found"); + createNotification = async (notificationData) => { + try { + const notification = await new this.Notification({ ...notificationData }).save(); + return notification; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createNotification"; + throw error; } + }; + getNotificationsByTeamId = async (teamId) => { + try { + const notifications = await this.Notification.find({ teamId }); + return notifications; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getNotificationsByTeamId"; + throw error; + } + }; + getNotificationsByIds = async (notificationIds) => { + try { + const notifications = await this.Notification.find({ _id: { $in: notificationIds } }); + return notifications; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getNotificationsByIds"; + throw error; + } + }; + getNotificationsByMonitorId = async (monitorId) => { + try { + const notifications = await this.Notification.find({ monitorId }); + return notifications; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getNotificationsByMonitorId"; + throw error; + } + }; + deleteNotificationsByMonitorId = async (monitorId) => { + try { + const result = await this.Notification.deleteMany({ monitorId }); + return result.deletedCount; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteNotificationsByMonitorId"; + throw error; + } + }; + deleteNotificationById = async (id) => { + try { + const notification = await this.Notification.findById(id); + if (!notification) { + throw new Error("Notification not found"); + } - const result = await Notification.findByIdAndDelete(id); - await Monitor.updateMany({ notifications: id }, { $pull: { notifications: id } }); - return result; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deleteNotificationById"; - throw error; - } -}; + const result = await this.Notification.findByIdAndDelete(id); + await this.Monitor.updateMany({ notifications: id }, { $pull: { notifications: id } }); + return result; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteNotificationById"; + throw error; + } + }; + getNotificationById = async (id) => { + try { + const notification = await this.Notification.findById(id); + return notification; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getNotificationById"; + throw error; + } + }; + editNotification = async (id, notificationData) => { + try { + const notification = await this.Notification.findByIdAndUpdate(id, notificationData, { + new: true, + }); + return notification; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "editNotification"; + throw error; + } + }; +} -const getNotificationById = async (id) => { - try { - const notification = await Notification.findById(id); - return notification; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getNotificationById"; - throw error; - } -}; - -const editNotification = async (id, notificationData) => { - try { - const notification = await Notification.findByIdAndUpdate(id, notificationData, { - new: true, - }); - return notification; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "editNotification"; - throw error; - } -}; - -export { - createNotification, - getNotificationsByTeamId, - getNotificationsByIds, - getNotificationsByMonitorId, - deleteNotificationsByMonitorId, - deleteNotificationById, - getNotificationById, - editNotification, -}; +export default NotificationModule; diff --git a/server/src/service/business/monitorService.js b/server/src/service/business/monitorService.js index e9254948a..f9e5a8875 100644 --- a/server/src/service/business/monitorService.js +++ b/server/src/service/business/monitorService.js @@ -155,7 +155,7 @@ class MonitorService { await this.jobQueue.deleteJob(monitor); await this.db.checkModule.deleteChecks(monitor._id); await this.db.deletePageSpeedChecksByMonitorId(monitor._id); - await this.db.deleteNotificationsByMonitorId(monitor._id); + await this.db.notificationsModule.deleteNotificationsByMonitorId(monitor._id); } catch (error) { this.logger.warn({ message: `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`, diff --git a/server/src/service/infrastructure/notificationService.js b/server/src/service/infrastructure/notificationService.js index 6af4fd20a..8c34519f3 100644 --- a/server/src/service/infrastructure/notificationService.js +++ b/server/src/service/infrastructure/notificationService.js @@ -81,7 +81,7 @@ class NotificationService { } async notifyAll({ notificationIDs, subject, html, content }) { - const notifications = await this.db.getNotificationsByIds(notificationIDs); + const notifications = await this.db.notificationModule.getNotificationsByIds(notificationIDs); // Map each notification to a test promise const promises = notifications.map(async (notification) => { From b5f4c041658a50aa927ec79ebee26b088146d58f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 30 Jul 2025 15:21:22 -0700 Subject: [PATCH 137/226] module->class --- server/src/config/services.js | 3 + server/src/db/mongo/MongoDB.js | 3 +- .../db/mongo/modules/pageSpeedCheckModule.js | 80 +++++++------------ server/src/service/business/monitorService.js | 2 +- .../service/infrastructure/bufferService.js | 2 +- 5 files changed, 34 insertions(+), 56 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index 2704baad0..93ad0ce46 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -63,6 +63,7 @@ import MaintenanceWindowModule from "../db/mongo/modules/maintenanceWindowModule import MonitorModule from "../db/mongo/modules/monitorModule.js"; import NetworkCheckModule from "../db/mongo/modules/networkCheckModule.js"; import NotificationModule from "../db/mongo/modules/notificationModule.js"; +import PageSpeedCheckModule from "../db/mongo/modules/pageSpeedCheckModule.js"; export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); @@ -96,6 +97,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService }); const networkCheckModule = new NetworkCheckModule({ NetworkCheck }); const notificationModule = new NotificationModule({ Notification, Monitor }); + const pageSpeedCheckModule = new PageSpeedCheckModule({ PageSpeedCheck }); const db = new MongoDB({ logger, @@ -109,6 +111,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService monitorModule, networkCheckModule, notificationModule, + pageSpeedCheckModule, }); await db.connect(); diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index 2e13e0a42..de6ca0c4e 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -42,6 +42,7 @@ class MongoDB { monitorModule, networkCheckModule, notificationModule, + pageSpeedCheckModule, }) { this.logger = logger; this.envSettings = envSettings; @@ -49,7 +50,7 @@ class MongoDB { this.inviteModule = inviteModule; Object.assign(this, recoveryModule); Object.assign(this, monitorModule); - Object.assign(this, pageSpeedCheckModule); + this.pageSpeedCheckModule = pageSpeedCheckModule; this.hardwareCheckModule = hardwareCheckModule; this.checkModule = checkModule; this.maintenanceWindowModule = maintenanceWindowModule; diff --git a/server/src/db/mongo/modules/pageSpeedCheckModule.js b/server/src/db/mongo/modules/pageSpeedCheckModule.js index 9169a0836..b2a3eb71d 100755 --- a/server/src/db/mongo/modules/pageSpeedCheckModule.js +++ b/server/src/db/mongo/modules/pageSpeedCheckModule.js @@ -1,57 +1,31 @@ -import PageSpeedCheck from "../../models/PageSpeedCheck.js"; +// import PageSpeedCheck from "../../models/PageSpeedCheck.js"; const SERVICE_NAME = "pageSpeedCheckModule"; -/** - * Create a PageSpeed check for a monitor - * @async - * @param {Object} pageSpeedCheckData - * @param {string} pageSpeedCheckData.monitorId - * @param {number} pageSpeedCheckData.accessibility - * @param {number} pageSpeedCheckData.bestPractices - * @param {number} pageSpeedCheckData.seo - * @param {number} pageSpeedCheckData.performance - * @returns {Promise} - * @throws {Error} - */ -const createPageSpeedCheck = async (pageSpeedCheckData) => { - try { - const pageSpeedCheck = await new PageSpeedCheck({ - ...pageSpeedCheckData, - }).save(); - return pageSpeedCheck; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "createPageSpeedCheck"; - throw error; - } -}; -const createPageSpeedChecks = async (pageSpeedChecks) => { - try { - await PageSpeedCheck.insertMany(pageSpeedChecks, { ordered: false }); - return true; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "createPageSpeedCheck"; - throw error; - } -}; -/** - * Delete all PageSpeed checks for a monitor - * @async - * @param {string} monitorId - * @returns {number} - * @throws {Error} - */ - -const deletePageSpeedChecksByMonitorId = async (monitorId) => { - try { - const result = await PageSpeedCheck.deleteMany({ monitorId }); - return result.deletedCount; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "deletePageSpeedChecksByMonitorId"; - throw error; +class PageSpeedCheckModule { + constructor({ PageSpeedCheck }) { + this.PageSpeedCheck = PageSpeedCheck; } -}; -export { createPageSpeedCheck, createPageSpeedChecks, deletePageSpeedChecksByMonitorId }; + createPageSpeedChecks = async (pageSpeedChecks) => { + try { + await this.PageSpeedCheck.insertMany(pageSpeedChecks, { ordered: false }); + return true; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createPageSpeedCheck"; + throw error; + } + }; + + deletePageSpeedChecksByMonitorId = async (monitorId) => { + try { + const result = await this.PageSpeedCheck.deleteMany({ monitorId }); + return result.deletedCount; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deletePageSpeedChecksByMonitorId"; + throw error; + } + }; +} +export default PageSpeedCheckModule; diff --git a/server/src/service/business/monitorService.js b/server/src/service/business/monitorService.js index f9e5a8875..c52b5cadf 100644 --- a/server/src/service/business/monitorService.js +++ b/server/src/service/business/monitorService.js @@ -154,7 +154,7 @@ class MonitorService { try { await this.jobQueue.deleteJob(monitor); await this.db.checkModule.deleteChecks(monitor._id); - await this.db.deletePageSpeedChecksByMonitorId(monitor._id); + await this.db.pageSpeedCheckModule.deletePageSpeedChecksByMonitorId(monitor._id); await this.db.notificationsModule.deleteNotificationsByMonitorId(monitor._id); } catch (error) { this.logger.warn({ diff --git a/server/src/service/infrastructure/bufferService.js b/server/src/service/infrastructure/bufferService.js index b29cf533b..46da6bef3 100755 --- a/server/src/service/infrastructure/bufferService.js +++ b/server/src/service/infrastructure/bufferService.js @@ -22,7 +22,7 @@ class BufferService { }; this.OPERATION_MAP = { checks: this.db.checkModule.createChecks, - pagespeedChecks: this.db.createPageSpeedChecks, + pagespeedChecks: this.db.pageSpeedCheckModule.createPageSpeedChecks, hardwareChecks: this.db.hardwareCheckModule.createHardwareChecks, }; From 48cf8a5e73c4c256eb699ddd5622ff402287509e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 30 Jul 2025 15:34:03 -0700 Subject: [PATCH 138/226] module -> class --- server/src/config/services.js | 4 + server/src/db/mongo/MongoDB.js | 3 +- server/src/db/mongo/modules/recoveryModule.js | 152 +++++++++--------- server/src/service/business/userService.js | 6 +- 4 files changed, 86 insertions(+), 79 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index 93ad0ce46..21608fd59 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -53,6 +53,7 @@ import MaintenanceWindow from "../db/models/MaintenanceWindow.js"; import MonitorStats from "../db/models/MonitorStats.js"; import NetworkCheck from "../db/models/NetworkCheck.js"; import Notification from "../db/models/Notification.js"; +import RecoveryToken from "../db/models/RecoveryToken.js"; import InviteModule from "../db/mongo/modules/inviteModule.js"; import CheckModule from "../db/mongo/modules/checkModule.js"; @@ -64,6 +65,7 @@ import MonitorModule from "../db/mongo/modules/monitorModule.js"; import NetworkCheckModule from "../db/mongo/modules/networkCheckModule.js"; import NotificationModule from "../db/mongo/modules/notificationModule.js"; import PageSpeedCheckModule from "../db/mongo/modules/pageSpeedCheckModule.js"; +import RecoveryModule from "../db/mongo/modules/recoveryModule.js"; export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); @@ -98,6 +100,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService const networkCheckModule = new NetworkCheckModule({ NetworkCheck }); const notificationModule = new NotificationModule({ Notification, Monitor }); const pageSpeedCheckModule = new PageSpeedCheckModule({ PageSpeedCheck }); + const recoveryModule = new RecoveryModule({ User, RecoveryToken, crypto, stringService }); const db = new MongoDB({ logger, @@ -112,6 +115,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService networkCheckModule, notificationModule, pageSpeedCheckModule, + recoveryModule, }); await db.connect(); diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index de6ca0c4e..49d54fc48 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -43,12 +43,13 @@ class MongoDB { networkCheckModule, notificationModule, pageSpeedCheckModule, + recoveryModule, }) { this.logger = logger; this.envSettings = envSettings; this.userModule = userModule; this.inviteModule = inviteModule; - Object.assign(this, recoveryModule); + this.recoveryModule = recoveryModule; Object.assign(this, monitorModule); this.pageSpeedCheckModule = pageSpeedCheckModule; this.hardwareCheckModule = hardwareCheckModule; diff --git a/server/src/db/mongo/modules/recoveryModule.js b/server/src/db/mongo/modules/recoveryModule.js index 2678f49c4..81e4a5a97 100755 --- a/server/src/db/mongo/modules/recoveryModule.js +++ b/server/src/db/mongo/modules/recoveryModule.js @@ -1,86 +1,88 @@ -import UserModel from "../../models/User.js"; -import RecoveryToken from "../../models/RecoveryToken.js"; -import crypto from "crypto"; -import serviceRegistry from "../../../service/system/serviceRegistry.js"; -import StringService from "../../../service/system/stringService.js"; +// import UserModel from "../../models/User.js"; +// import RecoveryToken from "../../models/RecoveryToken.js"; +// import crypto from "crypto"; +// import serviceRegistry from "../../../service/system/serviceRegistry.js"; +// import StringService from "../../../service/system/stringService.js"; const SERVICE_NAME = "recoveryModule"; -/** - * Request a recovery token - * @async - * @param {string} email - * @returns {Promise} - * @throws {Error} - */ -const requestRecoveryToken = async (email) => { - try { - // Delete any existing tokens - await RecoveryToken.deleteMany({ email }); - let recoveryToken = new RecoveryToken({ - email, - token: crypto.randomBytes(32).toString("hex"), - }); - await recoveryToken.save(); - return recoveryToken; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "requestRecoveryToken"; - throw error; +class RecoveryModule { + constructor({ User, RecoveryToken, crypto, stringService }) { + this.User = User; + this.RecoveryToken = RecoveryToken; + this.crypto = crypto; + this.stringService = stringService; } -}; -const validateRecoveryToken = async (candidateToken) => { - const stringService = serviceRegistry.get(StringService.SERVICE_NAME); - try { - const recoveryToken = await RecoveryToken.findOne({ - token: candidateToken, - }); - if (recoveryToken !== null) { + requestRecoveryToken = async (email) => { + try { + // Delete any existing tokens + await this.RecoveryToken.deleteMany({ email }); + let recoveryToken = new this.RecoveryToken({ + email, + token: this.crypto.randomBytes(32).toString("hex"), + }); + await recoveryToken.save(); return recoveryToken; - } else { - throw new Error(stringService.dbTokenNotFound); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "requestRecoveryToken"; + throw error; } - } catch (error) { - error.service = SERVICE_NAME; - error.method = "validateRecoveryToken"; - throw error; - } -}; - -const resetPassword = async (password, candidateToken) => { - const stringService = serviceRegistry.get(StringService.SERVICE_NAME); - try { - const newPassword = password; - - // Validate token again - const recoveryToken = await validateRecoveryToken(candidateToken); - const user = await UserModel.findOne({ email: recoveryToken.email }); - - if (user === null) { - throw new Error(stringService.dbUserNotFound); + }; + validateRecoveryToken = async (candidateToken) => { + try { + const recoveryToken = await this.RecoveryToken.findOne({ + token: candidateToken, + }); + if (recoveryToken !== null) { + return recoveryToken; + } else { + throw new Error(this.stringService.dbTokenNotFound); + } + } catch (error) { + error.service = SERVICE_NAME; + error.method = "validateRecoveryToken"; + throw error; } + }; - const match = await user.comparePassword(newPassword); - if (match === true) { - throw new Error(stringService.dbResetPasswordBadMatch); + resetPassword = async (password, candidateToken) => { + try { + const newPassword = password; + + // Validate token again + const recoveryToken = await this.validateRecoveryToken(candidateToken); + const user = await this.User.findOne({ email: recoveryToken.email }); + + if (user === null) { + console.log("WTF2"); + throw new Error(this.stringService.dbUserNotFound); + } + + const match = await user.comparePassword(newPassword); + + if (match === true) { + console.log("WTF"); + throw new Error("Password cannot be the same as the old password"); + } + + user.password = newPassword; + await user.save(); + await this.RecoveryToken.deleteMany({ email: recoveryToken.email }); + // Fetch the user again without the password + const userWithoutPassword = await this.User.findOne({ + email: recoveryToken.email, + }) + .select("-password") + .select("-profileImage"); + return userWithoutPassword; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "resetPassword"; + throw error; } + }; +} - user.password = newPassword; - await user.save(); - await RecoveryToken.deleteMany({ email: recoveryToken.email }); - // Fetch the user again without the password - const userWithoutPassword = await UserModel.findOne({ - email: recoveryToken.email, - }) - .select("-password") - .select("-profileImage"); - return userWithoutPassword; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "resetPassword"; - throw error; - } -}; - -export { requestRecoveryToken, validateRecoveryToken, resetPassword }; +export default RecoveryModule; diff --git a/server/src/service/business/userService.js b/server/src/service/business/userService.js index 3ffa4c957..d439a942b 100644 --- a/server/src/service/business/userService.js +++ b/server/src/service/business/userService.js @@ -133,7 +133,7 @@ class UserService { requestRecovery = async (email) => { const user = await this.db.userModule.getUserByEmail(email); - const recoveryToken = await this.db.requestRecoveryToken(email); + const recoveryToken = await this.db.recoveryModule.requestRecoveryToken(email); const name = user.firstName; const { clientHost } = this.settingsService.getSettings(); const url = `${clientHost}/set-new-password/${recoveryToken.token}`; @@ -148,11 +148,11 @@ class UserService { }; validateRecovery = async (recoveryToken) => { - await this.db.validateRecoveryToken(recoveryToken); + await this.db.recoveryModule.validateRecoveryToken(recoveryToken); }; resetPassword = async (password, recoveryToken) => { - const user = await this.db.resetPassword(password, recoveryToken); + const user = await this.db.recoveryModule.resetPassword(password, recoveryToken); const appSettings = await this.settingsService.getSettings(); const token = this.issueToken(user._doc, appSettings); return { user, token }; From 54d3392cf153d8536846e605c50828666b99bb9a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 30 Jul 2025 15:35:16 -0700 Subject: [PATCH 139/226] remove commented out imports --- server/src/db/mongo/modules/recoveryModule.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/server/src/db/mongo/modules/recoveryModule.js b/server/src/db/mongo/modules/recoveryModule.js index 81e4a5a97..d8d5b25c4 100755 --- a/server/src/db/mongo/modules/recoveryModule.js +++ b/server/src/db/mongo/modules/recoveryModule.js @@ -1,9 +1,3 @@ -// import UserModel from "../../models/User.js"; -// import RecoveryToken from "../../models/RecoveryToken.js"; -// import crypto from "crypto"; -// import serviceRegistry from "../../../service/system/serviceRegistry.js"; -// import StringService from "../../../service/system/stringService.js"; - const SERVICE_NAME = "recoveryModule"; class RecoveryModule { From 94a7044b7be4b1d9ae7a6561275ca06b296100d8 Mon Sep 17 00:00:00 2001 From: Mohansai Mallineni Date: Thu, 31 Jul 2025 05:07:42 +0530 Subject: [PATCH 140/226] Replaced existing icons with the provided svg's in the Incident page block area --- .../Components/StatusBoxes/StatusBox.jsx | 16 ++++++++-------- client/src/assets/icons/alert-icon.svg | 3 +++ client/src/assets/icons/check-icon.svg | 3 +++ client/src/assets/icons/close-icon.svg | 3 +++ client/src/assets/icons/warning-icon.svg | 3 +++ 5 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 client/src/assets/icons/alert-icon.svg create mode 100644 client/src/assets/icons/check-icon.svg create mode 100644 client/src/assets/icons/close-icon.svg create mode 100644 client/src/assets/icons/warning-icon.svg diff --git a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx index b19f017e7..90e664b5a 100644 --- a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx +++ b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx @@ -2,10 +2,10 @@ import PropTypes from "prop-types"; import { useTheme } from "@emotion/react"; import { Box, Stack, Typography } from "@mui/material"; import Background from "../../../../assets/Images/background-grid.svg?react"; -import MonitorHeartOutlinedIcon from "@mui/icons-material/MonitorHeartOutlined"; -import TaskAltOutlinedIcon from "@mui/icons-material/TaskAltOutlined"; -import CancelOutlinedIcon from "@mui/icons-material/CancelOutlined"; -import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded"; +import AlertIcon from "../../../../assets/icons/alert-icon.svg?react"; +import CheckIcon from "../../../../assets/icons/check-icon.svg?react"; +import CloseIcon from "../../../../assets/icons/close-icon.svg?react"; +import WarningIcon from "../../../../assets/icons/warning-icon.svg?react"; const StatusBox = ({ title, value, status }) => { const theme = useTheme(); @@ -22,28 +22,28 @@ const StatusBox = ({ title, value, status }) => { color = theme.palette.success.lowContrast; icon = ( - + ); } else if (status === "down") { color = theme.palette.error.lowContrast; icon = ( - + ); } else if (status === "paused") { color = theme.palette.warning.lowContrast; icon = ( - + ); } else { color = theme.palette.accent.main; icon = ( - + ); } diff --git a/client/src/assets/icons/alert-icon.svg b/client/src/assets/icons/alert-icon.svg new file mode 100644 index 000000000..1dc7fc2b7 --- /dev/null +++ b/client/src/assets/icons/alert-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/assets/icons/check-icon.svg b/client/src/assets/icons/check-icon.svg new file mode 100644 index 000000000..9a2cfac5a --- /dev/null +++ b/client/src/assets/icons/check-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/assets/icons/close-icon.svg b/client/src/assets/icons/close-icon.svg new file mode 100644 index 000000000..e1b6b690a --- /dev/null +++ b/client/src/assets/icons/close-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/assets/icons/warning-icon.svg b/client/src/assets/icons/warning-icon.svg new file mode 100644 index 000000000..468611758 --- /dev/null +++ b/client/src/assets/icons/warning-icon.svg @@ -0,0 +1,3 @@ + + + From 73a6277feb1e24e35918f9c4bcbafaf1dc3c4a50 Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Thu, 31 Jul 2025 16:04:41 +0530 Subject: [PATCH 141/226] Refactored Code --- .../src/Pages/Infrastructure/Create/index.jsx | 83 ++++++++----------- 1 file changed, 33 insertions(+), 50 deletions(-) diff --git a/client/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx index f2833d52f..b03342ef0 100644 --- a/client/src/Pages/Infrastructure/Create/index.jsx +++ b/client/src/Pages/Infrastructure/Create/index.jsx @@ -94,76 +94,59 @@ const CreateInfrastructureMonitor = () => { useEffect(() => { if (isCreate) { - // If global settings are not loaded yet, use default thresholds if (globalSettingsLoading) return; - // Create mode: use global thresholds - const globalThresholds = globalSettings?.data?.settings?.globalThresholds || {}; - const defaultThresholds = { - cpu: "", - memory: "", - disk: "", - temperature: "", - }; + const gt = globalSettings?.data?.settings?.globalThresholds || {}; - setInfrastructureMonitor((prev) => ({ - ...prev, + setHttps(false); + + setInfrastructureMonitor({ url: "", name: "", notifications: [], interval: 0.25, - cpu: globalThresholds.cpu != null, - usage_cpu: - globalThresholds.cpu != null - ? globalThresholds.cpu.toString() - : defaultThresholds.cpu, - memory: globalThresholds.memory != null, - usage_memory: - globalThresholds.memory != null - ? globalThresholds.memory.toString() - : defaultThresholds.memory, - disk: globalThresholds.disk != null, - usage_disk: - globalThresholds.disk != null - ? globalThresholds.disk.toString() - : defaultThresholds.disk, - temperature: globalThresholds.temperature != null, - usage_temperature: - globalThresholds.temperature != null - ? globalThresholds.temperature.toString() - : defaultThresholds.temperature, - })); + cpu: gt.cpu !== undefined, + usage_cpu: gt.cpu !== undefined ? gt.cpu.toString() : "", + memory: gt.memory !== undefined, + usage_memory: gt.memory !== undefined ? gt.memory.toString() : "", + disk: gt.disk !== undefined, + usage_disk: gt.disk !== undefined ? gt.disk.toString() : "", + temperature: gt.temperature !== undefined, + usage_temperature: gt.temperature !== undefined ? gt.temperature.toString() : "", + secret: "", + }); } else if (monitor) { - // Edit mode: use monitor only, ignore global thresholds - setInfrastructureMonitor((prev) => ({ - ...prev, - url: monitor.url?.replace(/^https?:\/\//, "") || "", + const { thresholds = {} } = monitor; + + setHttps(monitor.url.startsWith("https")); + + setInfrastructureMonitor({ + url: monitor.url.replace(/^https?:\/\//, ""), name: monitor.name || "", notifications: monitor.notifications || [], interval: monitor.interval / MS_PER_MINUTE, - cpu: monitor.thresholds?.usage_cpu != null, + cpu: thresholds.usage_cpu !== undefined, usage_cpu: - monitor.thresholds?.usage_cpu != null - ? (monitor.thresholds.usage_cpu * 100).toString() + thresholds.usage_cpu !== undefined + ? (thresholds.usage_cpu * 100).toString() : "", - memory: monitor.thresholds?.usage_memory != null, + memory: thresholds.usage_memory !== undefined, usage_memory: - monitor.thresholds?.usage_memory != null - ? (monitor.thresholds.usage_memory * 100).toString() + thresholds.usage_memory !== undefined + ? (thresholds.usage_memory * 100).toString() : "", - disk: monitor.thresholds?.usage_disk != null, + disk: thresholds.usage_disk !== undefined, usage_disk: - monitor.thresholds?.usage_disk != null - ? (monitor.thresholds.usage_disk * 100).toString() + thresholds.usage_disk !== undefined + ? (thresholds.usage_disk * 100).toString() : "", - temperature: monitor.thresholds?.usage_temperature != null, + temperature: thresholds.usage_temperature !== undefined, usage_temperature: - monitor.thresholds?.usage_temperature != null - ? (monitor.thresholds.usage_temperature * 100).toString() + thresholds.usage_temperature !== undefined + ? (thresholds.usage_temperature * 100).toString() : "", secret: monitor.secret || "", - })); - + }); } }, [isCreate, monitor, globalSettings, globalSettingsLoading]); From 927e8a88b83ac7ffc98b7fd0136534a4f67d4cce Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 31 Jul 2025 09:18:12 -0700 Subject: [PATCH 142/226] remove console log --- server/src/db/mongo/modules/recoveryModule.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/src/db/mongo/modules/recoveryModule.js b/server/src/db/mongo/modules/recoveryModule.js index d8d5b25c4..cc22306a3 100755 --- a/server/src/db/mongo/modules/recoveryModule.js +++ b/server/src/db/mongo/modules/recoveryModule.js @@ -50,14 +50,12 @@ class RecoveryModule { const user = await this.User.findOne({ email: recoveryToken.email }); if (user === null) { - console.log("WTF2"); throw new Error(this.stringService.dbUserNotFound); } const match = await user.comparePassword(newPassword); if (match === true) { - console.log("WTF"); throw new Error("Password cannot be the same as the old password"); } From 126767ed5c97f255d28298065de5f952c2e2a313 Mon Sep 17 00:00:00 2001 From: Mohansai Mallineni Date: Thu, 31 Jul 2025 22:39:17 +0530 Subject: [PATCH 143/226] Replaced existing icons with the provided svg's in the Incident page block area --- client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx index 90e664b5a..adf4faa8b 100644 --- a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx +++ b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx @@ -12,7 +12,6 @@ const StatusBox = ({ title, value, status }) => { let sharedStyles = { position: "absolute", right: 8, - opacity: 0.5, "& svg path": { stroke: theme.palette.primary.contrastTextTertiary }, }; From 7a190f7c69e86920683f67eca3275f219efb6ec1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 31 Jul 2025 11:57:55 -0700 Subject: [PATCH 144/226] module -> class --- server/src/config/services.js | 4 ++ server/src/controllers/settingsController.js | 2 +- server/src/db/mongo/MongoDB.js | 4 +- server/src/db/mongo/modules/settingsModule.js | 63 +++++++++---------- server/src/service/business/userService.js | 2 +- 5 files changed, 37 insertions(+), 38 deletions(-) diff --git a/server/src/config/services.js b/server/src/config/services.js index 21608fd59..4fb9424cc 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -54,6 +54,7 @@ import MonitorStats from "../db/models/MonitorStats.js"; import NetworkCheck from "../db/models/NetworkCheck.js"; import Notification from "../db/models/Notification.js"; import RecoveryToken from "../db/models/RecoveryToken.js"; +import AppSettings from "../db/models/AppSettings.js"; import InviteModule from "../db/mongo/modules/inviteModule.js"; import CheckModule from "../db/mongo/modules/checkModule.js"; @@ -66,6 +67,7 @@ import NetworkCheckModule from "../db/mongo/modules/networkCheckModule.js"; import NotificationModule from "../db/mongo/modules/notificationModule.js"; import PageSpeedCheckModule from "../db/mongo/modules/pageSpeedCheckModule.js"; import RecoveryModule from "../db/mongo/modules/recoveryModule.js"; +import SettingsModule from "../db/mongo/modules/settingsModule.js"; export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); @@ -101,6 +103,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService const notificationModule = new NotificationModule({ Notification, Monitor }); const pageSpeedCheckModule = new PageSpeedCheckModule({ PageSpeedCheck }); const recoveryModule = new RecoveryModule({ User, RecoveryToken, crypto, stringService }); + const settingsModule = new SettingsModule({ AppSettings }); const db = new MongoDB({ logger, @@ -116,6 +119,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService notificationModule, pageSpeedCheckModule, recoveryModule, + settingsModule, }); await db.connect(); diff --git a/server/src/controllers/settingsController.js b/server/src/controllers/settingsController.js index c66024025..7da724d23 100755 --- a/server/src/controllers/settingsController.js +++ b/server/src/controllers/settingsController.js @@ -55,7 +55,7 @@ class SettingsController extends BaseController { async (req, res) => { await updateAppSettingsBodyValidation.validateAsync(req.body); - const updatedSettings = await this.db.updateAppSettings(req.body); + const updatedSettings = await this.db.settingsModule.updateAppSettings(req.body); const returnSettings = this.buildAppSettings(updatedSettings); return res.success({ msg: this.stringService.updateAppSettings, diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index 49d54fc48..2e98a0335 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -44,20 +44,20 @@ class MongoDB { notificationModule, pageSpeedCheckModule, recoveryModule, + settingsModule, }) { this.logger = logger; this.envSettings = envSettings; this.userModule = userModule; this.inviteModule = inviteModule; this.recoveryModule = recoveryModule; - Object.assign(this, monitorModule); this.pageSpeedCheckModule = pageSpeedCheckModule; this.hardwareCheckModule = hardwareCheckModule; this.checkModule = checkModule; this.maintenanceWindowModule = maintenanceWindowModule; this.monitorModule = monitorModule; this.notificationModule = notificationModule; - Object.assign(this, settingsModule); + this.settingsModule = settingsModule; this.statusPageModule = statusPageModule; Object.assign(this, diagnosticModule); this.networkCheckModule = networkCheckModule; diff --git a/server/src/db/mongo/modules/settingsModule.js b/server/src/db/mongo/modules/settingsModule.js index df325d2fa..37b851bf2 100755 --- a/server/src/db/mongo/modules/settingsModule.js +++ b/server/src/db/mongo/modules/settingsModule.js @@ -1,41 +1,36 @@ -import AppSettings from "../../models/AppSettings.js"; +// import AppSettings from "../../models/AppSettings.js"; const SERVICE_NAME = "SettingsModule"; -const getAppSettings = async () => { - try { - const settings = AppSettings.findOne(); - return settings; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getSettings"; - throw error; +class SettingsModule { + constructor({ AppSettings }) { + this.AppSettings = AppSettings; } -}; -const updateAppSettings = async (newSettings) => { - try { - const update = { $set: { ...newSettings } }; + updateAppSettings = async (newSettings) => { + try { + const update = { $set: { ...newSettings } }; - if (newSettings.pagespeedApiKey === "") { - update.$unset = { pagespeedApiKey: "" }; - delete update.$set.pagespeedApiKey; + if (newSettings.pagespeedApiKey === "") { + update.$unset = { pagespeedApiKey: "" }; + delete update.$set.pagespeedApiKey; + } + + if (newSettings.systemEmailPassword === "") { + update.$unset = { systemEmailPassword: "" }; + delete update.$set.systemEmailPassword; + } + + await this.AppSettings.findOneAndUpdate({}, update, { + upsert: true, + }); + const settings = await this.AppSettings.findOne().select("-__v -_id -createdAt -updatedAt -singleton").lean(); + return settings; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "updateAppSettings"; + throw error; } + }; +} - if (newSettings.systemEmailPassword === "") { - update.$unset = { systemEmailPassword: "" }; - delete update.$set.systemEmailPassword; - } - - await AppSettings.findOneAndUpdate({}, update, { - upsert: true, - }); - const settings = await AppSettings.findOne().select("-__v -_id -createdAt -updatedAt -singleton").lean(); - return settings; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "updateAppSettings"; - throw error; - } -}; - -export { getAppSettings, updateAppSettings }; +export default SettingsModule; diff --git a/server/src/service/business/userService.js b/server/src/service/business/userService.js index d439a942b..0a965bced 100644 --- a/server/src/service/business/userService.js +++ b/server/src/service/business/userService.js @@ -37,7 +37,7 @@ class UserService { } else { // This is the first account, create JWT secret to use if one is not supplied by env const jwtSecret = this.crypto.randomBytes(64).toString("hex"); - await this.db.updateAppSettings({ jwtSecret }); + await this.db.settingsModule.updateAppSettings({ jwtSecret }); } const newUser = await this.db.userModule.insertUser({ ...user }, file); From ce49270cf8c00476c032553af62fd57235636e67 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 31 Jul 2025 12:00:27 -0700 Subject: [PATCH 145/226] remove unused diagnostic db module --- server/src/db/mongo/MongoDB.js | 27 ----------- .../src/db/mongo/modules/diagnosticModule.js | 48 ------------------- 2 files changed, 75 deletions(-) delete mode 100755 server/src/db/mongo/modules/diagnosticModule.js diff --git a/server/src/db/mongo/MongoDB.js b/server/src/db/mongo/MongoDB.js index 2e98a0335..0c6274320 100755 --- a/server/src/db/mongo/MongoDB.js +++ b/server/src/db/mongo/MongoDB.js @@ -1,32 +1,6 @@ import mongoose from "mongoose"; import AppSettings from "../models/AppSettings.js"; -//**************************************** -// Recovery Operations -//**************************************** -import * as recoveryModule from "./modules/recoveryModule.js"; - -//**************************************** -// Page Speed Checks -//**************************************** - -import * as pageSpeedCheckModule from "./modules/pageSpeedCheckModule.js"; - -//**************************************** -// Notifications -//**************************************** -import * as notificationModule from "./modules/notificationModule.js"; - -//**************************************** -// AppSettings -//**************************************** -import * as settingsModule from "./modules/settingsModule.js"; - -//**************************************** -// Diagnostic -//**************************************** -import * as diagnosticModule from "./modules/diagnosticModule.js"; - class MongoDB { static SERVICE_NAME = "MongoDB"; @@ -59,7 +33,6 @@ class MongoDB { this.notificationModule = notificationModule; this.settingsModule = settingsModule; this.statusPageModule = statusPageModule; - Object.assign(this, diagnosticModule); this.networkCheckModule = networkCheckModule; } diff --git a/server/src/db/mongo/modules/diagnosticModule.js b/server/src/db/mongo/modules/diagnosticModule.js deleted file mode 100755 index 8717720a3..000000000 --- a/server/src/db/mongo/modules/diagnosticModule.js +++ /dev/null @@ -1,48 +0,0 @@ -import Monitor from "../../models/Monitor.js"; -import { ObjectId } from "mongodb"; - -const SERVICE_NAME = "diagnosticModule"; -import { buildMonitorSummaryByTeamIdPipeline, buildMonitorsByTeamIdPipeline, buildFilteredMonitorsByTeamIdPipeline } from "./monitorModuleQueries.js"; - -const getMonitorsByTeamIdExecutionStats = async (req) => { - try { - let { limit, type, page, rowsPerPage, filter, field, order } = req.query; - limit = parseInt(limit); - page = parseInt(page); - rowsPerPage = parseInt(rowsPerPage); - if (field === undefined) { - field = "name"; - order = "asc"; - } - // Build match stage - const matchStage = { teamId: new ObjectId(req.params.teamId) }; - if (type !== undefined) { - matchStage.type = Array.isArray(type) ? { $in: type } : type; - } - - const summary = await Monitor.aggregate(buildMonitorSummaryByTeamIdPipeline({ matchStage })).explain("executionStats"); - - const monitors = await Monitor.aggregate(buildMonitorsByTeamIdPipeline({ matchStage, field, order })).explain("executionStats"); - - const filteredMonitors = await Monitor.aggregate( - buildFilteredMonitorsByTeamIdPipeline({ - matchStage, - filter, - page, - rowsPerPage, - field, - order, - limit, - type, - }) - ).explain("executionStats"); - - return { summary, monitors, filteredMonitors }; - } catch (error) { - error.service = SERVICE_NAME; - error.method = "getMonitorSummaryByTeamIdExecutionStats"; - throw error; - } -}; - -export { getMonitorsByTeamIdExecutionStats }; From 0779e5ea15122f16fddf4b880930e14c6c647d4b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 31 Jul 2025 12:02:52 -0700 Subject: [PATCH 146/226] remove commented out imports --- server/src/db/mongo/modules/monitorModule.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/server/src/db/mongo/modules/monitorModule.js b/server/src/db/mongo/modules/monitorModule.js index 332122df4..76f055f94 100755 --- a/server/src/db/mongo/modules/monitorModule.js +++ b/server/src/db/mongo/modules/monitorModule.js @@ -1,15 +1,3 @@ -// import Monitor from "../../models/Monitor.js"; -// import MonitorStats from "../../models/MonitorStats.js"; -// import Check from "../../models/Check.js"; -// import PageSpeedCheck from "../../models/PageSpeedCheck.js"; -// import HardwareCheck from "../../models/HardwareCheck.js"; -// import ServiceRegistry from "../../../service/system/serviceRegistry.js"; -// import StringService from "../../../service/system/stringService.js"; -// import fs from "fs"; -// import path from "path"; -// import { fileURLToPath } from "url"; -// import { ObjectId } from "mongodb"; - import { buildUptimeDetailsPipeline, buildHardwareDetailsPipeline, From 3dc332010e7d72f8dc964962b615e666926575f9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 31 Jul 2025 12:47:36 -0700 Subject: [PATCH 147/226] start refactor --- .../Components/Layouts/HomeLayout/index.css | 4 +- .../Sidebar/{index.css => index.bak} | 3 +- client/src/Components/Sidebar/index.jsx | 774 +--------------- client/src/Components/Sidebar/index_old.jsx | 873 ++++++++++++++++++ client/src/Components/Sidebar/logo.jsx | 57 ++ 5 files changed, 972 insertions(+), 739 deletions(-) rename client/src/Components/Sidebar/{index.css => index.bak} (98%) create mode 100644 client/src/Components/Sidebar/index_old.jsx create mode 100644 client/src/Components/Sidebar/logo.jsx diff --git a/client/src/Components/Layouts/HomeLayout/index.css b/client/src/Components/Layouts/HomeLayout/index.css index 2db147f41..56606a935 100644 --- a/client/src/Components/Layouts/HomeLayout/index.css +++ b/client/src/Components/Layouts/HomeLayout/index.css @@ -12,13 +12,13 @@ } } */ -.home-layout aside { +/* .home-layout aside { position: sticky; top: 0; left: 0; height: 100vh; max-width: var(--env-var-side-bar-width); -} +} */ .home-layout > div { min-height: calc(100vh - var(--env-var-spacing-2) * 2); diff --git a/client/src/Components/Sidebar/index.css b/client/src/Components/Sidebar/index.bak similarity index 98% rename from client/src/Components/Sidebar/index.css rename to client/src/Components/Sidebar/index.bak index ef921acca..a14a0f3d6 100644 --- a/client/src/Components/Sidebar/index.css +++ b/client/src/Components/Sidebar/index.bak @@ -1,5 +1,4 @@ -/* TODO */ -aside .MuiList-root svg { +TODO aside .MuiList-root svg { width: 20px; height: 20px; opacity: 0.9; diff --git a/client/src/Components/Sidebar/index.jsx b/client/src/Components/Sidebar/index.jsx index 219ceff1e..cad7d3b7d 100644 --- a/client/src/Components/Sidebar/index.jsx +++ b/client/src/Components/Sidebar/index.jsx @@ -1,19 +1,14 @@ -import React, { useEffect, useState, useRef } from "react"; -import { - Box, - Collapse, - Divider, - IconButton, - List, - ListItemButton, - ListItemIcon, - ListItemText, - Menu, - MenuItem, - Stack, - Tooltip, - Typography, -} from "@mui/material"; +import IconButton from "@mui/material/IconButton"; +import Stack from "@mui/material/Stack"; +import ArrowRight from "../../assets/icons/right-arrow.svg?react"; +import ArrowLeft from "../../assets/icons/left-arrow.svg?react"; +import List from "@mui/material/List"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Tooltip from "@mui/material/Tooltip"; +import Logo from "./logo"; + import ThemeSwitch from "../ThemeSwitch"; import Avatar from "../Avatar"; import StarPrompt from "../StarPrompt"; @@ -30,8 +25,6 @@ import PageSpeed from "../../assets/icons/page-speed.svg?react"; import Settings from "../../assets/icons/settings.svg?react"; import ArrowDown from "../../assets/icons/down-arrow.svg?react"; import ArrowUp from "../../assets/icons/up-arrow.svg?react"; -import ArrowRight from "../../assets/icons/right-arrow.svg?react"; -import ArrowLeft from "../../assets/icons/left-arrow.svg?react"; 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"; @@ -40,17 +33,13 @@ import Discussions from "../../assets/icons/discussions.svg?react"; import Notifications from "../../assets/icons/notifications.svg?react"; import Logs from "../../assets/icons/logs.svg?react"; -import "./index.css"; - // Utils -import { useLocation, useNavigate } from "react-router"; -import { useTheme } from "@emotion/react"; +import { useTheme } from "@mui/material/styles"; import { useDispatch, useSelector } from "react-redux"; -import { useTranslation } from "react-i18next"; -import { clearAuthState } from "../../Features/Auth/authSlice"; import { toggleSidebar } from "../../Features/UI/uiSlice"; -import { TurnedIn } from "@mui/icons-material"; -import { rules } from "eslint-plugin-react-refresh"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router"; const getMenu = (t) => [ { name: t("menu.uptime"), path: "uptime", icon: }, @@ -75,207 +64,25 @@ const getMenu = (t) => [ }, ]; -const getOtherMenuItems = (t) => [ - { name: t("menu.support"), path: "support", icon: }, - { - name: t("menu.discussions"), - path: "discussions", - icon: , - }, - { name: t("menu.docs"), path: "docs", icon: }, - { name: t("menu.changelog"), path: "changelog", icon: }, -]; - -const getAccountMenuItems = (t) => [ - { name: t("menu.profile"), path: "account/profile", icon: }, - { name: t("menu.password"), path: "account/password", icon: }, - { name: t("menu.team"), path: "account/team", icon: }, -]; - -/* TODO this could be a key in nested Path would be the link */ -const URL_MAP = { - support: "https://discord.com/invite/NAb6H3UTjK", - discussions: "https://github.com/bluewave-labs/checkmate/discussions", - docs: "https://bluewavelabs.gitbook.io/checkmate", - changelog: "https://github.com/bluewave-labs/checkmate/releases", -}; - -const PATH_MAP = { - monitors: "Dashboard", - pagespeed: "Dashboard", - infrastructure: "Dashboard", - account: "Account", - settings: "Settings", -}; - -/** - * @component - * Sidebar component serves as a sidebar containing a menu. - * - * @returns {JSX.Element} The JSX element representing the Sidebar component. - */ - -function Sidebar() { +const Sidebar = () => { const theme = useTheme(); - const navigate = useNavigate(); - const location = useLocation(); const dispatch = useDispatch(); const { t } = useTranslation(); - const authState = useSelector((state) => state.auth); - - const otherMenuItems = getOtherMenuItems(t); - const accountMenuItems = getAccountMenuItems(t); + const navigate = useNavigate(); + // Redux state const collapsed = useSelector((state) => state.ui.sidebar.collapsed); + + // Local state const [open, setOpen] = useState({ Dashboard: false, Account: false, Other: false }); - const [anchorEl, setAnchorEl] = useState(null); - const [popup, setPopup] = useState(); - const { user } = useSelector((state) => state.auth); - const sidebarRef = useRef(null); - const [sidebarReady, setSidebarReady] = useState(false); - const TRANSITION_DURATION = 200; - let menu = getMenu(t); - menu = menu.filter((item) => { - if (item.path === "logs") { - return user.role?.includes("admin") || user.role?.includes("superadmin"); - } - return true; - }); - - useEffect(() => { - if (!collapsed) { - setSidebarReady(false); - const timeout = setTimeout(() => { - setSidebarReady(true); - }, TRANSITION_DURATION); - return () => clearTimeout(timeout); - } else { - setSidebarReady(false); - } - }, [collapsed]); - - const renderAccountMenuItems = () => { - let filteredAccountMenuItems = [...accountMenuItems]; - - // If the user is in demo mode, remove the "Password" option - if (user.role?.includes("demo")) { - filteredAccountMenuItems = filteredAccountMenuItems.filter( - (item) => item.name !== "Password" - ); - } - - // If the user is NOT a superadmin, remove the "Team" option - if (user.role && !user.role.includes("superadmin")) { - filteredAccountMenuItems = filteredAccountMenuItems.filter( - (item) => item.name !== "Team" - ); - } - - return filteredAccountMenuItems.map((item) => ( - { - closePopup(); - navigate(item.path); - }} - sx={{ - gap: theme.spacing(2), - borderRadius: theme.shape.borderRadius, - pl: theme.spacing(4), - }} - > - {item.icon} - {item.name} - - )); - }; - - const openPopup = (event, id) => { - setAnchorEl(event.currentTarget); - setPopup(id); - }; - const closePopup = () => { - setAnchorEl(null); - }; - - /** - * Handles logging out the user - * - */ - const logout = async () => { - // Clear auth state - dispatch(clearAuthState()); - navigate("/login"); - }; - - useEffect(() => { - const matchedKey = Object.keys(PATH_MAP).find((key) => - location.pathname.includes(key) - ); - - if (matchedKey) { - setOpen((prev) => ({ ...prev, [PATH_MAP[matchedKey]]: true })); - } - }, [location]); - - const iconColor = theme.palette.primary.contrastTextTertiary; - const sidebarClassName = `${collapsed ? "collapsed" : "expanded"} ${sidebarReady ? "sidebar-ready" : ""}`; - - /* TODO refactor this, there are a some ternaries and comments in the return */ + const menu = getMenu(t); + console.log(collapsed); return ( {collapsed ? : } - {/* TODO Alignment done using padding. Use single source of truth to that*/} - - {/* TODO Abstract logo into component */} - {/* TODO Turn logo into a link */} - navigate("/")} - sx={{ cursor: "pointer" }} - > - - C - - - {t("common.appName")} - - - - - - {menu.map((item) => { - return item.path ? ( - /* If item has a path */ - - navigate(`/${item.path}`)} - sx={{ - height: "37px", - gap: theme.spacing(4), - borderRadius: theme.shape.borderRadius, - px: theme.spacing(4), - pl: theme.spacing(5), - }} - > - {item.icon} - {item.name} - - - ) : collapsed ? ( - /* TODO Do we ever get here? If item does not have a path and collapsed state is true */ - - - openPopup(event, item.name)} - sx={{ - position: "relative", - gap: theme.spacing(4), - borderRadius: theme.shape.borderRadius, - px: theme.spacing(4), - }} - > - {item.icon} - {item.name} - - - - {item.nested.map((child) => { - if ( - child.name === "Team" && - authState.user?.role && - !authState.user.role.includes("superadmin") - ) { - return null; - } - - return ( - { - const url = URL_MAP[child.path]; - if (url) { - window.open(url, "_blank", "noreferrer"); - } else { - navigate(`/${child.path}`); - } - closePopup(); - }} - sx={{ - gap: theme.spacing(4), - opacity: 0.9, - /* TODO this has no effect? */ - "& svg": { - "& path": { - stroke: theme.palette.primary.contrastTextTertiary, - strokeWidth: 1.1, - }, - }, - }} - > - {child.icon} - {child.name} - - ); - })} - - - ) : ( - /* TODO Do we ever get here? If item does not have a path and collapsed state is false */ - - - setOpen((prev) => ({ - ...Object.fromEntries(Object.keys(prev).map((key) => [key, false])), - [item.name]: !prev[item.name], - })) - } - sx={{ - gap: theme.spacing(4), - borderRadius: theme.shape.borderRadius, - px: theme.spacing(4), - }} - > - {item.icon} - {item.name} - {open[`${item.name}`] ? : } - - - - {item.nested.map((child) => { - if ( - child.name === "Team" && - authState.user?.role && - !authState.user.role.includes("superadmin") - ) { - return null; - } - - return ( - { - const url = URL_MAP[child.path]; - if (url) { - window.open(url, "_blank", "noreferrer"); - } else { - navigate(`/${child.path}`); - } - }} - sx={{ - gap: theme.spacing(4), - borderRadius: theme.shape.borderRadius, - pl: theme.spacing(4), - "&::before": { - content: `""`, - position: "absolute", - top: 0, - left: "-7px", - height: "100%", - borderLeft: 1, - borderLeftColor: theme.palette.primary.lowContrast, - }, - "&:last-child::before": { - height: "50%", - }, - "&::after": { - content: `""`, - position: "absolute", - top: "45%", - left: "-8px", - height: "3px", - width: "3px", - borderRadius: "50%", - backgroundColor: theme.palette.primary.lowContrast, - }, - "&.selected-path::after": { - /* TODO what is this selector doing? */ - backgroundColor: theme.palette.primary.contrastTextTertiary, - transform: "scale(1.2)", - }, - }} - > - {child.icon} - {child.name} - - ); - })} - - - - ); - })} - - - - {!collapsed && } - + - {otherMenuItems.map((item) => { + {menu.map((item) => { return item.path ? ( + /* If item has a path */ { - const url = URL_MAP[item.path]; - if (url) { - window.open(url, "_blank", "noreferrer"); - } else { - navigate(`/${item.path}`); - } - }} + onClick={() => navigate(`/${item.path}`)} sx={{ height: "37px", gap: theme.spacing(4), @@ -666,208 +163,15 @@ function Sidebar() { pl: theme.spacing(5), }} > - {item.icon} - {item.name} {" "} + {item.icon} + {!collapsed && {item.name}} ) : null; })} - - - {collapsed ? ( - <> - - openPopup(event, "logout")} - sx={{ p: 0, "&:focus": { outline: "none" } }} - > - - - - - ) : ( - <> - - - - {authState.user?.firstName} {authState.user?.lastName} - - - {authState.user?.role?.includes("superadmin") - ? t("roles.superAdmin") - : authState.user?.role?.includes("admin") - ? t("roles.admin") - : authState.user?.role?.includes("user") - ? t("roles.teamMember") - : authState.user?.role?.includes("demo") - ? t("roles.demoUser") - : authState.user?.role} - - - - - - openPopup(event, "logout")} - > - - - - - - )} - - {collapsed && ( - - - - {authState.user?.firstName} {authState.user?.lastName} - - - {authState.user?.role} - - - - )} - {/* TODO Do we need two dividers? */} - {collapsed && } - {/* */} - {renderAccountMenuItems()} - - - {t("menu.logOut", "Log out")} - - - ); -} +}; export default Sidebar; diff --git a/client/src/Components/Sidebar/index_old.jsx b/client/src/Components/Sidebar/index_old.jsx new file mode 100644 index 000000000..219ceff1e --- /dev/null +++ b/client/src/Components/Sidebar/index_old.jsx @@ -0,0 +1,873 @@ +import React, { useEffect, useState, useRef } from "react"; +import { + Box, + Collapse, + Divider, + IconButton, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Stack, + Tooltip, + Typography, +} from "@mui/material"; +import ThemeSwitch from "../ThemeSwitch"; +import Avatar from "../Avatar"; +import StarPrompt from "../StarPrompt"; +import LockSvg from "../../assets/icons/lock.svg?react"; +import UserSvg from "../../assets/icons/user.svg?react"; +import TeamSvg from "../../assets/icons/user-two.svg?react"; +import LogoutSvg from "../../assets/icons/logout.svg?react"; +import Support from "../../assets/icons/support.svg?react"; +import Maintenance from "../../assets/icons/maintenance.svg?react"; +import Monitors from "../../assets/icons/monitors.svg?react"; +import Incidents from "../../assets/icons/incidents.svg?react"; +import Integrations from "../../assets/icons/integrations.svg?react"; +import PageSpeed from "../../assets/icons/page-speed.svg?react"; +import Settings from "../../assets/icons/settings.svg?react"; +import ArrowDown from "../../assets/icons/down-arrow.svg?react"; +import ArrowUp from "../../assets/icons/up-arrow.svg?react"; +import ArrowRight from "../../assets/icons/right-arrow.svg?react"; +import ArrowLeft from "../../assets/icons/left-arrow.svg?react"; +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 StatusPages from "../../assets/icons/status-pages.svg?react"; +import Discussions from "../../assets/icons/discussions.svg?react"; +import Notifications from "../../assets/icons/notifications.svg?react"; +import Logs from "../../assets/icons/logs.svg?react"; + +import "./index.css"; + +// Utils +import { useLocation, useNavigate } from "react-router"; +import { useTheme } from "@emotion/react"; +import { useDispatch, useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { clearAuthState } from "../../Features/Auth/authSlice"; +import { toggleSidebar } from "../../Features/UI/uiSlice"; +import { TurnedIn } from "@mui/icons-material"; +import { rules } from "eslint-plugin-react-refresh"; + +const getMenu = (t) => [ + { name: t("menu.uptime"), path: "uptime", icon: }, + { name: t("menu.pagespeed"), path: "pagespeed", icon: }, + + { name: t("menu.infrastructure"), path: "infrastructure", icon: }, + { + name: t("menu.notifications"), + path: "notifications", + icon: , + }, + { name: t("menu.incidents"), path: "incidents", icon: }, + + { name: t("menu.statusPages"), path: "status", icon: }, + { name: t("menu.maintenance"), path: "maintenance", icon: }, + { name: t("menu.logs"), path: "logs", icon: }, + + { + name: t("menu.settings"), + icon: , + path: "settings", + }, +]; + +const getOtherMenuItems = (t) => [ + { name: t("menu.support"), path: "support", icon: }, + { + name: t("menu.discussions"), + path: "discussions", + icon: , + }, + { name: t("menu.docs"), path: "docs", icon: }, + { name: t("menu.changelog"), path: "changelog", icon: }, +]; + +const getAccountMenuItems = (t) => [ + { name: t("menu.profile"), path: "account/profile", icon: }, + { name: t("menu.password"), path: "account/password", icon: }, + { name: t("menu.team"), path: "account/team", icon: }, +]; + +/* TODO this could be a key in nested Path would be the link */ +const URL_MAP = { + support: "https://discord.com/invite/NAb6H3UTjK", + discussions: "https://github.com/bluewave-labs/checkmate/discussions", + docs: "https://bluewavelabs.gitbook.io/checkmate", + changelog: "https://github.com/bluewave-labs/checkmate/releases", +}; + +const PATH_MAP = { + monitors: "Dashboard", + pagespeed: "Dashboard", + infrastructure: "Dashboard", + account: "Account", + settings: "Settings", +}; + +/** + * @component + * Sidebar component serves as a sidebar containing a menu. + * + * @returns {JSX.Element} The JSX element representing the Sidebar component. + */ + +function Sidebar() { + const theme = useTheme(); + const navigate = useNavigate(); + const location = useLocation(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + const authState = useSelector((state) => state.auth); + + const otherMenuItems = getOtherMenuItems(t); + const accountMenuItems = getAccountMenuItems(t); + const collapsed = useSelector((state) => state.ui.sidebar.collapsed); + const [open, setOpen] = useState({ Dashboard: false, Account: false, Other: false }); + const [anchorEl, setAnchorEl] = useState(null); + const [popup, setPopup] = useState(); + const { user } = useSelector((state) => state.auth); + + const sidebarRef = useRef(null); + const [sidebarReady, setSidebarReady] = useState(false); + const TRANSITION_DURATION = 200; + let menu = getMenu(t); + menu = menu.filter((item) => { + if (item.path === "logs") { + return user.role?.includes("admin") || user.role?.includes("superadmin"); + } + return true; + }); + + useEffect(() => { + if (!collapsed) { + setSidebarReady(false); + const timeout = setTimeout(() => { + setSidebarReady(true); + }, TRANSITION_DURATION); + return () => clearTimeout(timeout); + } else { + setSidebarReady(false); + } + }, [collapsed]); + + const renderAccountMenuItems = () => { + let filteredAccountMenuItems = [...accountMenuItems]; + + // If the user is in demo mode, remove the "Password" option + if (user.role?.includes("demo")) { + filteredAccountMenuItems = filteredAccountMenuItems.filter( + (item) => item.name !== "Password" + ); + } + + // If the user is NOT a superadmin, remove the "Team" option + if (user.role && !user.role.includes("superadmin")) { + filteredAccountMenuItems = filteredAccountMenuItems.filter( + (item) => item.name !== "Team" + ); + } + + return filteredAccountMenuItems.map((item) => ( + { + closePopup(); + navigate(item.path); + }} + sx={{ + gap: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + pl: theme.spacing(4), + }} + > + {item.icon} + {item.name} + + )); + }; + + const openPopup = (event, id) => { + setAnchorEl(event.currentTarget); + setPopup(id); + }; + const closePopup = () => { + setAnchorEl(null); + }; + + /** + * Handles logging out the user + * + */ + const logout = async () => { + // Clear auth state + dispatch(clearAuthState()); + navigate("/login"); + }; + + useEffect(() => { + const matchedKey = Object.keys(PATH_MAP).find((key) => + location.pathname.includes(key) + ); + + if (matchedKey) { + setOpen((prev) => ({ ...prev, [PATH_MAP[matchedKey]]: true })); + } + }, [location]); + + const iconColor = theme.palette.primary.contrastTextTertiary; + const sidebarClassName = `${collapsed ? "collapsed" : "expanded"} ${sidebarReady ? "sidebar-ready" : ""}`; + + /* TODO refactor this, there are a some ternaries and comments in the return */ + return ( + + { + setOpen((prev) => + Object.fromEntries(Object.keys(prev).map((key) => [key, false])) + ); + dispatch(toggleSidebar()); + }} + > + {collapsed ? : } + + {/* TODO Alignment done using padding. Use single source of truth to that*/} + + {/* TODO Abstract logo into component */} + {/* TODO Turn logo into a link */} + navigate("/")} + sx={{ cursor: "pointer" }} + > + + C + + + {t("common.appName")} + + + + + + {menu.map((item) => { + return item.path ? ( + /* If item has a path */ + + navigate(`/${item.path}`)} + sx={{ + height: "37px", + gap: theme.spacing(4), + borderRadius: theme.shape.borderRadius, + px: theme.spacing(4), + pl: theme.spacing(5), + }} + > + {item.icon} + {item.name} + + + ) : collapsed ? ( + /* TODO Do we ever get here? If item does not have a path and collapsed state is true */ + + + openPopup(event, item.name)} + sx={{ + position: "relative", + gap: theme.spacing(4), + borderRadius: theme.shape.borderRadius, + px: theme.spacing(4), + }} + > + {item.icon} + {item.name} + + + + {item.nested.map((child) => { + if ( + child.name === "Team" && + authState.user?.role && + !authState.user.role.includes("superadmin") + ) { + return null; + } + + return ( + { + const url = URL_MAP[child.path]; + if (url) { + window.open(url, "_blank", "noreferrer"); + } else { + navigate(`/${child.path}`); + } + closePopup(); + }} + sx={{ + gap: theme.spacing(4), + opacity: 0.9, + /* TODO this has no effect? */ + "& svg": { + "& path": { + stroke: theme.palette.primary.contrastTextTertiary, + strokeWidth: 1.1, + }, + }, + }} + > + {child.icon} + {child.name} + + ); + })} + + + ) : ( + /* TODO Do we ever get here? If item does not have a path and collapsed state is false */ + + + setOpen((prev) => ({ + ...Object.fromEntries(Object.keys(prev).map((key) => [key, false])), + [item.name]: !prev[item.name], + })) + } + sx={{ + gap: theme.spacing(4), + borderRadius: theme.shape.borderRadius, + px: theme.spacing(4), + }} + > + {item.icon} + {item.name} + {open[`${item.name}`] ? : } + + + + {item.nested.map((child) => { + if ( + child.name === "Team" && + authState.user?.role && + !authState.user.role.includes("superadmin") + ) { + return null; + } + + return ( + { + const url = URL_MAP[child.path]; + if (url) { + window.open(url, "_blank", "noreferrer"); + } else { + navigate(`/${child.path}`); + } + }} + sx={{ + gap: theme.spacing(4), + borderRadius: theme.shape.borderRadius, + pl: theme.spacing(4), + "&::before": { + content: `""`, + position: "absolute", + top: 0, + left: "-7px", + height: "100%", + borderLeft: 1, + borderLeftColor: theme.palette.primary.lowContrast, + }, + "&:last-child::before": { + height: "50%", + }, + "&::after": { + content: `""`, + position: "absolute", + top: "45%", + left: "-8px", + height: "3px", + width: "3px", + borderRadius: "50%", + backgroundColor: theme.palette.primary.lowContrast, + }, + "&.selected-path::after": { + /* TODO what is this selector doing? */ + backgroundColor: theme.palette.primary.contrastTextTertiary, + transform: "scale(1.2)", + }, + }} + > + {child.icon} + {child.name} + + ); + })} + + + + ); + })} + + + + {!collapsed && } + + + {otherMenuItems.map((item) => { + return item.path ? ( + + { + const url = URL_MAP[item.path]; + if (url) { + window.open(url, "_blank", "noreferrer"); + } else { + navigate(`/${item.path}`); + } + }} + sx={{ + height: "37px", + gap: theme.spacing(4), + borderRadius: theme.shape.borderRadius, + px: theme.spacing(4), + pl: theme.spacing(5), + }} + > + {item.icon} + {item.name} {" "} + + + ) : null; + })} + + + + {collapsed ? ( + <> + + openPopup(event, "logout")} + sx={{ p: 0, "&:focus": { outline: "none" } }} + > + + + + + ) : ( + <> + + + + {authState.user?.firstName} {authState.user?.lastName} + + + {authState.user?.role?.includes("superadmin") + ? t("roles.superAdmin") + : authState.user?.role?.includes("admin") + ? t("roles.admin") + : authState.user?.role?.includes("user") + ? t("roles.teamMember") + : authState.user?.role?.includes("demo") + ? t("roles.demoUser") + : authState.user?.role} + + + + + + openPopup(event, "logout")} + > + + + + + + )} + + {collapsed && ( + + + + {authState.user?.firstName} {authState.user?.lastName} + + + {authState.user?.role} + + + + )} + {/* TODO Do we need two dividers? */} + {collapsed && } + {/* */} + {renderAccountMenuItems()} + + + {t("menu.logOut", "Log out")} + + + + + ); +} + +export default Sidebar; diff --git a/client/src/Components/Sidebar/logo.jsx b/client/src/Components/Sidebar/logo.jsx new file mode 100644 index 000000000..0a8a76f0c --- /dev/null +++ b/client/src/Components/Sidebar/logo.jsx @@ -0,0 +1,57 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; + +const Logo = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const navigate = useNavigate(); + + return ( + + {/* TODO Abstract logo into component */} + {/* TODO Turn logo into a link */} + navigate("/")} + sx={{ cursor: "pointer" }} + > + + C + + + {t("common.appName")} + + + + ); +}; + +export default Logo; From a86e9ff480d085b2b2f384a721e89a49fb4582ea Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 31 Jul 2025 13:24:32 -0700 Subject: [PATCH 148/226] fix references --- server/src/service/business/monitorService.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/server/src/service/business/monitorService.js b/server/src/service/business/monitorService.js index c52b5cadf..32f7d1ebe 100644 --- a/server/src/service/business/monitorService.js +++ b/server/src/service/business/monitorService.js @@ -71,7 +71,7 @@ class MonitorService { }; createMonitor = async ({ teamId, userId, body }) => { - const monitor = await this.db.createMonitor({ + const monitor = await this.db.monitorModule.createMonitor({ body, teamId, userId, @@ -122,7 +122,7 @@ class MonitorService { await createMonitorsBodyValidation.validateAsync(enrichedData); - const monitors = await this.db.createBulkMonitors(enrichedData); + const monitors = await this.db.monitorModule.createBulkMonitors(enrichedData); await Promise.all( monitors.map(async (monitor) => { @@ -141,14 +141,14 @@ class MonitorService { deleteMonitor = async ({ teamId, monitorId }) => { await this.verifyTeamAccess({ teamId, monitorId }); - const monitor = await this.db.deleteMonitor({ teamId, monitorId }); + const monitor = await this.db.monitorModule.deleteMonitor({ teamId, monitorId }); await this.jobQueue.deleteJob(monitor); await this.db.statusPageModule.deleteStatusPagesByMonitorId(monitor._id); return monitor; }; deleteAllMonitors = async ({ teamId }) => { - const { monitors, deletedCount } = await this.db.deleteAllMonitors(teamId); + const { monitors, deletedCount } = await this.db.monitorModule.deleteAllMonitors(teamId); await Promise.all( monitors.map(async (monitor) => { try { @@ -171,19 +171,19 @@ class MonitorService { editMonitor = async ({ teamId, monitorId, body }) => { await this.verifyTeamAccess({ teamId, monitorId }); - const editedMonitor = await this.db.editMonitor({ monitorId, body }); + const editedMonitor = await this.db.monitorModule.editMonitor({ monitorId, body }); await this.jobQueue.updateJob(editedMonitor); }; pauseMonitor = async ({ teamId, monitorId }) => { await this.verifyTeamAccess({ teamId, monitorId }); - const monitor = await this.db.pauseMonitor({ monitorId }); + const monitor = await this.db.monitorModule.pauseMonitor({ monitorId }); monitor.isActive === true ? await this.jobQueue.resumeJob(monitor._id, monitor) : await this.jobQueue.pauseJob(monitor); return monitor; }; addDemoMonitors = async ({ userId, teamId }) => { - const demoMonitors = await this.db.addDemoMonitors(userId, teamId); + const demoMonitors = await this.db.monitorModuleaddDemoMonitors(userId, teamId); await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor._id, monitor))); return demoMonitors; }; From 2e675f5deed8f5a3846c5fe30e36ecc1005dd631 Mon Sep 17 00:00:00 2001 From: karenvicent Date: Thu, 31 Jul 2025 16:32:30 -0400 Subject: [PATCH 149/226] feat: Enhance Login page design and fix UI details --- .../Fallback/FallBackActionButtons.jsx | 1 + .../src/Components/GenericFallback/index.jsx | 21 ++-- client/src/Components/Table/index.jsx | 3 + client/src/Pages/Auth/Login/index.jsx | 108 +++++++++++++++--- .../src/Pages/Auth/components/AuthHeader.jsx | 10 +- client/src/Utils/Theme/constants.js | 4 + client/src/Utils/Theme/globalTheme.js | 9 +- client/src/locales/en.json | 5 +- 8 files changed, 130 insertions(+), 31 deletions(-) diff --git a/client/src/Components/Fallback/FallBackActionButtons.jsx b/client/src/Components/Fallback/FallBackActionButtons.jsx index fbe8fe807..144b16501 100644 --- a/client/src/Components/Fallback/FallBackActionButtons.jsx +++ b/client/src/Components/Fallback/FallBackActionButtons.jsx @@ -17,6 +17,7 @@ const FallbackActionButtons = ({ link, type }) => { diff --git a/client/src/Pages/Auth/components/AuthHeader.jsx b/client/src/Pages/Auth/components/AuthHeader.jsx index c6ad46a47..ce454c065 100644 --- a/client/src/Pages/Auth/components/AuthHeader.jsx +++ b/client/src/Pages/Auth/components/AuthHeader.jsx @@ -9,7 +9,7 @@ import ThemeSwitch from "../../../Components/ThemeSwitch"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -const AuthHeader = () => { +const AuthHeader = ({ hideLogo = false }) => { // Hooks const theme = useTheme(); const { t } = useTranslation(); @@ -27,8 +27,12 @@ const AuthHeader = () => { alignItems="center" gap={theme.spacing(4)} > - - {t("common.appName")} + {!hideLogo && ( + <> + + {t("common.appName")} + + )} ({ style: { backgroundColor: theme.palette.accent.main, color: theme.palette.primary.contrastTextSecondaryDarkBg, - fontWeight: 700, letterSpacing: "0.5px", textShadow: "0 0 1px rgba(0, 0, 0, 0.15)", "&:hover": { @@ -723,6 +722,14 @@ const baseTheme = (palette) => ({ }, ], }, + + MuiTooltip: { + styleOverrides: { + tooltip: () => ({ + fontSize: typographyLevels.m, + }), + }, + }, }, shape: { borderRadius: 2, diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 584894543..669e322a5 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -154,7 +154,7 @@ "incorrect": "The password you provided does not match our records" } }, - "heading": "Log In", + "heading": "Log in to continue", "links": { "forgotPassword": "Forgot password?", "forgotPasswordLink": "Reset password", @@ -168,7 +168,8 @@ "toasts": { "incorrectPassword": "Incorrect password", "success": "Welcome back! You're successfully logged in." - } + }, + "welcome": "Welcome back to Checkmate!" }, "registration": { "description": { From b61a7ffc3c59cfef3ba36ae3e7a95e5c3ed670a4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 31 Jul 2025 15:37:40 -0700 Subject: [PATCH 150/226] components --- .../src/Components/Sidebar/collapseButton.jsx | 48 ++++ .../Sidebar/components/authFooter.jsx | 260 ++++++++++++++++++ .../Components/Sidebar/components/navItem.jsx | 89 ++++++ client/src/Components/Sidebar/index.jsx | 176 ++++++------ client/src/Components/Sidebar/logo.jsx | 28 +- client/src/Utils/Theme/globalTheme.js | 11 +- 6 files changed, 510 insertions(+), 102 deletions(-) create mode 100644 client/src/Components/Sidebar/collapseButton.jsx create mode 100644 client/src/Components/Sidebar/components/authFooter.jsx create mode 100644 client/src/Components/Sidebar/components/navItem.jsx diff --git a/client/src/Components/Sidebar/collapseButton.jsx b/client/src/Components/Sidebar/collapseButton.jsx new file mode 100644 index 000000000..551bcf25d --- /dev/null +++ b/client/src/Components/Sidebar/collapseButton.jsx @@ -0,0 +1,48 @@ +import IconButton from "@mui/material/IconButton"; +import ArrowRight from "../../assets/icons/right-arrow.svg?react"; +import ArrowLeft from "../../assets/icons/left-arrow.svg?react"; +import { useTheme } from "@mui/material/styles"; +import { useDispatch } from "react-redux"; +import { toggleSidebar } from "../../Features/UI/uiSlice"; + +const CollapseButton = ({ collapsed, setOpen }) => { + const theme = useTheme(); + const dispatch = useDispatch(); + return ( + { + setOpen((prev) => + Object.fromEntries(Object.keys(prev).map((key) => [key, false])) + ); + dispatch(toggleSidebar()); + }} + > + {collapsed ? : } + + ); +}; + +export default CollapseButton; diff --git a/client/src/Components/Sidebar/components/authFooter.jsx b/client/src/Components/Sidebar/components/authFooter.jsx new file mode 100644 index 000000000..7ff2f36ef --- /dev/null +++ b/client/src/Components/Sidebar/components/authFooter.jsx @@ -0,0 +1,260 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Tooltip from "@mui/material/Tooltip"; +import IconButton from "@mui/material/IconButton"; +import Avatar from "../../Avatar"; +import ThemeSwitch from "../../ThemeSwitch"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import Divider from "@mui/material/Divider"; +import DotsVertical from "../../../assets/icons/dots-vertical.svg?react"; +import LogoutSvg from "../../../assets/icons/logout.svg?react"; + +import { useTheme } from "@emotion/react"; +import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { clearAuthState } from "../../../Features/Auth/authSlice"; +import { useDispatch } from "react-redux"; + +const AuthFooter = ({ collapsed, accountMenuItems }) => { + const { t } = useTranslation(); + const theme = useTheme(); + const authState = useSelector((state) => state.auth); + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const [anchorEl, setAnchorEl] = useState(null); + const [popup, setPopup] = useState(); + + const openPopup = (event, id) => { + setAnchorEl(event.currentTarget); + setPopup(id); + }; + + const closePopup = () => { + setAnchorEl(null); + }; + + const logout = async () => { + // Clear auth state + dispatch(clearAuthState()); + navigate("/login"); + }; + const renderAccountMenuItems = (user, items) => { + let filteredAccountMenuItems = [...items]; + + // If the user is in demo mode, remove the "Password" option + if (user.role?.includes("demo")) { + filteredAccountMenuItems = filteredAccountMenuItems.filter( + (item) => item.name !== "Password" + ); + } + + // If the user is NOT a superadmin, remove the "Team" option + if (user.role && !user.role.includes("superadmin")) { + filteredAccountMenuItems = filteredAccountMenuItems.filter( + (item) => item.name !== "Team" + ); + } + + return filteredAccountMenuItems.map((item) => ( + { + closePopup(); + navigate(item.path); + }} + sx={{ + gap: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + pl: theme.spacing(4), + }} + > + {item.icon} + {item.name} + + )); + }; + return ( + + <> + + + + + {authState.user?.firstName} {authState.user?.lastName} + + + {authState.user?.role?.includes("superadmin") + ? t("roles.superAdmin") + : authState.user?.role?.includes("admin") + ? t("roles.admin") + : authState.user?.role?.includes("user") + ? t("roles.teamMember") + : authState.user?.role?.includes("demo") + ? t("roles.demoUser") + : authState.user?.role} + + + + + + openPopup(event, "logout")} + > + + + + + + + + {collapsed && ( + + + + {authState.user?.firstName} {authState.user?.lastName} + + + {authState.user?.role} + + + + )} + {/* TODO Do we need two dividers? */} + {collapsed && } + {/* */} + {renderAccountMenuItems(authState.user, accountMenuItems)} + + + {t("menu.logOut", "Log out")} + + + + ); +}; + +export default AuthFooter; diff --git a/client/src/Components/Sidebar/components/navItem.jsx b/client/src/Components/Sidebar/components/navItem.jsx new file mode 100644 index 000000000..f43b1bf1e --- /dev/null +++ b/client/src/Components/Sidebar/components/navItem.jsx @@ -0,0 +1,89 @@ +import Tooltip from "@mui/material/Tooltip"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; + +import { useTheme } from "@emotion/react"; +import { useNavigate } from "react-router"; + +const NavItem = ({ item, collapsed, selected, onClick }) => { + const theme = useTheme(); + const navigate = useNavigate(); + + return ( + + + + {item.icon} + + + + {item.name} + + + + + ); +}; + +export default NavItem; diff --git a/client/src/Components/Sidebar/index.jsx b/client/src/Components/Sidebar/index.jsx index cad7d3b7d..ac4a68c0f 100644 --- a/client/src/Components/Sidebar/index.jsx +++ b/client/src/Components/Sidebar/index.jsx @@ -1,21 +1,16 @@ -import IconButton from "@mui/material/IconButton"; import Stack from "@mui/material/Stack"; -import ArrowRight from "../../assets/icons/right-arrow.svg?react"; -import ArrowLeft from "../../assets/icons/left-arrow.svg?react"; -import List from "@mui/material/List"; -import ListItemButton from "@mui/material/ListItemButton"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import ListItemText from "@mui/material/ListItemText"; -import Tooltip from "@mui/material/Tooltip"; -import Logo from "./logo"; -import ThemeSwitch from "../ThemeSwitch"; -import Avatar from "../Avatar"; +import List from "@mui/material/List"; +import Logo from "./logo"; +import CollapseButton from "./collapseButton"; +import Divider from "@mui/material/Divider"; +import NavItem from "./components/navItem"; +import AuthFooter from "./components/authFooter"; + import StarPrompt from "../StarPrompt"; import LockSvg from "../../assets/icons/lock.svg?react"; import UserSvg from "../../assets/icons/user.svg?react"; import TeamSvg from "../../assets/icons/user-two.svg?react"; -import LogoutSvg from "../../assets/icons/logout.svg?react"; import Support from "../../assets/icons/support.svg?react"; import Maintenance from "../../assets/icons/maintenance.svg?react"; import Monitors from "../../assets/icons/monitors.svg?react"; @@ -25,7 +20,6 @@ import PageSpeed from "../../assets/icons/page-speed.svg?react"; import Settings from "../../assets/icons/settings.svg?react"; import ArrowDown from "../../assets/icons/down-arrow.svg?react"; import ArrowUp from "../../assets/icons/up-arrow.svg?react"; -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 StatusPages from "../../assets/icons/status-pages.svg?react"; @@ -35,12 +29,18 @@ import Logs from "../../assets/icons/logs.svg?react"; // Utils import { useTheme } from "@mui/material/styles"; -import { useDispatch, useSelector } from "react-redux"; -import { toggleSidebar } from "../../Features/UI/uiSlice"; +import { useSelector } from "react-redux"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; +const URL_MAP = { + support: "https://discord.com/invite/NAb6H3UTjK", + discussions: "https://github.com/bluewave-labs/checkmate/discussions", + docs: "https://bluewavelabs.gitbook.io/checkmate", + changelog: "https://github.com/bluewave-labs/checkmate/releases", +}; + const getMenu = (t) => [ { name: t("menu.uptime"), path: "uptime", icon: }, { name: t("menu.pagespeed"), path: "pagespeed", icon: }, @@ -64,9 +64,25 @@ const getMenu = (t) => [ }, ]; +const getOtherMenuItems = (t) => [ + { name: t("menu.support"), path: "support", icon: }, + { + name: t("menu.discussions"), + path: "discussions", + icon: , + }, + { name: t("menu.docs"), path: "docs", icon: }, + { name: t("menu.changelog"), path: "changelog", icon: }, +]; + +const getAccountMenuItems = (t) => [ + { name: t("menu.profile"), path: "account/profile", icon: }, + { name: t("menu.password"), path: "account/password", icon: }, + { name: t("menu.team"), path: "account/team", icon: }, +]; + const Sidebar = () => { const theme = useTheme(); - const dispatch = useDispatch(); const { t } = useTranslation(); const navigate = useNavigate(); // Redux state @@ -76,100 +92,82 @@ const Sidebar = () => { const [open, setOpen] = useState({ Dashboard: false, Account: false, Other: false }); const menu = getMenu(t); - console.log(collapsed); + const otherMenuItems = getOtherMenuItems(t); + const accountMenuItems = getAccountMenuItems(t); + return ( - { - setOpen((prev) => - Object.fromEntries(Object.keys(prev).map((key) => [key, false])) - ); - dispatch(toggleSidebar()); - }} - > - {collapsed ? : } - - + + {menu.map((item) => { - return item.path ? ( - /* If item has a path */ - - navigate(`/${item.path}`)} - sx={{ - height: "37px", - gap: theme.spacing(4), - borderRadius: theme.shape.borderRadius, - px: theme.spacing(4), - pl: theme.spacing(5), - }} - > - {item.icon} - {!collapsed && {item.name}} - - - ) : null; + item={item} + collapsed={collapsed} + selected={selected} + onClick={() => navigate(`/${item.path}`)} + /> + ); })} + {!collapsed && } + + {otherMenuItems.map((item) => { + const selected = location.pathname.startsWith(`/${item.path}`); + + return ( + { + const url = URL_MAP[item.path]; + if (url) { + window.open(url, "_blank", "noreferrer"); + } else { + navigate(`/${item.path}`); + } + }} + /> + ); + })} + + + ); }; diff --git a/client/src/Components/Sidebar/logo.jsx b/client/src/Components/Sidebar/logo.jsx index 0a8a76f0c..0d7adf54f 100644 --- a/client/src/Components/Sidebar/logo.jsx +++ b/client/src/Components/Sidebar/logo.jsx @@ -1,10 +1,11 @@ import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; -const Logo = () => { +const Logo = ({ collapsed }) => { const { t } = useTranslation(); const theme = useTheme(); const navigate = useNavigate(); @@ -35,20 +36,31 @@ const Logo = () => { sx={{ position: "relative", backgroundColor: theme.palette.accent.main, - color: theme.palette.accent.contrastText, borderRadius: theme.shape.borderRadius, userSelect: "none", }} > C - - {t("common.appName")} - + {" "} + + {t("common.appName")} + + ); diff --git a/client/src/Utils/Theme/globalTheme.js b/client/src/Utils/Theme/globalTheme.js index ec292142d..64ac46fd1 100644 --- a/client/src/Utils/Theme/globalTheme.js +++ b/client/src/Utils/Theme/globalTheme.js @@ -233,16 +233,16 @@ const baseTheme = (palette) => ({ }, MuiList: { styleOverrides: { - root: { + root: ({ theme }) => ({ padding: 0, - }, + }), }, }, MuiListItemButton: { styleOverrides: { - root: { - transition: "none", - }, + root: ({ theme }) => ({ + transition: "background-color .3s", + }), }, }, MuiListItemText: { @@ -279,6 +279,7 @@ const baseTheme = (palette) => ({ }), }, }, + MuiTableHead: { styleOverrides: { root: ({ theme }) => ({ From 48990b860f678f0334dce302921c2ba4552dd190 Mon Sep 17 00:00:00 2001 From: Mohansai Mallineni Date: Fri, 1 Aug 2025 21:09:51 +0530 Subject: [PATCH 151/226] Handled styling of new icons --- .../src/Pages/Incidents/Components/StatusBoxes/StatusBox.css | 5 +++++ .../src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.css diff --git a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.css b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.css new file mode 100644 index 000000000..bff2c0477 --- /dev/null +++ b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.css @@ -0,0 +1,5 @@ +svg { + width: 20px; + height: 20px; + opacity: 0.9; +} \ No newline at end of file diff --git a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx index adf4faa8b..54c89ee3d 100644 --- a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx +++ b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx @@ -6,7 +6,7 @@ import AlertIcon from "../../../../assets/icons/alert-icon.svg?react"; import CheckIcon from "../../../../assets/icons/check-icon.svg?react"; import CloseIcon from "../../../../assets/icons/close-icon.svg?react"; import WarningIcon from "../../../../assets/icons/warning-icon.svg?react"; - +import "./StatusBox.css"; const StatusBox = ({ title, value, status }) => { const theme = useTheme(); let sharedStyles = { From 8274d4ffbe4a8ec9f94f8cf71a54cb74bca5089b Mon Sep 17 00:00:00 2001 From: Mohansai Mallineni Date: Fri, 1 Aug 2025 21:11:46 +0530 Subject: [PATCH 152/226] Handled styling of new icons --- .../src/Pages/Incidents/Components/StatusBoxes/StatusBox.css | 5 +++++ .../src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.css diff --git a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.css b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.css new file mode 100644 index 000000000..6a260491f --- /dev/null +++ b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.css @@ -0,0 +1,5 @@ +svg { + width: 20px; + height: 20px; + opacity: 0.9; +} diff --git a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx index adf4faa8b..54c89ee3d 100644 --- a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx +++ b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx @@ -6,7 +6,7 @@ import AlertIcon from "../../../../assets/icons/alert-icon.svg?react"; import CheckIcon from "../../../../assets/icons/check-icon.svg?react"; import CloseIcon from "../../../../assets/icons/close-icon.svg?react"; import WarningIcon from "../../../../assets/icons/warning-icon.svg?react"; - +import "./StatusBox.css"; const StatusBox = ({ title, value, status }) => { const theme = useTheme(); let sharedStyles = { From ebfb25ebc067fd4e440a3881be288b7cdbde1e76 Mon Sep 17 00:00:00 2001 From: Mohansai Mallineni Date: Fri, 1 Aug 2025 22:09:15 +0530 Subject: [PATCH 153/226] Handled styling of new icons --- .../Pages/Incidents/Components/StatusBoxes/StatusBox.css | 5 ----- .../Pages/Incidents/Components/StatusBoxes/StatusBox.jsx | 8 ++++++-- 2 files changed, 6 insertions(+), 7 deletions(-) delete mode 100644 client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.css diff --git a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.css b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.css deleted file mode 100644 index 6a260491f..000000000 --- a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.css +++ /dev/null @@ -1,5 +0,0 @@ -svg { - width: 20px; - height: 20px; - opacity: 0.9; -} diff --git a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx index f925cf4c2..7b20e3d51 100644 --- a/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx +++ b/client/src/Pages/Incidents/Components/StatusBoxes/StatusBox.jsx @@ -6,14 +6,18 @@ import AlertIcon from "../../../../assets/icons/alert-icon.svg?react"; import CheckIcon from "../../../../assets/icons/check-icon.svg?react"; import CloseIcon from "../../../../assets/icons/close-icon.svg?react"; import WarningIcon from "../../../../assets/icons/warning-icon.svg?react"; -import "./StatusBox.css"; const StatusBox = ({ title, value, status }) => { const theme = useTheme(); let sharedStyles = { position: "absolute", right: 8, - "& svg path": { stroke: theme.palette.primary.contrastTextTertiary }, + "& svg": { + width: 20, + height: 20, + opacity: 0.9, + "& path": { stroke: theme.palette.primary.contrastTextTertiary, strokeWidth: 1.7 }, + }, }; let color; From 2d01205f70839874d07a6bb78cd0d84ecf6ad2d3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 1 Aug 2025 12:39:35 -0700 Subject: [PATCH 154/226] auth footer styling --- .../Sidebar/components/authFooter.jsx | 140 +++++++++--------- .../Sidebar/{index.bak => index.css} | 0 2 files changed, 70 insertions(+), 70 deletions(-) rename client/src/Components/Sidebar/{index.bak => index.css} (100%) diff --git a/client/src/Components/Sidebar/components/authFooter.jsx b/client/src/Components/Sidebar/components/authFooter.jsx index 7ff2f36ef..e792cec12 100644 --- a/client/src/Components/Sidebar/components/authFooter.jsx +++ b/client/src/Components/Sidebar/components/authFooter.jsx @@ -88,82 +88,82 @@ const AuthFooter = ({ collapsed, accountMenuItems }) => { gap={theme.spacing(2)} borderRadius={theme.shape.borderRadius} > - <> - - + + + - - - {authState.user?.firstName} {authState.user?.lastName} - - - {authState.user?.role?.includes("superadmin") - ? t("roles.superAdmin") - : authState.user?.role?.includes("admin") - ? t("roles.admin") - : authState.user?.role?.includes("user") - ? t("roles.teamMember") - : authState.user?.role?.includes("demo") - ? t("roles.demoUser") - : authState.user?.role} - - - - - - + + {authState.user?.role?.includes("superadmin") + ? t("roles.superAdmin") + : authState.user?.role?.includes("admin") + ? t("roles.admin") + : authState.user?.role?.includes("user") + ? t("roles.teamMember") + : authState.user?.role?.includes("demo") + ? t("roles.demoUser") + : authState.user?.role} + + + + + + openPopup(event, "logout")} - > - - - - - - + "& svg": { + width: "22px", + height: "22px", + }, + "& svg path": { + /* Vertical three dots */ + stroke: theme.palette.primary.contrastTextTertiary, + }, + }} + onClick={(event) => openPopup(event, "logout")} + > + + + + Date: Fri, 1 Aug 2025 14:10:20 -0700 Subject: [PATCH 155/226] simplify layouts --- .../src/Components/Sidebar/collapseButton.jsx | 5 +- .../Sidebar/components/authFooter.jsx | 138 ++++++++++-------- client/src/Components/Sidebar/index.jsx | 12 +- client/src/Components/Sidebar/logo.jsx | 79 +++++----- 4 files changed, 113 insertions(+), 121 deletions(-) diff --git a/client/src/Components/Sidebar/collapseButton.jsx b/client/src/Components/Sidebar/collapseButton.jsx index 551bcf25d..b8f088669 100644 --- a/client/src/Components/Sidebar/collapseButton.jsx +++ b/client/src/Components/Sidebar/collapseButton.jsx @@ -5,7 +5,7 @@ import { useTheme } from "@mui/material/styles"; import { useDispatch } from "react-redux"; import { toggleSidebar } from "../../Features/UI/uiSlice"; -const CollapseButton = ({ collapsed, setOpen }) => { +const CollapseButton = ({ collapsed }) => { const theme = useTheme(); const dispatch = useDispatch(); return ( @@ -34,9 +34,6 @@ const CollapseButton = ({ collapsed, setOpen }) => { }, }} onClick={() => { - setOpen((prev) => - Object.fromEntries(Object.keys(prev).map((key) => [key, false])) - ); dispatch(toggleSidebar()); }} > diff --git a/client/src/Components/Sidebar/components/authFooter.jsx b/client/src/Components/Sidebar/components/authFooter.jsx index e792cec12..dbc1ccfb0 100644 --- a/client/src/Components/Sidebar/components/authFooter.jsx +++ b/client/src/Components/Sidebar/components/authFooter.jsx @@ -87,82 +87,94 @@ const AuthFooter = ({ collapsed, accountMenuItems }) => { px={theme.spacing(8)} gap={theme.spacing(2)} borderRadius={theme.shape.borderRadius} + boxSizing={"border-box"} > - - {authState.user?.firstName} {authState.user?.lastName} - - - {authState.user?.role?.includes("superadmin") - ? t("roles.superAdmin") - : authState.user?.role?.includes("admin") - ? t("roles.admin") - : authState.user?.role?.includes("user") - ? t("roles.teamMember") - : authState.user?.role?.includes("demo") - ? t("roles.demoUser") - : authState.user?.role} - - - - - - openPopup(event, "logout")} > - - - + {authState.user?.firstName} {authState.user?.lastName} + + + {authState.user?.role?.includes("superadmin") + ? t("roles.superAdmin") + : authState.user?.role?.includes("admin") + ? t("roles.admin") + : authState.user?.role?.includes("user") + ? t("roles.teamMember") + : authState.user?.role?.includes("demo") + ? t("roles.demoUser") + : authState.user?.role} + + + + + + openPopup(event, "logout")} + > + + + + { // Redux state const collapsed = useSelector((state) => state.ui.sidebar.collapsed); - // Local state - const [open, setOpen] = useState({ Dashboard: false, Account: false, Other: false }); - const menu = getMenu(t); const otherMenuItems = getOtherMenuItems(t); const accountMenuItems = getAccountMenuItems(t); @@ -108,10 +102,7 @@ const Sidebar = () => { transition: "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)", }} > - + { sx={{ px: theme.spacing(6), height: "100%", - /* overflow: "hidden", */ }} > {menu.map((item) => { diff --git a/client/src/Components/Sidebar/logo.jsx b/client/src/Components/Sidebar/logo.jsx index 0d7adf54f..e6aa660fb 100644 --- a/client/src/Components/Sidebar/logo.jsx +++ b/client/src/Components/Sidebar/logo.jsx @@ -15,53 +15,46 @@ const Logo = ({ collapsed }) => { pt={theme.spacing(6)} pb={theme.spacing(12)} pl={theme.spacing(8)} + direction="row" + alignItems="center" + gap={theme.spacing(4)} + onClick={() => navigate("/")} + sx={{ cursor: "pointer" }} > - {/* TODO Abstract logo into component */} - {/* TODO Turn logo into a link */} - navigate("/")} - sx={{ cursor: "pointer" }} + - + + {" "} + - C - - - {" "} - - {t("common.appName")} - - - + {t("common.appName")} + + ); }; From b4f42bb7d495b53c862c85e86e29c6f4c4cbfca1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 1 Aug 2025 14:58:30 -0700 Subject: [PATCH 156/226] fix arrow icons --- client/src/Components/ArrowLeft/index.jsx | 36 +++++++++++ client/src/Components/ArrowRight/index.jsx | 28 +++++++++ client/src/Components/Breadcrumbs/index.jsx | 3 +- .../src/Components/Sidebar/collapseButton.jsx | 34 ++++++---- .../Sidebar/components/collapseButton.jsx | 59 ++++++++++++++++++ .../Components/Sidebar/components/logo.jsx | 62 +++++++++++++++++++ .../Table/TablePagination/Actions/index.jsx | 10 ++- client/src/assets/icons/left-arrow-double.svg | 5 +- client/src/assets/icons/left-arrow-long.svg | 5 +- client/src/assets/icons/left-arrow.svg | 5 +- .../src/assets/icons/right-arrow-double.svg | 5 +- client/src/assets/icons/right-arrow.svg | 5 +- 12 files changed, 227 insertions(+), 30 deletions(-) create mode 100644 client/src/Components/ArrowLeft/index.jsx create mode 100644 client/src/Components/ArrowRight/index.jsx create mode 100644 client/src/Components/Sidebar/components/collapseButton.jsx create mode 100644 client/src/Components/Sidebar/components/logo.jsx diff --git a/client/src/Components/ArrowLeft/index.jsx b/client/src/Components/ArrowLeft/index.jsx new file mode 100644 index 000000000..cc7e0b078 --- /dev/null +++ b/client/src/Components/ArrowLeft/index.jsx @@ -0,0 +1,36 @@ +import LeftArrow from "../../assets/icons/left-arrow.svg?react"; +import LeftArrowDouble from "../../assets/icons/left-arrow-double.svg?react"; +import LeftArrowLong from "../../assets/icons/left-arrow-long.svg?react"; +import PropTypes from "prop-types"; + +const ArrowLeft = ({ type, color = "#667085", ...props }) => { + console.log(color); + if (type === "double") { + return ( + + ); + } else if (type === "long") { + return ( + + ); + } else { + return ( + + ); + } +}; + +ArrowLeft.propTypes = { + color: PropTypes.string, + type: PropTypes.oneOf(["double", "long", "default"]), +}; +export default ArrowLeft; diff --git a/client/src/Components/ArrowRight/index.jsx b/client/src/Components/ArrowRight/index.jsx new file mode 100644 index 000000000..335bc19f2 --- /dev/null +++ b/client/src/Components/ArrowRight/index.jsx @@ -0,0 +1,28 @@ +import RightArrow from "../../assets/icons/right-arrow.svg?react"; +import RightArrowDouble from "../../assets/icons/right-arrow-double.svg?react"; +import PropTypes from "prop-types"; + +const ArrowRight = ({ type, color = "#667085", ...props }) => { + if (type === "double") { + return ( + + ); + } else { + return ( + + ); + } +}; + +ArrowRight.propTypes = { + type: PropTypes.oneOf(["double", "default"]), + color: PropTypes.string, +}; + +export default ArrowRight; diff --git a/client/src/Components/Breadcrumbs/index.jsx b/client/src/Components/Breadcrumbs/index.jsx index bf04afa2e..f1af89790 100644 --- a/client/src/Components/Breadcrumbs/index.jsx +++ b/client/src/Components/Breadcrumbs/index.jsx @@ -2,8 +2,7 @@ import PropTypes from "prop-types"; import { Box, Breadcrumbs as MUIBreadcrumbs } from "@mui/material"; import { useTheme } from "@emotion/react"; import { useNavigate } from "react-router-dom"; -import ArrowRight from "../../assets/icons/right-arrow.svg?react"; - +import ArrowRight from "../ArrowRight"; import "./index.css"; /** diff --git a/client/src/Components/Sidebar/collapseButton.jsx b/client/src/Components/Sidebar/collapseButton.jsx index b8f088669..1cd9fc618 100644 --- a/client/src/Components/Sidebar/collapseButton.jsx +++ b/client/src/Components/Sidebar/collapseButton.jsx @@ -1,13 +1,27 @@ import IconButton from "@mui/material/IconButton"; -import ArrowRight from "../../assets/icons/right-arrow.svg?react"; -import ArrowLeft from "../../assets/icons/left-arrow.svg?react"; +import ArrowRight from "../ArrowRight"; +import ArrowLeft from "../ArrowLeft"; import { useTheme } from "@mui/material/styles"; import { useDispatch } from "react-redux"; import { toggleSidebar } from "../../Features/UI/uiSlice"; +import PropTypes from "prop-types"; const CollapseButton = ({ collapsed }) => { const theme = useTheme(); const dispatch = useDispatch(); + const arrowIcon = collapsed ? ( + + ) : ( + + ); return ( { right: 0, transform: `translate(50%, 0)`, backgroundColor: theme.palette.tertiary.main, - border: 1, - borderColor: theme.palette.primary.lowContrast, + border: `1px solid ${theme.palette.primary.lowContrast}`, p: theme.spacing(2.5), - "& svg": { - width: theme.spacing(8), - height: theme.spacing(8), - "& path": { - stroke: theme.palette.primary.contrastTextSecondary, - }, - }, + "&:focus": { outline: "none" }, "&:hover": { backgroundColor: theme.palette.primary.lowContrast, @@ -37,9 +44,12 @@ const CollapseButton = ({ collapsed }) => { dispatch(toggleSidebar()); }} > - {collapsed ? : } + {arrowIcon} ); }; +CollapseButton.propTypes = { + collapsed: PropTypes.bool.isRequired, +}; export default CollapseButton; diff --git a/client/src/Components/Sidebar/components/collapseButton.jsx b/client/src/Components/Sidebar/components/collapseButton.jsx new file mode 100644 index 000000000..bcae710f0 --- /dev/null +++ b/client/src/Components/Sidebar/components/collapseButton.jsx @@ -0,0 +1,59 @@ +import IconButton from "@mui/material/IconButton"; +import ArrowRight from "../../ArrowRight"; +import ArrowLeft from "../../ArrowLeft"; +import { useTheme } from "@mui/material/styles"; +import { useDispatch } from "react-redux"; +import { toggleSidebar } from "../../../Features/UI/uiSlice"; + +import PropTypes from "prop-types"; + +const CollapseButton = ({ collapsed }) => { + const theme = useTheme(); + const dispatch = useDispatch(); + + const arrowIcon = collapsed ? ( + + ) : ( + + ); + + return ( + { + dispatch(toggleSidebar()); + }} + > + {arrowIcon} + + ); +}; + +CollapseButton.propTypes = { + collapsed: PropTypes.bool, +}; + +export default CollapseButton; diff --git a/client/src/Components/Sidebar/components/logo.jsx b/client/src/Components/Sidebar/components/logo.jsx new file mode 100644 index 000000000..e6aa660fb --- /dev/null +++ b/client/src/Components/Sidebar/components/logo.jsx @@ -0,0 +1,62 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; + +const Logo = ({ collapsed }) => { + const { t } = useTranslation(); + const theme = useTheme(); + const navigate = useNavigate(); + + return ( + navigate("/")} + sx={{ cursor: "pointer" }} + > + + C + + + {" "} + + {t("common.appName")} + + + + ); +}; + +export default Logo; diff --git a/client/src/Components/Table/TablePagination/Actions/index.jsx b/client/src/Components/Table/TablePagination/Actions/index.jsx index 1b654fe9b..dec72446b 100644 --- a/client/src/Components/Table/TablePagination/Actions/index.jsx +++ b/client/src/Components/Table/TablePagination/Actions/index.jsx @@ -1,9 +1,7 @@ import PropTypes from "prop-types"; import { Box, Button } from "@mui/material"; -import LeftArrowDouble from "../../../../assets/icons/left-arrow-double.svg?react"; -import RightArrowDouble from "../../../../assets/icons/right-arrow-double.svg?react"; -import LeftArrow from "../../../../assets/icons/left-arrow.svg?react"; -import RightArrow from "../../../../assets/icons/right-arrow.svg?react"; +import LeftArrow from "../../../ArrowLeft"; +import RightArrow from "../../../ArrowRight"; import { useTheme } from "@emotion/react"; TablePaginationActions.propTypes = { @@ -50,7 +48,7 @@ function TablePaginationActions({ count, page, rowsPerPage, onPageChange }) { disabled={page === 0} aria-label="first page" > - + ); diff --git a/client/src/assets/icons/left-arrow-double.svg b/client/src/assets/icons/left-arrow-double.svg index 6001a3ead..4cb2a5186 100644 --- a/client/src/assets/icons/left-arrow-double.svg +++ b/client/src/assets/icons/left-arrow-double.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/client/src/assets/icons/left-arrow-long.svg b/client/src/assets/icons/left-arrow-long.svg index db9b827ed..56a774730 100644 --- a/client/src/assets/icons/left-arrow-long.svg +++ b/client/src/assets/icons/left-arrow-long.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/client/src/assets/icons/left-arrow.svg b/client/src/assets/icons/left-arrow.svg index 8b38c408a..649d1681c 100644 --- a/client/src/assets/icons/left-arrow.svg +++ b/client/src/assets/icons/left-arrow.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/client/src/assets/icons/right-arrow-double.svg b/client/src/assets/icons/right-arrow-double.svg index 4235134d7..6ad4882ad 100644 --- a/client/src/assets/icons/right-arrow-double.svg +++ b/client/src/assets/icons/right-arrow-double.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/client/src/assets/icons/right-arrow.svg b/client/src/assets/icons/right-arrow.svg index 6b02062f2..77d022798 100644 --- a/client/src/assets/icons/right-arrow.svg +++ b/client/src/assets/icons/right-arrow.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file From 800f988979fcc10abcf3fa89fad394a104c9ed17 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 1 Aug 2025 15:01:30 -0700 Subject: [PATCH 157/226] move components to component folder --- .../src/Components/Sidebar/collapseButton.jsx | 55 ---------------- .../Sidebar/components/collapseButton.jsx | 10 +-- client/src/Components/Sidebar/index.jsx | 4 +- client/src/Components/Sidebar/logo.jsx | 62 ------------------- 4 files changed, 5 insertions(+), 126 deletions(-) delete mode 100644 client/src/Components/Sidebar/collapseButton.jsx delete mode 100644 client/src/Components/Sidebar/logo.jsx diff --git a/client/src/Components/Sidebar/collapseButton.jsx b/client/src/Components/Sidebar/collapseButton.jsx deleted file mode 100644 index 1cd9fc618..000000000 --- a/client/src/Components/Sidebar/collapseButton.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import IconButton from "@mui/material/IconButton"; -import ArrowRight from "../ArrowRight"; -import ArrowLeft from "../ArrowLeft"; -import { useTheme } from "@mui/material/styles"; -import { useDispatch } from "react-redux"; -import { toggleSidebar } from "../../Features/UI/uiSlice"; -import PropTypes from "prop-types"; - -const CollapseButton = ({ collapsed }) => { - const theme = useTheme(); - const dispatch = useDispatch(); - const arrowIcon = collapsed ? ( - - ) : ( - - ); - return ( - { - dispatch(toggleSidebar()); - }} - > - {arrowIcon} - - ); -}; - -CollapseButton.propTypes = { - collapsed: PropTypes.bool.isRequired, -}; -export default CollapseButton; diff --git a/client/src/Components/Sidebar/components/collapseButton.jsx b/client/src/Components/Sidebar/components/collapseButton.jsx index bcae710f0..e7c382e5a 100644 --- a/client/src/Components/Sidebar/components/collapseButton.jsx +++ b/client/src/Components/Sidebar/components/collapseButton.jsx @@ -4,27 +4,24 @@ import ArrowLeft from "../../ArrowLeft"; import { useTheme } from "@mui/material/styles"; import { useDispatch } from "react-redux"; import { toggleSidebar } from "../../../Features/UI/uiSlice"; - import PropTypes from "prop-types"; const CollapseButton = ({ collapsed }) => { const theme = useTheme(); const dispatch = useDispatch(); - const arrowIcon = collapsed ? ( ) : ( ); - return ( { }; CollapseButton.propTypes = { - collapsed: PropTypes.bool, + collapsed: PropTypes.bool.isRequired, }; - export default CollapseButton; diff --git a/client/src/Components/Sidebar/index.jsx b/client/src/Components/Sidebar/index.jsx index e8ce7ff78..bbecb075d 100644 --- a/client/src/Components/Sidebar/index.jsx +++ b/client/src/Components/Sidebar/index.jsx @@ -1,8 +1,8 @@ import Stack from "@mui/material/Stack"; import List from "@mui/material/List"; -import Logo from "./logo"; -import CollapseButton from "./collapseButton"; +import Logo from "./components/logo"; +import CollapseButton from "./components/collapseButton"; import Divider from "@mui/material/Divider"; import NavItem from "./components/navItem"; import AuthFooter from "./components/authFooter"; diff --git a/client/src/Components/Sidebar/logo.jsx b/client/src/Components/Sidebar/logo.jsx deleted file mode 100644 index e6aa660fb..000000000 --- a/client/src/Components/Sidebar/logo.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import { useTheme } from "@mui/material/styles"; -import { useNavigate } from "react-router"; -import { useTranslation } from "react-i18next"; - -const Logo = ({ collapsed }) => { - const { t } = useTranslation(); - const theme = useTheme(); - const navigate = useNavigate(); - - return ( - navigate("/")} - sx={{ cursor: "pointer" }} - > - - C - - - {" "} - - {t("common.appName")} - - - - ); -}; - -export default Logo; From fe9bf6375f0fcbea0984a2066c93cebd292e6ee2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 1 Aug 2025 15:14:03 -0700 Subject: [PATCH 158/226] fix sticky sidebar --- .../src/Components/Sidebar/components/authFooter.jsx | 2 +- client/src/Components/Sidebar/index.jsx | 10 ++++++++-- client/src/index.css | 2 ++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/client/src/Components/Sidebar/components/authFooter.jsx b/client/src/Components/Sidebar/components/authFooter.jsx index dbc1ccfb0..224bdbb34 100644 --- a/client/src/Components/Sidebar/components/authFooter.jsx +++ b/client/src/Components/Sidebar/components/authFooter.jsx @@ -81,7 +81,7 @@ const AuthFooter = ({ collapsed, accountMenuItems }) => { return ( { return ( Date: Fri, 1 Aug 2025 15:22:44 -0700 Subject: [PATCH 159/226] remove extra popup state --- .../Components/Sidebar/components/authFooter.jsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/client/src/Components/Sidebar/components/authFooter.jsx b/client/src/Components/Sidebar/components/authFooter.jsx index 224bdbb34..616754c67 100644 --- a/client/src/Components/Sidebar/components/authFooter.jsx +++ b/client/src/Components/Sidebar/components/authFooter.jsx @@ -18,6 +18,7 @@ import { useState } from "react"; import { useNavigate } from "react-router"; import { clearAuthState } from "../../../Features/Auth/authSlice"; import { useDispatch } from "react-redux"; +import PropTypes from "prop-types"; const AuthFooter = ({ collapsed, accountMenuItems }) => { const { t } = useTranslation(); @@ -27,11 +28,9 @@ const AuthFooter = ({ collapsed, accountMenuItems }) => { const dispatch = useDispatch(); const [anchorEl, setAnchorEl] = useState(null); - const [popup, setPopup] = useState(); - const openPopup = (event, id) => { + const openPopup = (event) => { setAnchorEl(event.currentTarget); - setPopup(id); }; const closePopup = () => { @@ -169,7 +168,7 @@ const AuthFooter = ({ collapsed, accountMenuItems }) => { stroke: theme.palette.primary.contrastTextTertiary, }, }} - onClick={(event) => openPopup(event, "logout")} + onClick={(event) => openPopup(event)} > @@ -179,7 +178,7 @@ const AuthFooter = ({ collapsed, accountMenuItems }) => { { ); }; +AuthFooter.propTypes = { + collapsed: PropTypes.bool, + accountMenuItems: PropTypes.array, +}; + export default AuthFooter; From eb4856901b716de2539d61d7e5e713318c12e19f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 1 Aug 2025 15:28:22 -0700 Subject: [PATCH 160/226] extract function --- .../Sidebar/components/authFooter.jsx | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/client/src/Components/Sidebar/components/authFooter.jsx b/client/src/Components/Sidebar/components/authFooter.jsx index 616754c67..6d2dfceeb 100644 --- a/client/src/Components/Sidebar/components/authFooter.jsx +++ b/client/src/Components/Sidebar/components/authFooter.jsx @@ -20,6 +20,22 @@ import { clearAuthState } from "../../../Features/Auth/authSlice"; import { useDispatch } from "react-redux"; import PropTypes from "prop-types"; +const getFilteredAccountMenuItems = (user, items) => { + if (!user) return []; + + let filtered = [...items]; + + if (user.role?.includes("demo")) { + filtered = filtered.filter((item) => item.name !== "Password"); + } + + if (!user.role?.includes("superadmin")) { + filtered = filtered.filter((item) => item.name !== "Team"); + } + + return filtered; +}; + const AuthFooter = ({ collapsed, accountMenuItems }) => { const { t } = useTranslation(); const theme = useTheme(); @@ -43,23 +59,9 @@ const AuthFooter = ({ collapsed, accountMenuItems }) => { navigate("/login"); }; const renderAccountMenuItems = (user, items) => { - let filteredAccountMenuItems = [...items]; + const filteredItems = getFilteredAccountMenuItems(user, items); - // If the user is in demo mode, remove the "Password" option - if (user.role?.includes("demo")) { - filteredAccountMenuItems = filteredAccountMenuItems.filter( - (item) => item.name !== "Password" - ); - } - - // If the user is NOT a superadmin, remove the "Team" option - if (user.role && !user.role.includes("superadmin")) { - filteredAccountMenuItems = filteredAccountMenuItems.filter( - (item) => item.name !== "Team" - ); - } - - return filteredAccountMenuItems.map((item) => ( + return filteredItems.map((item) => ( { From 7c22663250f2c52b7e1fbb60ac608a6a963bb11a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 1 Aug 2025 15:31:03 -0700 Subject: [PATCH 161/226] remove unused css and old sidebar --- client/src/Components/Sidebar/index.css | 105 --- client/src/Components/Sidebar/index_old.jsx | 873 -------------------- 2 files changed, 978 deletions(-) delete mode 100644 client/src/Components/Sidebar/index.css delete mode 100644 client/src/Components/Sidebar/index_old.jsx diff --git a/client/src/Components/Sidebar/index.css b/client/src/Components/Sidebar/index.css deleted file mode 100644 index a14a0f3d6..000000000 --- a/client/src/Components/Sidebar/index.css +++ /dev/null @@ -1,105 +0,0 @@ -TODO aside .MuiList-root svg { - width: 20px; - height: 20px; - opacity: 0.9; -} -aside span.MuiTypography-root { - font-size: var(--env-var-font-size-medium); - line-height: 1; -} -aside .MuiStack-root + span.MuiTypography-root { - font-size: var(--env-var-font-size-medium-plus); -} -aside .MuiListSubheader-root { - font-size: var(--env-var-font-size-small); - font-weight: 500; - line-height: 1.5; - text-transform: uppercase; - margin-bottom: 2px; - opacity: 0.6; -} -aside p.MuiTypography-root { - font-size: var(--env-var-font-size-small); - opacity: 0.8; -} -aside .MuiListItemButton-root:not(.selected-path) > * { - opacity: 0.9; -} -aside .selected-path > * { - opacity: 1; -} -aside .selected-path span.MuiTypography-root { - font-weight: 600; -} -aside .MuiCollapse-wrapperInner .MuiList-root > .MuiListItemButton-root { - position: relative; -} -aside .MuiCollapse-wrapperInner .MuiList-root svg, -aside .MuiList-root .MuiListItemText-root + svg { - width: 18px; - height: 18px; -} - -.sidebar-popup li.MuiButtonBase-root:has(.MuiBox-root) { - padding-bottom: 0; -} -.sidebar-popup svg { - width: 16px; - height: 16px; - opacity: 0.9; -} - -/* TRANSITIONS */ -aside { - flex: 1; - transition: max-width 650ms cubic-bezier(0.36, -0.01, 0, 0.77); -} -.home-layout aside.collapsed { - max-width: 64px; -} - -aside.expanded .MuiTypography-root, -aside.expanded p.MuiTypography-root, -aside.expanded .MuiListItemText-root + svg, -aside.expanded .MuiAvatar-root + .MuiBox-root + .MuiIconButton-root { - visibility: visible; - animation: fadeIn 1s ease; -} - -aside.collapsed .MuiTypography-root, -aside.collapsed p.MuiTypography-root, -aside.collapsed .MuiListItemText-root + svg, -aside.collapsed .MuiAvatar-root + .MuiBox-root + .MuiIconButton-root { - opacity: 0; - visibility: hidden; -} - -aside .MuiListSubheader-root { - transition: padding 200ms ease; -} - -.sidebar-delay-fade { - opacity: 0; - visibility: hidden; - transition: opacity 0.3s ease; -} - -aside.expanded.sidebar-ready .sidebar-delay-fade { - opacity: 1; - visibility: visible; -} - -@keyframes fadeIn { - 0% { - opacity: 0; - visibility: hidden; - } - 30% { - opacity: 0; - visibility: hidden; - } - 100% { - opacity: 0.9; - visibility: visible; - } -} diff --git a/client/src/Components/Sidebar/index_old.jsx b/client/src/Components/Sidebar/index_old.jsx deleted file mode 100644 index 219ceff1e..000000000 --- a/client/src/Components/Sidebar/index_old.jsx +++ /dev/null @@ -1,873 +0,0 @@ -import React, { useEffect, useState, useRef } from "react"; -import { - Box, - Collapse, - Divider, - IconButton, - List, - ListItemButton, - ListItemIcon, - ListItemText, - Menu, - MenuItem, - Stack, - Tooltip, - Typography, -} from "@mui/material"; -import ThemeSwitch from "../ThemeSwitch"; -import Avatar from "../Avatar"; -import StarPrompt from "../StarPrompt"; -import LockSvg from "../../assets/icons/lock.svg?react"; -import UserSvg from "../../assets/icons/user.svg?react"; -import TeamSvg from "../../assets/icons/user-two.svg?react"; -import LogoutSvg from "../../assets/icons/logout.svg?react"; -import Support from "../../assets/icons/support.svg?react"; -import Maintenance from "../../assets/icons/maintenance.svg?react"; -import Monitors from "../../assets/icons/monitors.svg?react"; -import Incidents from "../../assets/icons/incidents.svg?react"; -import Integrations from "../../assets/icons/integrations.svg?react"; -import PageSpeed from "../../assets/icons/page-speed.svg?react"; -import Settings from "../../assets/icons/settings.svg?react"; -import ArrowDown from "../../assets/icons/down-arrow.svg?react"; -import ArrowUp from "../../assets/icons/up-arrow.svg?react"; -import ArrowRight from "../../assets/icons/right-arrow.svg?react"; -import ArrowLeft from "../../assets/icons/left-arrow.svg?react"; -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 StatusPages from "../../assets/icons/status-pages.svg?react"; -import Discussions from "../../assets/icons/discussions.svg?react"; -import Notifications from "../../assets/icons/notifications.svg?react"; -import Logs from "../../assets/icons/logs.svg?react"; - -import "./index.css"; - -// Utils -import { useLocation, useNavigate } from "react-router"; -import { useTheme } from "@emotion/react"; -import { useDispatch, useSelector } from "react-redux"; -import { useTranslation } from "react-i18next"; -import { clearAuthState } from "../../Features/Auth/authSlice"; -import { toggleSidebar } from "../../Features/UI/uiSlice"; -import { TurnedIn } from "@mui/icons-material"; -import { rules } from "eslint-plugin-react-refresh"; - -const getMenu = (t) => [ - { name: t("menu.uptime"), path: "uptime", icon: }, - { name: t("menu.pagespeed"), path: "pagespeed", icon: }, - - { name: t("menu.infrastructure"), path: "infrastructure", icon: }, - { - name: t("menu.notifications"), - path: "notifications", - icon: , - }, - { name: t("menu.incidents"), path: "incidents", icon: }, - - { name: t("menu.statusPages"), path: "status", icon: }, - { name: t("menu.maintenance"), path: "maintenance", icon: }, - { name: t("menu.logs"), path: "logs", icon: }, - - { - name: t("menu.settings"), - icon: , - path: "settings", - }, -]; - -const getOtherMenuItems = (t) => [ - { name: t("menu.support"), path: "support", icon: }, - { - name: t("menu.discussions"), - path: "discussions", - icon: , - }, - { name: t("menu.docs"), path: "docs", icon: }, - { name: t("menu.changelog"), path: "changelog", icon: }, -]; - -const getAccountMenuItems = (t) => [ - { name: t("menu.profile"), path: "account/profile", icon: }, - { name: t("menu.password"), path: "account/password", icon: }, - { name: t("menu.team"), path: "account/team", icon: }, -]; - -/* TODO this could be a key in nested Path would be the link */ -const URL_MAP = { - support: "https://discord.com/invite/NAb6H3UTjK", - discussions: "https://github.com/bluewave-labs/checkmate/discussions", - docs: "https://bluewavelabs.gitbook.io/checkmate", - changelog: "https://github.com/bluewave-labs/checkmate/releases", -}; - -const PATH_MAP = { - monitors: "Dashboard", - pagespeed: "Dashboard", - infrastructure: "Dashboard", - account: "Account", - settings: "Settings", -}; - -/** - * @component - * Sidebar component serves as a sidebar containing a menu. - * - * @returns {JSX.Element} The JSX element representing the Sidebar component. - */ - -function Sidebar() { - const theme = useTheme(); - const navigate = useNavigate(); - const location = useLocation(); - const dispatch = useDispatch(); - const { t } = useTranslation(); - const authState = useSelector((state) => state.auth); - - const otherMenuItems = getOtherMenuItems(t); - const accountMenuItems = getAccountMenuItems(t); - const collapsed = useSelector((state) => state.ui.sidebar.collapsed); - const [open, setOpen] = useState({ Dashboard: false, Account: false, Other: false }); - const [anchorEl, setAnchorEl] = useState(null); - const [popup, setPopup] = useState(); - const { user } = useSelector((state) => state.auth); - - const sidebarRef = useRef(null); - const [sidebarReady, setSidebarReady] = useState(false); - const TRANSITION_DURATION = 200; - let menu = getMenu(t); - menu = menu.filter((item) => { - if (item.path === "logs") { - return user.role?.includes("admin") || user.role?.includes("superadmin"); - } - return true; - }); - - useEffect(() => { - if (!collapsed) { - setSidebarReady(false); - const timeout = setTimeout(() => { - setSidebarReady(true); - }, TRANSITION_DURATION); - return () => clearTimeout(timeout); - } else { - setSidebarReady(false); - } - }, [collapsed]); - - const renderAccountMenuItems = () => { - let filteredAccountMenuItems = [...accountMenuItems]; - - // If the user is in demo mode, remove the "Password" option - if (user.role?.includes("demo")) { - filteredAccountMenuItems = filteredAccountMenuItems.filter( - (item) => item.name !== "Password" - ); - } - - // If the user is NOT a superadmin, remove the "Team" option - if (user.role && !user.role.includes("superadmin")) { - filteredAccountMenuItems = filteredAccountMenuItems.filter( - (item) => item.name !== "Team" - ); - } - - return filteredAccountMenuItems.map((item) => ( - { - closePopup(); - navigate(item.path); - }} - sx={{ - gap: theme.spacing(2), - borderRadius: theme.shape.borderRadius, - pl: theme.spacing(4), - }} - > - {item.icon} - {item.name} - - )); - }; - - const openPopup = (event, id) => { - setAnchorEl(event.currentTarget); - setPopup(id); - }; - const closePopup = () => { - setAnchorEl(null); - }; - - /** - * Handles logging out the user - * - */ - const logout = async () => { - // Clear auth state - dispatch(clearAuthState()); - navigate("/login"); - }; - - useEffect(() => { - const matchedKey = Object.keys(PATH_MAP).find((key) => - location.pathname.includes(key) - ); - - if (matchedKey) { - setOpen((prev) => ({ ...prev, [PATH_MAP[matchedKey]]: true })); - } - }, [location]); - - const iconColor = theme.palette.primary.contrastTextTertiary; - const sidebarClassName = `${collapsed ? "collapsed" : "expanded"} ${sidebarReady ? "sidebar-ready" : ""}`; - - /* TODO refactor this, there are a some ternaries and comments in the return */ - return ( - - { - setOpen((prev) => - Object.fromEntries(Object.keys(prev).map((key) => [key, false])) - ); - dispatch(toggleSidebar()); - }} - > - {collapsed ? : } - - {/* TODO Alignment done using padding. Use single source of truth to that*/} - - {/* TODO Abstract logo into component */} - {/* TODO Turn logo into a link */} - navigate("/")} - sx={{ cursor: "pointer" }} - > - - C - - - {t("common.appName")} - - - - - - {menu.map((item) => { - return item.path ? ( - /* If item has a path */ - - navigate(`/${item.path}`)} - sx={{ - height: "37px", - gap: theme.spacing(4), - borderRadius: theme.shape.borderRadius, - px: theme.spacing(4), - pl: theme.spacing(5), - }} - > - {item.icon} - {item.name} - - - ) : collapsed ? ( - /* TODO Do we ever get here? If item does not have a path and collapsed state is true */ - - - openPopup(event, item.name)} - sx={{ - position: "relative", - gap: theme.spacing(4), - borderRadius: theme.shape.borderRadius, - px: theme.spacing(4), - }} - > - {item.icon} - {item.name} - - - - {item.nested.map((child) => { - if ( - child.name === "Team" && - authState.user?.role && - !authState.user.role.includes("superadmin") - ) { - return null; - } - - return ( - { - const url = URL_MAP[child.path]; - if (url) { - window.open(url, "_blank", "noreferrer"); - } else { - navigate(`/${child.path}`); - } - closePopup(); - }} - sx={{ - gap: theme.spacing(4), - opacity: 0.9, - /* TODO this has no effect? */ - "& svg": { - "& path": { - stroke: theme.palette.primary.contrastTextTertiary, - strokeWidth: 1.1, - }, - }, - }} - > - {child.icon} - {child.name} - - ); - })} - - - ) : ( - /* TODO Do we ever get here? If item does not have a path and collapsed state is false */ - - - setOpen((prev) => ({ - ...Object.fromEntries(Object.keys(prev).map((key) => [key, false])), - [item.name]: !prev[item.name], - })) - } - sx={{ - gap: theme.spacing(4), - borderRadius: theme.shape.borderRadius, - px: theme.spacing(4), - }} - > - {item.icon} - {item.name} - {open[`${item.name}`] ? : } - - - - {item.nested.map((child) => { - if ( - child.name === "Team" && - authState.user?.role && - !authState.user.role.includes("superadmin") - ) { - return null; - } - - return ( - { - const url = URL_MAP[child.path]; - if (url) { - window.open(url, "_blank", "noreferrer"); - } else { - navigate(`/${child.path}`); - } - }} - sx={{ - gap: theme.spacing(4), - borderRadius: theme.shape.borderRadius, - pl: theme.spacing(4), - "&::before": { - content: `""`, - position: "absolute", - top: 0, - left: "-7px", - height: "100%", - borderLeft: 1, - borderLeftColor: theme.palette.primary.lowContrast, - }, - "&:last-child::before": { - height: "50%", - }, - "&::after": { - content: `""`, - position: "absolute", - top: "45%", - left: "-8px", - height: "3px", - width: "3px", - borderRadius: "50%", - backgroundColor: theme.palette.primary.lowContrast, - }, - "&.selected-path::after": { - /* TODO what is this selector doing? */ - backgroundColor: theme.palette.primary.contrastTextTertiary, - transform: "scale(1.2)", - }, - }} - > - {child.icon} - {child.name} - - ); - })} - - - - ); - })} - - - - {!collapsed && } - - - {otherMenuItems.map((item) => { - return item.path ? ( - - { - const url = URL_MAP[item.path]; - if (url) { - window.open(url, "_blank", "noreferrer"); - } else { - navigate(`/${item.path}`); - } - }} - sx={{ - height: "37px", - gap: theme.spacing(4), - borderRadius: theme.shape.borderRadius, - px: theme.spacing(4), - pl: theme.spacing(5), - }} - > - {item.icon} - {item.name} {" "} - - - ) : null; - })} - - - - {collapsed ? ( - <> - - openPopup(event, "logout")} - sx={{ p: 0, "&:focus": { outline: "none" } }} - > - - - - - ) : ( - <> - - - - {authState.user?.firstName} {authState.user?.lastName} - - - {authState.user?.role?.includes("superadmin") - ? t("roles.superAdmin") - : authState.user?.role?.includes("admin") - ? t("roles.admin") - : authState.user?.role?.includes("user") - ? t("roles.teamMember") - : authState.user?.role?.includes("demo") - ? t("roles.demoUser") - : authState.user?.role} - - - - - - openPopup(event, "logout")} - > - - - - - - )} - - {collapsed && ( - - - - {authState.user?.firstName} {authState.user?.lastName} - - - {authState.user?.role} - - - - )} - {/* TODO Do we need two dividers? */} - {collapsed && } - {/* */} - {renderAccountMenuItems()} - - - {t("menu.logOut", "Log out")} - - - - - ); -} - -export default Sidebar; From 12747bd95b499d7332c5c45647951dd928a41778 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 1 Aug 2025 15:36:33 -0700 Subject: [PATCH 162/226] remove console --- client/src/Components/ArrowLeft/index.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/Components/ArrowLeft/index.jsx b/client/src/Components/ArrowLeft/index.jsx index cc7e0b078..7909441f3 100644 --- a/client/src/Components/ArrowLeft/index.jsx +++ b/client/src/Components/ArrowLeft/index.jsx @@ -4,7 +4,6 @@ import LeftArrowLong from "../../assets/icons/left-arrow-long.svg?react"; import PropTypes from "prop-types"; const ArrowLeft = ({ type, color = "#667085", ...props }) => { - console.log(color); if (type === "double") { return ( Date: Fri, 1 Aug 2025 15:43:12 -0700 Subject: [PATCH 163/226] extract role names --- .../Sidebar/components/authFooter.jsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/client/src/Components/Sidebar/components/authFooter.jsx b/client/src/Components/Sidebar/components/authFooter.jsx index 6d2dfceeb..c18584b96 100644 --- a/client/src/Components/Sidebar/components/authFooter.jsx +++ b/client/src/Components/Sidebar/components/authFooter.jsx @@ -36,6 +36,17 @@ const getFilteredAccountMenuItems = (user, items) => { return filtered; }; +const getRoleDisplayText = (user, t) => { + if (!user?.role) return ""; + + if (user.role.includes("superadmin")) return t("roles.superAdmin"); + if (user.role.includes("admin")) return t("roles.admin"); + if (user.role.includes("user")) return t("roles.teamMember"); + if (user.role.includes("demo")) return t("roles.demoUser"); + + return user.role; +}; + const AuthFooter = ({ collapsed, accountMenuItems }) => { const { t } = useTranslation(); const theme = useTheme(); @@ -54,7 +65,6 @@ const AuthFooter = ({ collapsed, accountMenuItems }) => { }; const logout = async () => { - // Clear auth state dispatch(clearAuthState()); navigate("/login"); }; @@ -133,15 +143,7 @@ const AuthFooter = ({ collapsed, accountMenuItems }) => { whiteSpace="nowrap" sx={{ textTransform: "capitalize", opacity: 0.8 }} > - {authState.user?.role?.includes("superadmin") - ? t("roles.superAdmin") - : authState.user?.role?.includes("admin") - ? t("roles.admin") - : authState.user?.role?.includes("user") - ? t("roles.teamMember") - : authState.user?.role?.includes("demo") - ? t("roles.demoUser") - : authState.user?.role} + {getRoleDisplayText(authState.user, t)} Date: Fri, 1 Aug 2025 15:45:43 -0700 Subject: [PATCH 164/226] add proptypes, fix logo text animation --- client/src/Components/Sidebar/components/logo.jsx | 9 +++++++-- client/src/Components/Sidebar/components/navItem.jsx | 10 +++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/client/src/Components/Sidebar/components/logo.jsx b/client/src/Components/Sidebar/components/logo.jsx index e6aa660fb..7a59f406d 100644 --- a/client/src/Components/Sidebar/components/logo.jsx +++ b/client/src/Components/Sidebar/components/logo.jsx @@ -4,6 +4,7 @@ import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; const Logo = ({ collapsed }) => { const { t } = useTranslation(); @@ -38,10 +39,10 @@ const Logo = ({ collapsed }) => { {" "} @@ -59,4 +60,8 @@ const Logo = ({ collapsed }) => { ); }; +Logo.propTypes = { + collapsed: PropTypes.bool, +}; + export default Logo; diff --git a/client/src/Components/Sidebar/components/navItem.jsx b/client/src/Components/Sidebar/components/navItem.jsx index f43b1bf1e..409a84fde 100644 --- a/client/src/Components/Sidebar/components/navItem.jsx +++ b/client/src/Components/Sidebar/components/navItem.jsx @@ -3,13 +3,11 @@ import ListItemButton from "@mui/material/ListItemButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; - import { useTheme } from "@emotion/react"; -import { useNavigate } from "react-router"; +import PropTypes from "prop-types"; const NavItem = ({ item, collapsed, selected, onClick }) => { const theme = useTheme(); - const navigate = useNavigate(); return ( { ); }; +NavItem.propTypes = { + item: PropTypes.object, + collapsed: PropTypes.bool, + selected: PropTypes.bool, + onClick: PropTypes.func, +}; export default NavItem; From 7900e1104f0a5e7b77f4ad91b81f29854f784c10 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 1 Aug 2025 15:52:55 -0700 Subject: [PATCH 165/226] clean up nav item --- .../Components/Sidebar/components/navItem.jsx | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/client/src/Components/Sidebar/components/navItem.jsx b/client/src/Components/Sidebar/components/navItem.jsx index 409a84fde..2c569edde 100644 --- a/client/src/Components/Sidebar/components/navItem.jsx +++ b/client/src/Components/Sidebar/components/navItem.jsx @@ -8,7 +8,15 @@ import PropTypes from "prop-types"; const NavItem = ({ item, collapsed, selected, onClick }) => { const theme = useTheme(); + const iconStroke = selected + ? theme.palette.primary.contrastText + : theme.palette.primary.contrastTextTertiary; + const buttonBgColor = selected ? theme.palette.secondary.main : "transparent"; + const buttonBgHoverColor = selected + ? theme.palette.secondary.main + : theme.palette.tertiary.main; + const fontWeight = selected ? 600 : 400; return ( { > { sx={{ minWidth: 0, "& svg": { - height: "20px", - width: "20px", + height: 20, + width: 20, opacity: 0.81, }, "& svg path": { - stroke: selected - ? theme.palette.primary.contrastText - : theme.palette.primary.contrastTextTertiary, + stroke: iconStroke, }, }} > @@ -72,7 +76,7 @@ const NavItem = ({ item, collapsed, selected, onClick }) => { variant="body1" color={theme.palette.primary.contrastText} sx={{ - fontWeight: selected ? 600 : 400, + fontWeight: fontWeight, opacity: 0.9, }} > From 521c6988a52765ce789aac2de5eff3fb62179f4a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 1 Aug 2025 15:58:01 -0700 Subject: [PATCH 166/226] add onclick to avatar --- client/src/Components/Avatar/index.jsx | 4 +++- client/src/Components/Sidebar/components/authFooter.jsx | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/client/src/Components/Avatar/index.jsx b/client/src/Components/Avatar/index.jsx index aa2050827..d089080ff 100644 --- a/client/src/Components/Avatar/index.jsx +++ b/client/src/Components/Avatar/index.jsx @@ -15,7 +15,7 @@ import { useTheme } from "@emotion/react"; * */ -const Avatar = ({ src, small, sx }) => { +const Avatar = ({ src, small, sx, onClick = () => {} }) => { const { user } = useSelector((state) => state.auth); const theme = useTheme(); @@ -31,6 +31,7 @@ const Avatar = ({ src, small, sx }) => { return ( { borderRadius={theme.shape.borderRadius} boxSizing={"border-box"} > - + collapsed && openPopup(e)} + sx={{ + cursor: collapsed ? "pointer" : "default", + }} + /> Date: Sat, 2 Aug 2025 02:04:06 +0300 Subject: [PATCH 167/226] Added game server support --- client/src/Hooks/monitorHooks.js | 2 +- client/src/Pages/Uptime/Create/index.jsx | 40 +- .../Monitors/Components/Filter/index.jsx | 1 + client/src/Pages/Uptime/Monitors/index.jsx | 2 +- client/src/Utils/games.js | 3450 +++++++++++++++++ client/src/Validation/validation.js | 1 + client/src/locales/ar.json | 4 +- client/src/locales/cs.json | 4 +- client/src/locales/de.json | 4 +- client/src/locales/en.json | 11 +- client/src/locales/es.json | 4 +- client/src/locales/fi.json | 4 +- client/src/locales/fr.json | 4 +- client/src/locales/ja.json | 4 +- client/src/locales/pt-BR.json | 4 +- client/src/locales/ru.json | 4 +- client/src/locales/tr.json | 4 +- client/src/locales/uk.json | 4 +- client/src/locales/vi.json | 4 +- client/src/locales/zh-TW.json | 4 +- server/package-lock.json | 565 ++- server/package.json | 1 + server/src/db/models/Monitor.js | 5 +- server/src/db/mongo/modules/checkModule.js | 1 + server/src/db/mongo/modules/monitorModule.js | 2 +- .../db/mongo/modules/monitorModuleQueries.js | 2 +- server/src/service/business/monitorService.js | 14 +- .../service/infrastructure/bufferService.js | 1 + .../service/infrastructure/networkService.js | 55 + server/src/validation/joi.js | 8 +- 30 files changed, 4110 insertions(+), 103 deletions(-) create mode 100644 client/src/Utils/games.js diff --git a/client/src/Hooks/monitorHooks.js b/client/src/Hooks/monitorHooks.js index 616a37877..4a19f2fa7 100644 --- a/client/src/Hooks/monitorHooks.js +++ b/client/src/Hooks/monitorHooks.js @@ -357,7 +357,7 @@ const useUpdateMonitor = () => { expectedValue: monitor.expectedValue, ignoreTlsErrors: monitor.ignoreTlsErrors, jsonPath: monitor.jsonPath, - ...(monitor.type === "port" && { port: monitor.port }), + ...((monitor.type === "port" || monitor.type === "game") && { port: monitor.port }), ...(monitor.type === "hardware" && { thresholds: monitor.thresholds, secret: monitor.secret, diff --git a/client/src/Pages/Uptime/Create/index.jsx b/client/src/Pages/Uptime/Create/index.jsx index 2d918cb9f..3a1080421 100644 --- a/client/src/Pages/Uptime/Create/index.jsx +++ b/client/src/Pages/Uptime/Create/index.jsx @@ -43,6 +43,8 @@ import { useFetchMonitorById, } from "../../../Hooks/monitorHooks"; +import { GAMES } from '../../../Utils/games'; + /** * Parses a URL string and returns a URL object. * @@ -113,6 +115,12 @@ const UptimeCreate = ({ isClone = false }) => { { _id: 4, name: t("time.fourMinutes") }, { _id: 5, name: t("time.fiveMinutes") }, ]; + + const GAMELIST = Object.entries(GAMES).map(([key, value]) => ({ + _id: key, + name: value.name + })); + const CRUMBS = [ { name: "uptime", path: "/uptime" }, ...(isCreate @@ -153,6 +161,11 @@ const UptimeCreate = ({ isClone = false }) => { placeholder: t("monitorType.port.placeholder"), namePlaceholder: t("monitorType.port.namePlaceholder"), }, + game: { + label: t("monitorType.game.label"), + placeholder: t("monitorType.game.placeholder"), + namePlaceholder: t("monitorType.game.namePlaceholder"), + } }; // Handlers @@ -169,12 +182,13 @@ const UptimeCreate = ({ isClone = false }) => { : monitor.url, name: monitor.name || monitor.url.substring(0, 50), type: monitor.type, - port: monitor.type === "port" ? monitor.port : undefined, + port: (monitor.type === "port" || monitor.type === "game") ? monitor.port : undefined, interval: monitor.interval, matchMethod: monitor.matchMethod, expectedValue: monitor.expectedValue, jsonPath: monitor.jsonPath, ignoreTlsErrors: monitor.ignoreTlsErrors, + gameId: monitor.gameId || undefined, }; } else { form = { @@ -188,8 +202,9 @@ const UptimeCreate = ({ isClone = false }) => { interval: monitor.interval, teamId: monitor.teamId, userId: monitor.userId, - port: monitor.type === "port" ? monitor.port : undefined, + port: (monitor.type === "port" || monitor.type === "game") ? monitor.port : undefined, ignoreTlsErrors: monitor.ignoreTlsErrors, + gameId: monitor.gameId || undefined, }; } if (!useAdvancedMatching) { @@ -486,6 +501,15 @@ const UptimeCreate = ({ isClone = false }) => { checked={monitor.type === "port"} onChange={onChange} /> + {errors["type"] ? ( { onChange={onChange} error={errors["port"] ? true : false} helperText={errors["port"]} - hidden={monitor.type !== "port"} + hidden={monitor.type !== "port" && monitor.type !== "game"} /> + { + monitor.type === "game" && + ()} + {monitor.type === "game" && ( + { const BREADCRUMBS = [{ name: t("logsPage.title"), path: "/logs" }]; - // Height of the header (Breadcrumbs + Tabs) - const HEADER_HEIGHT = theme.spacing(25); - return ( - <> + + + - { - {/* Main content below fixed header */} - - {value === 0 && } - {value === 1 && } - {value === 2 && } - - + {/* Main content */} + {value === 0 && } + {value === 1 && } + {value === 2 && } + ); }; From 13a5f699bafc492de810c2dac921087e5b0350c8 Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Wed, 13 Aug 2025 22:53:18 +0530 Subject: [PATCH 218/226] Updated Style --- client/src/Pages/Logs/Logs/index.jsx | 48 +++++++++++----------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/client/src/Pages/Logs/Logs/index.jsx b/client/src/Pages/Logs/Logs/index.jsx index a5bc9757e..29cc55a9b 100644 --- a/client/src/Pages/Logs/Logs/index.jsx +++ b/client/src/Pages/Logs/Logs/index.jsx @@ -51,41 +51,31 @@ const Logs = () => { ]; return ( - {t("logsPage.description")} - - - - - {t("logsPage.logLevelSelect.title")} - setLogLevel(e.target.value)} + /> + Date: Thu, 14 Aug 2025 00:04:53 +0530 Subject: [PATCH 219/226] Metrics validated. --- server/src/db/mongo/modules/monitorModule.js | 11 - .../db/mongo/modules/monitorModuleQueries.js | 474 ++++++++++++------ 2 files changed, 316 insertions(+), 169 deletions(-) diff --git a/server/src/db/mongo/modules/monitorModule.js b/server/src/db/mongo/modules/monitorModule.js index 3afe664f6..0e301cdcf 100755 --- a/server/src/db/mongo/modules/monitorModule.js +++ b/server/src/db/mongo/modules/monitorModule.js @@ -343,17 +343,6 @@ class MonitorModule { const stats = hardwareStats[0]; - if (stats?.net?.length) { - const elapsedSeconds = (new Date(dates.end).getTime() - new Date(dates.start).getTime()) / 1000; - stats.net = stats.net.map((iface) => ({ - ...iface, - bytesSentPerSec: iface.deltaBytesSent / elapsedSeconds, - bytesRecvPerSec: iface.deltaBytesRecv / elapsedSeconds, - packetsSentPerSec: iface.deltaPacketsSent / elapsedSeconds, - packetsRecvPerSec: iface.deltaPacketsRecv / elapsedSeconds, - })); - } - return { ...monitor.toObject(), stats, diff --git a/server/src/db/mongo/modules/monitorModuleQueries.js b/server/src/db/mongo/modules/monitorModuleQueries.js index 91fb83444..7753066fc 100755 --- a/server/src/db/mongo/modules/monitorModuleQueries.js +++ b/server/src/db/mongo/modules/monitorModuleQueries.js @@ -263,6 +263,9 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { net: { $push: "$net", }, + updatedAts: { + $push: "$updatedAt", + }, }, }, { @@ -378,9 +381,7 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { }, net: { $map: { - input: { - $range: [0, { $size: { $arrayElemAt: ["$net", 0] } }], - }, + input: { $range: [0, { $size: { $arrayElemAt: ["$net", 0] } }] }, as: "netIndex", in: { name: { @@ -395,211 +396,368 @@ const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { "$$netIndex", ], }, - deltaBytesSent: { - $subtract: [ - { - $arrayElemAt: [ - { - $map: { - input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, - as: "iface", - in: "$$iface.bytes_sent", + bytesSentPerSecond: { + $let: { + vars: { + first: { + $arrayElemAt: [ + { $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.bytes_sent" } }, + "$$netIndex", + ], + }, + last: { + $arrayElemAt: [ + { + $map: { + input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, + as: "iface", + in: "$$iface.bytes_sent", + }, }, + "$$netIndex", + ], + }, + tFirst: { $arrayElemAt: ["$updatedAts", 0] }, + tLast: { $arrayElemAt: ["$updatedAts", { $subtract: [{ $size: "$updatedAts" }, 1] }] }, + }, + in: { + $cond: [ + { $gt: [{ $subtract: ["$$tLast", "$$tFirst"] }, 0] }, + { + $divide: [{ $subtract: ["$$last", "$$first"] }, { $divide: [{ $subtract: ["$$tLast", "$$tFirst"] }, 1000] }], }, - "$$netIndex", + 0, ], }, - { - $arrayElemAt: [ - { $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.bytes_sent" } }, - "$$netIndex", - ], - }, - ], + }, }, deltaBytesRecv: { - $subtract: [ - { - $arrayElemAt: [ - { - $map: { - input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, - as: "iface", - in: "$$iface.bytes_recv", + $let: { + vars: { + first: { + $arrayElemAt: [ + { $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.bytes_recv" } }, + "$$netIndex", + ], + }, + last: { + $arrayElemAt: [ + { + $map: { + input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, + as: "iface", + in: "$$iface.bytes_recv", + }, }, + "$$netIndex", + ], + }, + tFirst: { $arrayElemAt: ["$updatedAts", 0] }, + tLast: { $arrayElemAt: ["$updatedAts", { $subtract: [{ $size: "$updatedAts" }, 1] }] }, + }, + in: { + $cond: [ + { $gt: [{ $subtract: ["$$tLast", "$$tFirst"] }, 0] }, + { + $divide: [{ $subtract: ["$$last", "$$first"] }, { $divide: [{ $subtract: ["$$tLast", "$$tFirst"] }, 1000] }], }, - "$$netIndex", + 0, ], }, - { - $arrayElemAt: [ - { $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.bytes_recv" } }, - "$$netIndex", - ], - }, - ], + }, }, deltaPacketsSent: { - $subtract: [ - { - $arrayElemAt: [ - { - $map: { - input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, - as: "iface", - in: "$$iface.packets_sent", + $let: { + vars: { + first: { + $arrayElemAt: [ + { $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.packets_sent" } }, + "$$netIndex", + ], + }, + last: { + $arrayElemAt: [ + { + $map: { + input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, + as: "iface", + in: "$$iface.packets_sent", + }, }, + "$$netIndex", + ], + }, + tFirst: { $arrayElemAt: ["$updatedAts", 0] }, + tLast: { $arrayElemAt: ["$updatedAts", { $subtract: [{ $size: "$updatedAts" }, 1] }] }, + }, + in: { + $cond: [ + { $gt: [{ $subtract: ["$$tLast", "$$tFirst"] }, 0] }, + { + $divide: [{ $subtract: ["$$last", "$$first"] }, { $divide: [{ $subtract: ["$$tLast", "$$tFirst"] }, 1000] }], }, - "$$netIndex", + 0, ], }, - { - $arrayElemAt: [ - { $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.packets_sent" } }, - "$$netIndex", - ], - }, - ], + }, }, deltaPacketsRecv: { - $subtract: [ - { - $arrayElemAt: [ - { - $map: { - input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, - as: "iface", - in: "$$iface.packets_recv", + $let: { + vars: { + first: { + $arrayElemAt: [ + { + $map: { + input: { $arrayElemAt: ["$net", 0] }, + as: "iface", + in: "$$iface.packets_recv", + }, }, + "$$netIndex", + ], + }, + last: { + $arrayElemAt: [ + { + $map: { + input: { + $arrayElemAt: [ + "$net", + { + $subtract: [{ $size: "$net" }, 1], + }, + ], + }, + as: "iface", + in: "$$iface.packets_recv", + }, + }, + "$$netIndex", + ], + }, + tFirst: { $arrayElemAt: ["$updatedAts", 0] }, + tLast: { + $arrayElemAt: [ + "$updatedAts", + { + $subtract: [{ $size: "$updatedAts" }, 1], + }, + ], + }, + }, + in: { + $cond: [ + { $gt: [{ $subtract: ["$$tLast", "$$tFirst"] }, 0] }, + { + $divide: [{ $subtract: ["$$last", "$$first"] }, { $divide: [{ $subtract: ["$$tLast", "$$tFirst"] }, 1000] }], }, - "$$netIndex", + 0, ], }, - { - $arrayElemAt: [ - { $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.packets_recv" } }, - "$$netIndex", - ], - }, - ], + }, }, deltaErrIn: { - $subtract: [ - { - $arrayElemAt: [ - { - $map: { - input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, - as: "iface", - in: "$$iface.err_in", + $let: { + vars: { + first: { + $arrayElemAt: [{ $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.err_in" } }, "$$netIndex"], + }, + last: { + $arrayElemAt: [ + { + $map: { + input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, + as: "iface", + in: "$$iface.err_in", + }, }, + "$$netIndex", + ], + }, + tFirst: { $arrayElemAt: ["$updatedAts", 0] }, + tLast: { $arrayElemAt: ["$updatedAts", { $subtract: [{ $size: "$updatedAts" }, 1] }] }, + }, + in: { + $cond: [ + { $gt: [{ $subtract: ["$$tLast", "$$tFirst"] }, 0] }, + { + $divide: [{ $subtract: ["$$last", "$$first"] }, { $divide: [{ $subtract: ["$$tLast", "$$tFirst"] }, 1000] }], }, - "$$netIndex", + 0, ], }, - { - $arrayElemAt: [{ $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.err_in" } }, "$$netIndex"], - }, - ], + }, }, deltaErrOut: { - $subtract: [ - { - $arrayElemAt: [ - { - $map: { - input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, - as: "iface", - in: "$$iface.err_out", + $let: { + vars: { + first: { + $arrayElemAt: [ + { + $map: { + input: { $arrayElemAt: ["$net", 0] }, + as: "iface", + in: "$$iface.err_out", + }, }, + "$$netIndex", + ], + }, + last: { + $arrayElemAt: [ + { + $map: { + input: { + $arrayElemAt: [ + "$net", + { + $subtract: [{ $size: "$net" }, 1], + }, + ], + }, + as: "iface", + in: "$$iface.err_out", + }, + }, + "$$netIndex", + ], + }, + tFirst: { $arrayElemAt: ["$updatedAts", 0] }, + tLast: { + $arrayElemAt: [ + "$updatedAts", + { + $subtract: [{ $size: "$updatedAts" }, 1], + }, + ], + }, + }, + in: { + $cond: [ + { $gt: [{ $subtract: ["$$tLast", "$$tFirst"] }, 0] }, + { + $divide: [{ $subtract: ["$$last", "$$first"] }, { $divide: [{ $subtract: ["$$tLast", "$$tFirst"] }, 1000] }], }, - "$$netIndex", + 0, ], }, - { - $arrayElemAt: [{ $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.err_out" } }, "$$netIndex"], - }, - ], + }, }, deltaDropIn: { - $subtract: [ - { - $arrayElemAt: [ - { - $map: { - input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, - as: "iface", - in: "$$iface.drop_in", + $let: { + vars: { + first: { + $arrayElemAt: [ + { + $map: { + input: { $arrayElemAt: ["$net", 0] }, + as: "iface", + in: "$$iface.drop_in", + }, }, + "$$netIndex", + ], + }, + last: { + $arrayElemAt: [ + { + $map: { + input: { + $arrayElemAt: [ + "$net", + { + $subtract: [{ $size: "$net" }, 1], + }, + ], + }, + as: "iface", + in: "$$iface.drop_in", + }, + }, + "$$netIndex", + ], + }, + tFirst: { $arrayElemAt: ["$updatedAts", 0] }, + tLast: { + $arrayElemAt: [ + "$updatedAts", + { + $subtract: [{ $size: "$updatedAts" }, 1], + }, + ], + }, + }, + in: { + $cond: [ + { $gt: [{ $subtract: ["$$tLast", "$$tFirst"] }, 0] }, + { + $divide: [{ $subtract: ["$$last", "$$first"] }, { $divide: [{ $subtract: ["$$tLast", "$$tFirst"] }, 1000] }], }, - "$$netIndex", + 0, ], }, - { - $arrayElemAt: [{ $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.drop_in" } }, "$$netIndex"], - }, - ], + }, }, deltaDropOut: { - $subtract: [ - { - $arrayElemAt: [ - { - $map: { - input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, - as: "iface", - in: "$$iface.drop_out", + $let: { + vars: { + first: { + $arrayElemAt: [ + { + $map: { + input: { $arrayElemAt: ["$net", 0] }, + as: "iface", + in: "$$iface.drop_out", + }, }, + "$$netIndex", + ], + }, + last: { + $arrayElemAt: [ + { + $map: { + input: { + $arrayElemAt: [ + "$net", + { + $subtract: [{ $size: "$net" }, 1], + }, + ], + }, + as: "iface", + in: "$$iface.drop_out", + }, + }, + "$$netIndex", + ], + }, + tFirst: { $arrayElemAt: ["$updatedAts", 0] }, + tLast: { + $arrayElemAt: [ + "$updatedAts", + { + $subtract: [{ $size: "$updatedAts" }, 1], + }, + ], + }, + }, + in: { + $cond: [ + { $gt: [{ $subtract: ["$$tLast", "$$tFirst"] }, 0] }, + { + $divide: [{ $subtract: ["$$last", "$$first"] }, { $divide: [{ $subtract: ["$$tLast", "$$tFirst"] }, 1000] }], }, - "$$netIndex", + 0, ], }, - { - $arrayElemAt: [{ $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.drop_out" } }, "$$netIndex"], - }, - ], - }, - deltaFifoIn: { - $subtract: [ - { - $arrayElemAt: [ - { - $map: { - input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, - as: "iface", - in: "$$iface.fifo_in", - }, - }, - "$$netIndex", - ], - }, - { - $arrayElemAt: [{ $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.fifo_in" } }, "$$netIndex"], - }, - ], - }, - deltaFifoOut: { - $subtract: [ - { - $arrayElemAt: [ - { - $map: { - input: { $arrayElemAt: ["$net", { $subtract: [{ $size: "$net" }, 1] }] }, - as: "iface", - in: "$$iface.fifo_out", - }, - }, - "$$netIndex", - ], - }, - { - $arrayElemAt: [{ $map: { input: { $arrayElemAt: ["$net", 0] }, as: "iface", in: "$$iface.fifo_out" } }, "$$netIndex"], - }, - ], + }, }, }, }, }, + firstUpdatedAt: 1, + lastUpdatedAt: 1, }, }, ], From f381b0ce4a3424586d58f37137ea87e1de6f870b Mon Sep 17 00:00:00 2001 From: Tafadzwa Chiwozva Date: Wed, 13 Aug 2025 17:50:30 -0400 Subject: [PATCH 220/226] docs: add guide and tooling for custom CA trust in Dockerized Checkmate (Fixes #2747) --- README.md | 6 + docker/.gitignore | 10 + docker/dev/certs/.gitkeep | 0 .../dev/docker-compose.custom-ca-example.yaml | 30 ++ .../nginx-test/docker-compose.nginx-test.yaml | 13 + docker/dev/nginx-test/nginx.conf | 14 + docs/README.md | 38 ++ docs/custom-ca-quick-reference.md | 60 +++ docs/custom-ca-trust.md | 234 +++++++++ scripts/dev/setup-custom-ca.sh | 143 ++++++ scripts/test-custom-ca.sh | 454 ++++++++++++++++++ 11 files changed, 1002 insertions(+) create mode 100644 docker/dev/certs/.gitkeep create mode 100644 docker/dev/docker-compose.custom-ca-example.yaml create mode 100644 docker/dev/nginx-test/docker-compose.nginx-test.yaml create mode 100644 docker/dev/nginx-test/nginx.conf create mode 100644 docs/README.md create mode 100644 docs/custom-ca-quick-reference.md create mode 100644 docs/custom-ca-trust.md create mode 100755 scripts/dev/setup-custom-ca.sh create mode 100755 scripts/test-custom-ca.sh diff --git a/README.md b/README.md index cc1b10e0e..ca55e38ab 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,12 @@ See installation instructions in [Checkmate documentation portal](https://docs.c Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions. +### Using a Custom CA + +If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options. + +For more documentation, see the [docs directory](./docs/). + ## Translations diff --git a/docker/.gitignore b/docker/.gitignore index 9a6df883a..76567d514 100755 --- a/docker/.gitignore +++ b/docker/.gitignore @@ -12,3 +12,13 @@ dist/docker-compose-test.yaml /dist-mono/redis/data/* /dist-arm/mongo/data/* *.env + +# Custom CA certificates and keys (should not be committed) +dev/certs/* +!dev/certs/.gitkeep + +# Certificate artifacts +*.key +*.csr +*.srl +*.log diff --git a/docker/dev/certs/.gitkeep b/docker/dev/certs/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/docker/dev/docker-compose.custom-ca-example.yaml b/docker/dev/docker-compose.custom-ca-example.yaml new file mode 100644 index 000000000..a5d7e2f43 --- /dev/null +++ b/docker/dev/docker-compose.custom-ca-example.yaml @@ -0,0 +1,30 @@ +# Dev/Test only: Not required in production +# Example Docker Compose configuration for custom CA trust +# This file demonstrates how to configure Checkmate to trust custom CAs +# +# Usage: docker-compose -f docker-compose.yaml -f docker-compose.custom-ca-example.yaml up + +services: + server: + image: uptime_server:latest + restart: always + ports: + - "52345:52345" + env_file: + - server.env + environment: + # Mount your custom CA certificate + NODE_EXTRA_CA_CERTS: /certs/custom-ca.pem + volumes: + # Mount the certs directory (read-only for security) + - ./certs:/certs:ro + depends_on: + - redis + - mongodb + # Optional: Add healthcheck to ensure the server starts properly + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:52345/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/docker/dev/nginx-test/docker-compose.nginx-test.yaml b/docker/dev/nginx-test/docker-compose.nginx-test.yaml new file mode 100644 index 000000000..766e839bd --- /dev/null +++ b/docker/dev/nginx-test/docker-compose.nginx-test.yaml @@ -0,0 +1,13 @@ +# Dev/Test only: Not required in production +# Docker Compose configuration for nginx test service + +services: + nginx-test: + image: nginx:alpine + ports: + - "8443:443" + volumes: + - ../certs/host-int-cert.pem:/etc/nginx/certs/server.crt:ro + - ../certs/host-int-key.pem:/etc/nginx/certs/server.key:ro + - ./nginx.conf:/etc/nginx/nginx.conf:ro + restart: unless-stopped diff --git a/docker/dev/nginx-test/nginx.conf b/docker/dev/nginx-test/nginx.conf new file mode 100644 index 000000000..81c4c9240 --- /dev/null +++ b/docker/dev/nginx-test/nginx.conf @@ -0,0 +1,14 @@ +# Dev/Test only: Not required in production +# Nginx configuration for testing custom CA trust functionality + +events {} +http { + server { + listen 443 ssl; + ssl_certificate /etc/nginx/certs/server.crt; + ssl_certificate_key /etc/nginx/certs/server.key; + location / { + return 200 "hello from tls\n"; + } + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..dc245e21e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,38 @@ +# Checkmate Documentation + +Welcome to the Checkmate documentation. This directory contains guides and references for various aspects of Checkmate deployment and configuration. + +## Available Documentation + +### Deployment & Configuration +- **[Custom CA Trust Guide](./custom-ca-trust.md)** - Configure Checkmate to trust custom Certificate Authorities +- **[Custom CA Quick Reference](./custom-ca-quick-reference.md)** - Quick setup guide for custom CA trust + +## Docker Configuration + +### Custom CA Trust +If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our custom CA trust documentation: + +- **Full Guide**: [Custom CA Trust Guide](./custom-ca-trust.md) +- **Quick Reference**: [Custom CA Quick Reference](./custom-ca-quick-reference.md) + +### Example Configurations +The `docker/dev/` directory contains example configurations: +- `docker-compose.custom-ca-example.yaml` - Example Docker Compose with custom CA +- `server-custom-ca.Dockerfile` - Example Dockerfile for OS-level CA trust +- `export-smallstep-ca.sh` - Helper script for Smallstep CA export + +## Contributing to Documentation + +If you find issues or want to improve the documentation: + +1. Check existing issues and discussions +2. Submit a pull request with your changes +3. Follow the same markdown formatting style +4. Include practical examples and code snippets + +## Getting Help + +- [GitHub Discussions](https://github.com/bluewave-labs/checkmate/discussions) +- [Discord Channel](https://discord.gg/NAb6H3UTjK) +- [Documentation Portal](https://docs.checkmate.so/) diff --git a/docs/custom-ca-quick-reference.md b/docs/custom-ca-quick-reference.md new file mode 100644 index 000000000..71ea3b954 --- /dev/null +++ b/docs/custom-ca-quick-reference.md @@ -0,0 +1,60 @@ +# Custom CA Trust - Quick Reference + +## Fast Setup (Node-level) + +1. **Export your CA certificate:** + ```bash + # For Smallstep + step certificate inspect --format pem step-ca/root_ca.crt > docker/dev/certs/custom-ca.pem + ``` + +2. **Use the provided example override:** + ```bash + docker-compose -f docker-compose.yaml -f docker-compose.custom-ca-example.yaml up -d + ``` + +3. **Or manually update docker-compose.yaml:** + ```yaml + services: + server: + environment: + NODE_EXTRA_CA_CERTS: /certs/custom-ca.pem + volumes: + - ./certs:/certs:ro + ``` + +## Alternative Setup (OS-level) + +1. **Use custom Dockerfile:** + ```bash + docker-compose -f docker-compose.yaml -f docker-compose.custom-ca.yaml up + ``` + +## File Structure +``` +docker/dev/ +├── docker-compose.yaml +├── docker-compose.custom-ca-example.yaml # Example config +├── certs/ +│ ├── README.md +│ └── custom-ca.pem # Your CA certificate +└── export-smallstep-ca.sh # Helper script +``` + +## Environment Variables +- `NODE_EXTRA_CA_CERTS=/certs/custom-ca.pem` - Node.js trust +- Mount `./certs:/certs:ro` - Volume mount + +## Security +- ✅ Only trust CAs you control +- ✅ Use read-only volume mounts +- ✅ Keep certificates out of version control +- ❌ Never trust untrusted CAs + +## Troubleshooting +- Check container logs: `docker-compose logs server` +- Verify certificate: `docker exec -it ls -la /certs/` +- Test connection: `docker exec -it wget --ca-certificate=/certs/custom-ca.pem https://your-internal-site.com` + +## Full Documentation +See [Custom CA Trust Guide](./custom-ca-trust.md) for detailed instructions. diff --git a/docs/custom-ca-trust.md b/docs/custom-ca-trust.md new file mode 100644 index 000000000..b3a19831f --- /dev/null +++ b/docs/custom-ca-trust.md @@ -0,0 +1,234 @@ +# Custom Certificate Authority Trust + +This guide explains how to configure Checkmate to trust custom Certificate Authorities (CAs) when running in Docker, particularly useful for internal/private CAs like Smallstep. + +## Understanding Certificate Authorities + +Certificate Authorities (CAs) are entities that issue and manage digital certificates. While public CAs (like Let's Encrypt, DigiCert) are trusted by default in most systems, private or internal CAs (like those issued by Smallstep, internal PKI systems) require explicit trust configuration. + +When Checkmate monitors HTTPS endpoints with certificates from private CAs, it may show them as "DOWN" due to certificate validation failures, even if the service is actually accessible. + +## Node-level Trust Approach + +The simplest approach is to mount your custom CA certificate and configure Node.js to trust it using the `NODE_EXTRA_CA_CERTS` environment variable. + +### Docker Compose Configuration + +Add a volume mount for your CA certificate and set the environment variable: + +```yaml +services: + server: + image: uptime_server:latest + restart: always + ports: + - "52345:52345" + env_file: + - server.env + environment: + NODE_EXTRA_CA_CERTS: /certs/custom-ca.pem + volumes: + - ./certs:/certs:ro + depends_on: + - redis + - mongodb +``` + +### Directory Structure + +Create a `certs` directory in your Docker Compose project root: + +``` +docker/dev/ +├── docker-compose.yaml +├── certs/ +│ └── custom-ca.pem +└── ... +``` + +## OS-level Trust Approach (Debian-based) + +For more comprehensive trust configuration, you can create a derived Dockerfile that installs your CA at the OS level. + +### Custom Dockerfile + +Create `docker/dev/server-custom-ca.Dockerfile`: + +```dockerfile +FROM node:20-alpine + +# Install ca-certificates for Alpine +RUN apk add --no-cache ca-certificates + +WORKDIR /app + +COPY ./server/package*.json ./ + +RUN npm install + +COPY ./server/ ./ + +# Copy your custom CA certificate +COPY ./certs/custom-ca.crt /usr/local/share/ca-certificates/ + +# Update CA certificates +RUN update-ca-certificates + +EXPOSE 52345 + +CMD ["node", "src/index.js"] +``` + +### Docker Compose Override + +Create `docker/dev/docker-compose.custom-ca.yaml`: + +```yaml +services: + server: + build: + context: . + dockerfile: server-custom-ca.Dockerfile + restart: always + ports: + - "52345:52345" + env_file: + - server.env + depends_on: + - redis + - mongodb +``` + +Run with: `docker-compose -f docker-compose.yaml -f docker-compose.custom-ca.yaml up` + +## Alpine Linux Considerations + +Since Checkmate uses Alpine Linux as the base image, you need to install the `ca-certificates` package: + +```dockerfile +# Install ca-certificates for Alpine +RUN apk add --no-cache ca-certificates + +# Copy and update CA certificates +COPY ./certs/custom-ca.crt /usr/local/share/ca-certificates/ +RUN update-ca-certificates +``` + +## Smallstep CA Configuration + +If you're using Smallstep as your internal CA, you can export the root CA certificate: + +### Export Smallstep Root CA + +```bash +# Export the root CA certificate +step certificate inspect --format pem step-ca/root_ca.crt > custom-ca.pem + +# Or if you have the CA URL configured +step certificate inspect --format pem $(step path)/certs/root_ca.crt > custom-ca.pem +``` + +### Using the Exported Certificate + +1. Copy the exported `custom-ca.pem` to your `docker/dev/certs/` directory +2. Use either the Node-level or OS-level approach above +3. Restart your Checkmate server container + +## Security Considerations + +⚠️ **Important Security Warning**: Only trust CAs that you control or explicitly trust. Adding untrusted CAs can compromise the security of your monitoring system. + +- **Private CAs**: Only trust CAs from your organization's PKI infrastructure +- **Self-signed certificates**: Consider using proper CA infrastructure instead +- **Certificate validation**: Ensure your CA certificates are valid and not expired +- **Access control**: Limit access to the CA certificate files in production + +## Troubleshooting + +### Verify CA Trust + +Test if your CA is trusted by the container: + +```bash +# Enter the running container +docker exec -it sh + +# Check if the CA is in the trust store +ls -la /usr/local/share/ca-certificates/ +cat /etc/ssl/certs/ca-certificates.crt | grep -A 5 -B 5 "YOUR_CA_NAME" +``` + +### Common Issues + +1. **Permission denied**: Ensure the CA certificate file has proper read permissions +2. **Certificate format**: Use PEM format (.pem, .crt) for best compatibility +3. **Container restart**: Always restart the container after adding new CA certificates +4. **Path issues**: Verify the certificate path in your volume mounts + +## Example: Complete Working Setup + +Here's a complete example for a Smallstep CA setup. This demonstrates both the baseline failure (expected) and the custom CA success: + +### Baseline Test (Should Fail) +1. **Start Checkmate without custom CA trust:** + ```bash + cd docker/dev + docker-compose up -d + ``` + +2. **Test connection to internal HTTPS endpoint:** + ```bash + # This should fail with TLS error (unknown CA) + docker-compose exec server node -e " + const https = require('https'); + https.get('https://your-internal-site.com', res => { + console.log('STATUS:', res.statusCode); + }).on('error', e => { + console.error('ERR:', e.message); + }); + " + ``` + **Expected result**: TLS error due to unknown CA + +### Custom CA Test (Should Succeed) +1. **Export Smallstep Root CA:** + ```bash + step certificate inspect --format pem step-ca/root_ca.crt > docker/dev/certs/smallstep-root-ca.pem + ``` + +2. **Update docker-compose.yaml with custom CA trust:** + ```yaml + services: + server: + environment: + NODE_EXTRA_CA_CERTS: /certs/smallstep-root-ca.pem + volumes: + - ./certs:/certs:ro + ``` + +3. **Restart with custom CA:** + ```bash + docker-compose down + docker-compose -f docker-compose.yaml -f docker-compose.custom-ca-example.yaml up -d + ``` + +4. **Test the same connection:** + ```bash + # This should now succeed + docker-compose exec server node -e " + const https = require('https'); + https.get('https://your-internal-site.com', res => { + console.log('STATUS:', res.statusCode); + }).on('error', e => { + console.error('ERR:', e.message); + }); + " + ``` + **Expected result**: HTTP 200 OK + +### Verification +- **Baseline**: TLS failure proves default behavior (unknown CA) +- **Custom CA**: TLS success proves custom CA trust is working +- **Both tests must pass** to confirm the feature works correctly + +Your Checkmate server should now trust certificates issued by your Smallstep CA, allowing you to monitor internal HTTPS endpoints without disabling SSL validation. diff --git a/scripts/dev/setup-custom-ca.sh b/scripts/dev/setup-custom-ca.sh new file mode 100755 index 000000000..699f70bbd --- /dev/null +++ b/scripts/dev/setup-custom-ca.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash + +# Dev/Test only: Not required in production +# This script generates test certificates for development and testing purposes + +set -euo pipefail + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CERTS_DIR="$REPO_ROOT/docker/dev/certs" + +# Function to print colored output +print_status() { + local status=$1 + local message=$2 + case $status in + "PASS") + echo -e "${GREEN}[PASS]${NC} $message" + ;; + "INFO") + echo -e "${BLUE}[INFO]${NC} $message" + ;; + "WARN") + echo -e "${YELLOW}[WARN]${NC} $message" + ;; + esac +} + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to generate certificates using mkcert +generate_certs_mkcert() { + print_status "INFO" "Generating certificates using mkcert..." + + # Install mkcert CA (ignore if already done) + mkcert -install 2>/dev/null || true + + # Copy root CA + local ca_root + ca_root=$(mkcert -CAROOT) + cp "$ca_root/rootCA.pem" "$CERTS_DIR/custom-ca.pem" + + # Generate server certificate + mkcert -key-file "$CERTS_DIR/host-int-key.pem" \ + -cert-file "$CERTS_DIR/host-int-cert.pem" \ + host.docker.internal + + print_status "PASS" "Certificates generated using mkcert" +} + +# Function to generate certificates using OpenSSL +generate_certs_openssl() { + print_status "INFO" "Generating certificates using OpenSSL..." + + # Create CA private key + openssl genrsa -out "$CERTS_DIR/ca.key" 2048 + + # Create CA certificate + openssl req -new -x509 -days 365 -key "$CERTS_DIR/ca.key" \ + -out "$CERTS_DIR/custom-ca.pem" \ + -subj "/C=US/ST=Test/L=Test/O=Test CA/CN=Test Root CA" + + # Create server private key + openssl genrsa -out "$CERTS_DIR/host-int-key.pem" 2048 + + # Create server certificate signing request + openssl req -new -key "$CERTS_DIR/host-int-key.pem" \ + -out "$CERTS_DIR/host-int-cert.csr" \ + -subj "/C=US/ST=Test/L=Test/O=Test/CN=host.docker.internal" + + # Create extfile for SAN + cat > "$CERTS_DIR/san.ext" << EOF +subjectAltName=DNS:host.docker.internal,IP:127.0.0.1 +EOF + + # Sign server certificate with CA + openssl x509 -req -days 365 \ + -in "$CERTS_DIR/host-int-cert.csr" \ + -CA "$CERTS_DIR/custom-ca.pem" \ + -CAkey "$CERTS_DIR/ca.key" \ + -CAcreateserial \ + -out "$CERTS_DIR/host-int-cert.pem" \ + -extfile "$CERTS_DIR/san.ext" + + # Clean up temporary files + rm -f "$CERTS_DIR/ca.key" "$CERTS_DIR/host-int-cert.csr" "$CERTS_DIR/san.ext" "$CERTS_DIR/.srl" + + print_status "PASS" "Certificates generated using OpenSSL" +} + +# Main function +main() { + print_status "INFO" "Setting up custom CA certificates for Checkmate testing" + echo "================================================================" + + # Create certs directory if missing + print_status "INFO" "Creating certificates directory..." + mkdir -p "$CERTS_DIR" + + # Generate certificates + if command_exists mkcert; then + generate_certs_mkcert + else + print_status "WARN" "mkcert not found, falling back to OpenSSL" + generate_certs_openssl + fi + + # Create duplicate for compatibility with existing overrides + print_status "INFO" "Creating duplicate CA file for compatibility..." + cp "$CERTS_DIR/custom-ca.pem" "$CERTS_DIR/smallstep-root-ca.pem" + + # Verify certificates exist + if [ ! -f "$CERTS_DIR/custom-ca.pem" ] || [ ! -f "$CERTS_DIR/host-int-cert.pem" ] || [ ! -f "$CERTS_DIR/host-int-key.pem" ]; then + echo "Error: Failed to generate required certificates" + exit 1 + fi + + # Print summary + echo "" + print_status "PASS" "All required certificates generated successfully" + echo "" + echo "Certificate summary:" + echo "====================" + ls -l "$CERTS_DIR" + echo "" + echo "CA certificate preview:" + echo "======================" + head -3 "$CERTS_DIR/custom-ca.pem" + echo "" + print_status "INFO" "Certificates are ready for use with Checkmate custom CA trust" +} + +# Run main function +main "$@" diff --git a/scripts/test-custom-ca.sh b/scripts/test-custom-ca.sh new file mode 100755 index 000000000..bdf5b62c7 --- /dev/null +++ b/scripts/test-custom-ca.sh @@ -0,0 +1,454 @@ +#!/usr/bin/env bash + +# Dev/Test only: Not required in production +# This script tests the custom CA trust functionality in development environment + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEV_DIR="${ROOT_DIR}/docker/dev" +CERT_DIR="${DEV_DIR}/certs" +NGINX_DIR="${DEV_DIR}/nginx-test" +COMPOSE_BASELINE="${DEV_DIR}/docker-compose.yaml" +COMPOSE_CUSTOM_CA="${DEV_DIR}/docker-compose.custom-ca-example.yaml" +COMPOSE_NGINX_TEST="${NGINX_DIR}/docker-compose.nginx-test.yaml" + +# Test configuration +NGINX_PORT=8443 +CHECKMATE_PORT=52345 +HEALTH_ENDPOINT="http://localhost:$CHECKMATE_PORT/api/v1/health" +TEST_URL="https://host.docker.internal:$NGINX_PORT" +MAX_WAIT=60 +WAIT_INTERVAL=2 + +# Global flag for cleanup behavior +CLEAN_CERTS=false + +# Function to print colored output +print_status() { + local status=$1 + local message=$2 + case $status in + "PASS") + echo -e "${GREEN}[PASS]${NC} $message" + ;; + "FAIL") + echo -e "${RED}[FAIL]${NC} $message" + ;; + "INFO") + echo -e "${BLUE}[INFO]${NC} $message" + ;; + "WARN") + echo -e "${YELLOW}[WARN]${NC} $message" + ;; + esac +} + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to check if certificates exist and are valid +check_certificates() { + local ca_file="${CERT_DIR}/custom-ca.pem" + local cert_file="${CERT_DIR}/host-int-cert.pem" + local key_file="${CERT_DIR}/host-int-key.pem" + + if [ ! -f "$ca_file" ] || [ ! -f "$cert_file" ] || [ ! -f "$key_file" ]; then + return 1 + fi + + # Check if files are not empty + if [ ! -s "$ca_file" ] || [ ! -s "$cert_file" ] || [ ! -s "$key_file" ]; then + return 1 + fi + + # Check if CA file starts with BEGIN CERTIFICATE + if ! head -1 "$ca_file" | grep -q "BEGIN CERTIFICATE"; then + return 1 + fi + + return 0 +} + +# Function to wait for service to be ready +wait_for_service() { + local url=$1 + local service_name=$2 + local elapsed=0 + + print_status "INFO" "Waiting for $service_name to be ready..." + + while [ $elapsed -lt $MAX_WAIT ]; do + if curl -s -f "$url" >/dev/null 2>&1; then + print_status "PASS" "$service_name is ready after ${elapsed}s" + return 0 + fi + + sleep $WAIT_INTERVAL + elapsed=$((elapsed + WAIT_INTERVAL)) + echo -n "." + done + + print_status "FAIL" "$service_name failed to start within ${MAX_WAIT}s" + return 1 +} + +# Function to get container name by image +get_container_name() { + local image_name=$1 + docker ps --filter "ancestor=$image_name" --format "{{.Names}}" | head -n1 +} + +# Function to run Node.js probe test +run_probe_test() { + local container_name=$1 + local test_name=$2 + local expected_exit_code=$3 + + print_status "INFO" "Running $test_name probe test..." + + local probe_script=" +const https = require('https'); +https.get('https://host.docker.internal:8443', res => { + console.log('STATUS', res.statusCode); + process.exit(res.statusCode===200?0:1); +}).on('error', e => { + console.error('ERR', e.code||e.message); + process.exit(1); +}); +" + + local exit_code + if docker exec -i "$container_name" node -e "$probe_script" 2>/dev/null; then + exit_code=$? + else + exit_code=$? + fi + + if [ $exit_code -eq $expected_exit_code ]; then + print_status "PASS" "$test_name probe test completed with expected exit code $expected_exit_code" + return 0 + else + print_status "FAIL" "$test_name probe test failed with exit code $exit_code (expected $expected_exit_code)" + return 1 + fi +} + +# Function to setup certificates if needed +setup_certificates_if_needed() { + if check_certificates; then + print_status "INFO" "Certificates already exist and are valid, skipping generation" + return 0 + fi + + print_status "INFO" "Certificates missing or invalid, generating new ones..." + if [ -x "${ROOT_DIR}/scripts/dev/setup-custom-ca.sh" ]; then + "${ROOT_DIR}/scripts/dev/setup-custom-ca.sh" + else + print_status "FAIL" "Certificate setup script not found at scripts/dev/setup-custom-ca.sh" + return 1 + fi +} + +# Function to create nginx test configuration +create_nginx_test_config() { + print_status "INFO" "Setting up nginx test configuration..." + + mkdir -p "$NGINX_DIR" + + # Create nginx.conf if it doesn't exist + if [ ! -f "${NGINX_DIR}/nginx.conf" ]; then + cat > "${NGINX_DIR}/nginx.conf" << 'EOF' +events {} +http { + server { + listen 443 ssl; + ssl_certificate /etc/nginx/certs/server.crt; + ssl_certificate_key /etc/nginx/certs/server.key; + location / { + return 200 "hello from tls\n"; + } + } +} +EOF + fi + + # Create docker-compose.nginx-test.yaml if it doesn't exist + if [ ! -f "$COMPOSE_NGINX_TEST" ]; then + cat > "$COMPOSE_NGINX_TEST" << EOF +services: + nginx-test: + image: nginx:alpine + ports: + - "$NGINX_PORT:443" + volumes: + - ../certs/host-int-cert.pem:/etc/nginx/certs/server.crt:ro + - ../certs/host-int-key.pem:/etc/nginx/certs/server.key:ro + - ./nginx.conf:/etc/nginx/nginx.conf:ro + restart: unless-stopped +EOF + fi + + print_status "PASS" "Nginx test configuration created" +} + +# Function to start nginx test service +start_nginx_test() { + print_status "INFO" "Starting nginx test service..." + + cd "$NGINX_DIR" + docker-compose -f docker-compose.nginx-test.yaml up -d + + # Wait for nginx to be ready + local elapsed=0 + while [ $elapsed -lt $MAX_WAIT ]; do + if curl -s -f -k "https://localhost:$NGINX_PORT" >/dev/null 2>&1; then + print_status "PASS" "Nginx test service is ready" + cd "$ROOT_DIR" + return 0 + fi + + sleep $WAIT_INTERVAL + elapsed=$((elapsed + WAIT_INTERVAL)) + echo -n "." + done + + print_status "FAIL" "Nginx test service failed to start" + cd "$ROOT_DIR" + return 1 +} + +# Function to stop nginx test service +stop_nginx_test() { + print_status "INFO" "Stopping nginx test service..." + cd "$NGINX_DIR" + docker-compose -f docker-compose.nginx-test.yaml down 2>/dev/null || true + cd "$ROOT_DIR" +} + +# Function to run baseline test +run_baseline_test() { + print_status "INFO" "Running baseline test (should fail due to unknown CA)..." + + # Start baseline Checkmate + cd "$DEV_DIR" + docker-compose -f docker-compose.yaml up -d --build + + # Wait for Checkmate to be ready + if ! wait_for_service "$HEALTH_ENDPOINT" "Checkmate baseline"; then + cd "$ROOT_DIR" + return 1 + fi + + # Get container name + local container_name + container_name=$(get_container_name "uptime_server") + if [ -z "$container_name" ]; then + print_status "FAIL" "Could not find Checkmate server container" + cd "$ROOT_DIR" + return 1 + fi + + # Run probe test (should fail) + if run_probe_test "$container_name" "baseline" 1; then + print_status "PASS" "Baseline test completed - TLS failure as expected" + cd "$ROOT_DIR" + return 0 + else + print_status "FAIL" "Baseline test failed - unexpected behavior" + cd "$ROOT_DIR" + return 1 + fi +} + +# Function to run custom CA test +run_custom_ca_test() { + print_status "INFO" "Running custom CA test (should succeed)..." + + # Stop baseline Checkmate + cd "$DEV_DIR" + docker-compose -f docker-compose.yaml down + + # Start Checkmate with custom CA + docker-compose -f docker-compose.yaml -f docker-compose.custom-ca-example.yaml up -d --build + + # Wait for Checkmate to be ready + if ! wait_for_service "$HEALTH_ENDPOINT" "Checkmate custom CA"; then + cd "$ROOT_DIR" + return 1 + fi + + # Get container name + local container_name + container_name=$(get_container_name "uptime_server") + if [ -z "$container_name" ]; then + print_status "FAIL" "Could not find Checkmate server container" + cd "$ROOT_DIR" + return 1 + fi + + # Run probe test (should succeed) + if run_probe_test "$container_name" "custom CA" 0; then + print_status "PASS" "Custom CA test completed - TLS success as expected" + cd "$ROOT_DIR" + return 0 + else + print_status "FAIL" "Custom CA test failed - unexpected behavior" + cd "$ROOT_DIR" + return 1 + fi +} + +# Function to cleanup everything +cleanup() { + print_status "INFO" "Cleaning up test environment..." + + # Stop all services + cd "$DEV_DIR" + docker-compose -f docker-compose.yaml down 2>/dev/null || true + docker-compose -f docker-compose.yaml -f docker-compose.custom-ca-example.yaml down 2>/dev/null || true + + stop_nginx_test + + # Only remove certificates if --clean was specified + if [ "$CLEAN_CERTS" = true ]; then + print_status "INFO" "Removing certificates as requested with --clean" + rm -rf "${CERT_DIR:?}"/* + else + print_status "INFO" "Preserving certificates for future test runs" + fi + + cd "$ROOT_DIR" + print_status "PASS" "Cleanup completed" +} + +# Function to show usage +show_usage() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --clean Clean up all test containers and certificates" + echo " --help Show this help message" + echo "" + echo "This script tests the custom CA trust functionality in Checkmate." +} + +# Main function +main() { + local clean_only=false + + # Parse command line arguments + while [[ $# -gt 0 ]]; do + case $1 in + --clean) + clean_only=true + CLEAN_CERTS=true + shift + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + show_usage + exit 1 + ;; + esac + done + + print_status "INFO" "Starting Checkmate Custom CA Trust Test" + echo "==================================================" + + if [ "$clean_only" = true ]; then + cleanup + exit 0 + fi + + # Check prerequisites + if ! command_exists docker; then + print_status "FAIL" "Docker is not installed or not in PATH" + exit 1 + fi + + if ! command_exists docker-compose; then + print_status "FAIL" "docker-compose is not installed or not in PATH" + exit 1 + fi + + # Check if required files exist + if [ ! -f "$COMPOSE_BASELINE" ]; then + print_status "FAIL" "Baseline docker-compose.yaml not found at $COMPOSE_BASELINE" + exit 1 + fi + + if [ ! -f "$COMPOSE_CUSTOM_CA" ]; then + print_status "FAIL" "Custom CA docker-compose override not found at $COMPOSE_CUSTOM_CA" + exit 1 + fi + + # Setup test environment + setup_certificates_if_needed + create_nginx_test_config + start_nginx_test + + # Run tests + local baseline_result=false + local custom_ca_result=false + + if run_baseline_test; then + baseline_result=true + fi + + if run_custom_ca_test; then + custom_ca_result=true + fi + + # Print summary + echo "" + echo "==================================================" + print_status "INFO" "Test Summary" + echo "==================================================" + + if [ "$baseline_result" = true ]; then + print_status "PASS" "Baseline: TLS failure as expected" + else + print_status "FAIL" "Baseline: Unexpected behavior" + fi + + if [ "$custom_ca_result" = true ]; then + print_status "PASS" "Custom CA: TLS success (STATUS 200)" + else + print_status "FAIL" "Custom CA: Unexpected behavior" + fi + + if [ "$baseline_result" = true ] && [ "$custom_ca_result" = true ]; then + echo "" + print_status "PASS" "All tests passed! Custom CA trust is working correctly." + echo "" + print_status "INFO" "To clean up, run: $0 --clean" + exit 0 + else + echo "" + print_status "FAIL" "Some tests failed. Custom CA trust may not be working correctly." + echo "" + print_status "INFO" "To clean up, run: $0 --clean" + exit 1 + fi +} + +# Trap cleanup on script exit +trap cleanup EXIT + +# Run main function +main "$@" From 096cfccf40be333a0d7f01251fa4dddeef903ebc Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 13 Aug 2025 15:16:01 -0700 Subject: [PATCH 221/226] update dokcer compose files to remove mongo replica set config --- docker/dev/docker-compose.yaml | 6 +++--- docker/dist-arm/docker-compose.yaml | 28 ++++++++-------------------- docker/dist-mono/docker-compose.yaml | 6 +++--- docker/dist/docker-compose.yaml | 6 +++--- 4 files changed, 17 insertions(+), 29 deletions(-) diff --git a/docker/dev/docker-compose.yaml b/docker/dev/docker-compose.yaml index f0b193b2d..8f897fa64 100755 --- a/docker/dev/docker-compose.yaml +++ b/docker/dev/docker-compose.yaml @@ -35,13 +35,13 @@ services: mongodb: image: uptime_mongo:latest restart: always - command: ["mongod", "--quiet", "--replSet", "rs0", "--bind_ip_all"] + command: ["mongod", "--quiet", "--bind_ip_all"] volumes: - ./mongo/data:/data/db healthcheck: - test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) }" | mongosh --port 27017 --quiet + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')", "--quiet"] interval: 5s timeout: 30s start_period: 0s start_interval: 1s - retries: 30 \ No newline at end of file + retries: 30 diff --git a/docker/dist-arm/docker-compose.yaml b/docker/dist-arm/docker-compose.yaml index 921d3b8c1..37a5f4636 100644 --- a/docker/dist-arm/docker-compose.yaml +++ b/docker/dist-arm/docker-compose.yaml @@ -7,7 +7,7 @@ services: environment: - UPTIME_APP_API_BASE_URL=http://localhost:52345/api/v1 - UPTIME_APP_CLIENT_HOST=http://localhost - - DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db?replicaSet=rs0 + - DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db - REDIS_URL=redis://redis:6379 - CLIENT_HOST=http://localhost - JWT_SECRET=my_secret @@ -22,22 +22,10 @@ services: - "27017:27017" volumes: - ./mongo/data:/data/db - - mongo-init: - image: mongo:4.4.18 - depends_on: - - mongodb - entrypoint: > - bash -c " - echo 'Waiting for MongoDB to be ready...' && - until mongo --host mongodb --eval 'db.adminCommand(\"ping\")' > /dev/null 2>&1; do - sleep 2 - done && - echo 'MongoDB is up. Initiating replica set...' && - mongo --host mongodb --eval ' - rs.initiate({ - _id: \"rs0\", - members: [{ _id: 0, host: \"mongodb:27017\" }] - }) - ' || echo 'Replica set may already be initialized.' - " + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')", "--quiet"] + interval: 5s + timeout: 30s + start_period: 0s + start_interval: 1s + retries: 30 diff --git a/docker/dist-mono/docker-compose.yaml b/docker/dist-mono/docker-compose.yaml index 0dd97aafb..342e59c96 100755 --- a/docker/dist-mono/docker-compose.yaml +++ b/docker/dist-mono/docker-compose.yaml @@ -7,7 +7,7 @@ services: environment: - UPTIME_APP_API_BASE_URL=http://localhost:52345/api/v1 - UPTIME_APP_CLIENT_HOST=http://localhost - - DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db?replicaSet=rs0 + - DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db - REDIS_URL=redis://redis:6379 - CLIENT_HOST=http://localhost - JWT_SECRET=my_secret @@ -16,11 +16,11 @@ services: mongodb: image: ghcr.io/bluewave-labs/checkmate-mongo:latest restart: always - command: ["mongod", "--quiet", "--replSet", "rs0", "--bind_ip_all"] + command: ["mongod", "--quiet", "--bind_ip_all"] volumes: - ./mongo/data:/data/db healthcheck: - test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) }" | mongosh --port 27017 --quiet + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')", "--quiet"] interval: 5s timeout: 30s start_period: 0s diff --git a/docker/dist/docker-compose.yaml b/docker/dist/docker-compose.yaml index ff2f1b7ea..980c727f6 100755 --- a/docker/dist/docker-compose.yaml +++ b/docker/dist/docker-compose.yaml @@ -18,7 +18,7 @@ services: depends_on: - mongodb environment: - - DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db?replicaSet=rs0 + - DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db - REDIS_URL=redis://redis:6379 - CLIENT_HOST=http://localhost - JWT_SECRET=my_secret @@ -29,9 +29,9 @@ services: restart: always volumes: - ./mongo/data:/data/db - command: ["mongod", "--quiet", "--replSet", "rs0", "--bind_ip_all"] + command: ["mongod", "--quiet", "--bind_ip_all"] healthcheck: - test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) }" | mongosh --port 27017 --quiet + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')", "--quiet"] interval: 5s timeout: 30s start_period: 0s From d5526b89d9093a04ebc87b5b12fa7a09665d39bf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 13 Aug 2025 15:22:03 -0700 Subject: [PATCH 222/226] remove redis references --- docker/dev/docker-compose.yaml | 12 ------------ docker/dev/redis.Dockerfile | 2 -- docker/dist-mono/build_images.sh | 1 - docker/dist-mono/docker-compose.yaml | 1 - docker/dist-mono/redis.Dockerfile | 2 -- docker/dist/build_images.sh | 1 - docker/dist/docker-compose.yaml | 1 - docker/dist/redis.Dockerfile | 2 -- docker/prod/build_images.sh | 1 - docker/prod/docker-compose.yaml | 5 ++--- docker/prod/redis.Dockerfile | 2 -- docker/staging/build_images.sh | 1 - docker/staging/docker-compose.yaml | 19 ++----------------- docker/staging/redis.Dockerfile | 2 -- 14 files changed, 4 insertions(+), 48 deletions(-) delete mode 100755 docker/dev/redis.Dockerfile delete mode 100755 docker/dist-mono/redis.Dockerfile delete mode 100755 docker/dist/redis.Dockerfile delete mode 100755 docker/prod/redis.Dockerfile delete mode 100755 docker/staging/redis.Dockerfile diff --git a/docker/dev/docker-compose.yaml b/docker/dev/docker-compose.yaml index 8f897fa64..a2c66c027 100755 --- a/docker/dev/docker-compose.yaml +++ b/docker/dev/docker-compose.yaml @@ -19,19 +19,7 @@ services: env_file: - server.env depends_on: - - redis - mongodb - redis: - image: uptime_redis:latest - restart: always - volumes: - - ./redis/data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 5s mongodb: image: uptime_mongo:latest restart: always diff --git a/docker/dev/redis.Dockerfile b/docker/dev/redis.Dockerfile deleted file mode 100755 index af68ec61e..000000000 --- a/docker/dev/redis.Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM redis -EXPOSE 6379 \ No newline at end of file diff --git a/docker/dist-mono/build_images.sh b/docker/dist-mono/build_images.sh index b56b9052c..fd3a163cc 100755 --- a/docker/dist-mono/build_images.sh +++ b/docker/dist-mono/build_images.sh @@ -8,7 +8,6 @@ cd ../.. services=("mono_mongo" "mono_redis" "mono_server") dockerfiles=( "./docker/dist-mono/mongoDB.Dockerfile" - "./docker/dist-mono/redis.Dockerfile" "./docker/dist-mono/server.Dockerfile" ) diff --git a/docker/dist-mono/docker-compose.yaml b/docker/dist-mono/docker-compose.yaml index 342e59c96..0e0b49445 100755 --- a/docker/dist-mono/docker-compose.yaml +++ b/docker/dist-mono/docker-compose.yaml @@ -8,7 +8,6 @@ services: - UPTIME_APP_API_BASE_URL=http://localhost:52345/api/v1 - UPTIME_APP_CLIENT_HOST=http://localhost - DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db - - REDIS_URL=redis://redis:6379 - CLIENT_HOST=http://localhost - JWT_SECRET=my_secret depends_on: diff --git a/docker/dist-mono/redis.Dockerfile b/docker/dist-mono/redis.Dockerfile deleted file mode 100755 index af68ec61e..000000000 --- a/docker/dist-mono/redis.Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM redis -EXPOSE 6379 \ No newline at end of file diff --git a/docker/dist/build_images.sh b/docker/dist/build_images.sh index 953682175..84fbb7d5a 100755 --- a/docker/dist/build_images.sh +++ b/docker/dist/build_images.sh @@ -7,7 +7,6 @@ cd ../../ declare -A services=( ["bluewaveuptime/uptime_client"]="./docker/dist/client.Dockerfile" ["bluewaveuptime/uptime_database_mongo"]="./docker/dist/mongoDB.Dockerfile" - ["bluewaveuptime/uptime_redis"]="./docker/dist/redis.Dockerfile" ["bluewaveuptime/uptime_server"]="./docker/dist/server.Dockerfile" ) diff --git a/docker/dist/docker-compose.yaml b/docker/dist/docker-compose.yaml index 980c727f6..2e845daab 100755 --- a/docker/dist/docker-compose.yaml +++ b/docker/dist/docker-compose.yaml @@ -19,7 +19,6 @@ services: - mongodb environment: - DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db - - REDIS_URL=redis://redis:6379 - CLIENT_HOST=http://localhost - JWT_SECRET=my_secret # volumes: diff --git a/docker/dist/redis.Dockerfile b/docker/dist/redis.Dockerfile deleted file mode 100755 index af68ec61e..000000000 --- a/docker/dist/redis.Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM redis -EXPOSE 6379 \ No newline at end of file diff --git a/docker/prod/build_images.sh b/docker/prod/build_images.sh index 23e91355c..49f8c7841 100755 --- a/docker/prod/build_images.sh +++ b/docker/prod/build_images.sh @@ -8,7 +8,6 @@ cd ../../ declare -A services=( ["uptime_client"]="./docker/prod/client.Dockerfile" ["uptime_database_mongo"]="./docker/prod/mongoDB.Dockerfile" - ["uptime_redis"]="./docker/prod/redis.Dockerfile" ["uptime_server"]="./docker/prod/server.Dockerfile" ) diff --git a/docker/prod/docker-compose.yaml b/docker/prod/docker-compose.yaml index 980a0e2f5..61c6c021d 100755 --- a/docker/prod/docker-compose.yaml +++ b/docker/prod/docker-compose.yaml @@ -37,14 +37,13 @@ services: mongodb: image: ghcr.io/bluewave-labs/checkmate:mongo-demo restart: always - command: ["mongod", "--quiet", "--replSet", "rs0", "--bind_ip_all"] + command: ["mongod", "--quiet", "--bind_ip_all"] volumes: - ./mongo/data:/data/db - # - ./mongo/init/init.js:/docker-entrypoint-initdb.d/init.js // No longer needed env_file: - mongo.env healthcheck: - test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) }" | mongosh --port 27017 --quiet + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')", "--quiet"] interval: 5s timeout: 30s start_period: 0s diff --git a/docker/prod/redis.Dockerfile b/docker/prod/redis.Dockerfile deleted file mode 100755 index af68ec61e..000000000 --- a/docker/prod/redis.Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM redis -EXPOSE 6379 \ No newline at end of file diff --git a/docker/staging/build_images.sh b/docker/staging/build_images.sh index ccfd451ba..a11aebf7c 100755 --- a/docker/staging/build_images.sh +++ b/docker/staging/build_images.sh @@ -8,7 +8,6 @@ cd ../../ declare -A services=( ["uptime_client"]="./docker/staging/client.Dockerfile" ["uptime_database_mongo"]="./docker/staging/mongoDB.Dockerfile" - ["uptime_redis"]="./docker/staging/redis.Dockerfile" ["uptime_server"]="./docker/staging/server.Dockerfile" ) diff --git a/docker/staging/docker-compose.yaml b/docker/staging/docker-compose.yaml index b9ca52b6e..436c6327f 100755 --- a/docker/staging/docker-compose.yaml +++ b/docker/staging/docker-compose.yaml @@ -31,34 +31,19 @@ services: env_file: - server.env depends_on: - - redis - mongodb - redis: - image: ghcr.io/bluewave-labs/checkmate:redis-staging - restart: always - ports: - - "6379:6379" - volumes: - - ./redis/data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 5s mongodb: image: ghcr.io/bluewave-labs/checkmate:mongo-staging restart: always - command: ["mongod", "--quiet", "--replSet", "rs0", "--bind_ip_all"] + command: ["mongod", "--quiet", "--bind_ip_all"] ports: - "27017:27017" volumes: - ./mongo/data:/data/db - # - ./mongo/init/02_create_users.js:/docker-entrypoint-initdb.d/02_create_users.js // No longer needed env_file: - mongo.env healthcheck: - test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) }" | mongosh --port 27017 --quiet + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')", "--quiet"] interval: 5s timeout: 30s start_period: 0s diff --git a/docker/staging/redis.Dockerfile b/docker/staging/redis.Dockerfile deleted file mode 100755 index af68ec61e..000000000 --- a/docker/staging/redis.Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM redis -EXPOSE 6379 \ No newline at end of file From ce667bc53bb460785f79bd1ea0b6abcb3cdd3075 Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Thu, 14 Aug 2025 15:02:44 +0530 Subject: [PATCH 223/226] Improved code quality --- client/src/Pages/Logs/Logs/index.jsx | 58 ++++++++++++++++++---------- client/src/Pages/Logs/index.jsx | 28 +++++--------- 2 files changed, 46 insertions(+), 40 deletions(-) diff --git a/client/src/Pages/Logs/Logs/index.jsx b/client/src/Pages/Logs/Logs/index.jsx index 29cc55a9b..d9ce2a25c 100644 --- a/client/src/Pages/Logs/Logs/index.jsx +++ b/client/src/Pages/Logs/Logs/index.jsx @@ -50,32 +50,48 @@ const Logs = () => { { _id: "debug", name: t("logsPage.logLevelSelect.values.debug") }, ]; return ( - - + {t("logsPage.description")} - - - {t("logsPage.logLevelSelect.title")} - { + setLogLevel(e.target.value); + }} + /> { }; const BREADCRUMBS = [{ name: t("logsPage.title"), path: "/logs" }]; - return ( - - - - - - - - - - {/* Main content */} + + + + {value === 0 && } {value === 1 && } {value === 2 && } From a1cbbf5d426dbdf01eaa383f76e19d40fd8de102 Mon Sep 17 00:00:00 2001 From: singh-kanwarpreet Date: Thu, 14 Aug 2025 15:06:01 +0530 Subject: [PATCH 224/226] Added zIndex as earlier causes problem in diagnostics --- client/src/Pages/Logs/index.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/Pages/Logs/index.jsx b/client/src/Pages/Logs/index.jsx index 5b9879051..52aa60199 100644 --- a/client/src/Pages/Logs/index.jsx +++ b/client/src/Pages/Logs/index.jsx @@ -33,6 +33,7 @@ const Logs = () => { position: "sticky", top: theme.spacing(0), backdropFilter: "blur(10px)", + zIndex: theme.zIndex.appBar, }} > From ff31ee773fdd87bf22865ee1923a1d3594301e85 Mon Sep 17 00:00:00 2001 From: Owaise Date: Thu, 14 Aug 2025 19:35:12 +0530 Subject: [PATCH 225/226] Added a drop down so that we have choice to make about which network interface to choose. --- .../Components/Charts/Utils/chartUtils.jsx | 2 +- .../Components/NetworkStats/NetworkCharts.jsx | 29 ++++---- .../Details/Components/NetworkStats/index.jsx | 72 +++++++++++++++---- .../Details/Hooks/useHardwareUtils.jsx | 50 ++++++++++--- client/src/locales/en.json | 9 +++ 5 files changed, 124 insertions(+), 38 deletions(-) diff --git a/client/src/Components/Charts/Utils/chartUtils.jsx b/client/src/Components/Charts/Utils/chartUtils.jsx index ce15d27cd..e24881c1c 100644 --- a/client/src/Components/Charts/Utils/chartUtils.jsx +++ b/client/src/Components/Charts/Utils/chartUtils.jsx @@ -109,7 +109,7 @@ export const NetworkTick = ({ x, y, payload, index, formatter }) => { formatter = (value, space = false) => { if (typeof value !== "number") return value; // need to add space between value and unit - return `${(value / 1024).toFixed(2)}${space ? " " : ""}Kbps`; + return `${(value / 1024).toFixed(1)}${space ? " " : ""}Kbps`; }; } diff --git a/client/src/Pages/Infrastructure/Details/Components/NetworkStats/NetworkCharts.jsx b/client/src/Pages/Infrastructure/Details/Components/NetworkStats/NetworkCharts.jsx index 98eced859..9157a3432 100644 --- a/client/src/Pages/Infrastructure/Details/Components/NetworkStats/NetworkCharts.jsx +++ b/client/src/Pages/Infrastructure/Details/Components/NetworkStats/NetworkCharts.jsx @@ -15,7 +15,7 @@ const NetworkCharts = ({ ethernetData, dateRange }) => { const theme = useTheme(); const { t } = useTranslation(); - const { formatBytesString } = useHardwareUtils(); + const { formatBytesPerSecondString, formatPacketsPerSecondString } = useHardwareUtils(); if (!ethernetData?.length) { return {t("noNetworkStatsAvailable")}; @@ -26,19 +26,19 @@ const NetworkCharts = ({ ethernetData, dateRange }) => { type: "network-bytes", data: ethernetData, dataKeys: ["bytesPerSec"], - heading: t("bytesPerSecond"), + heading: t("dataReceived"), strokeColor: theme.palette.info.main, gradientStartColor: theme.palette.info.main, - yLabel: t("bytesPerSecond"), + yLabel: t("rate"), xTick: , - yTick: , + yTick: , toolTip: ( ), }, @@ -46,18 +46,19 @@ const NetworkCharts = ({ ethernetData, dateRange }) => { type: "network-packets", data: ethernetData, dataKeys: ["packetsPerSec"], - heading: t("packetsPerSecond"), + heading: t("packetsReceivedRate"), strokeColor: theme.palette.success.main, gradientStartColor: theme.palette.success.main, - yLabel: t("packetsPerSecond"), + yLabel: t("rate"), xTick: , + yTick: , toolTip: ( Math.round(value).toLocaleString()} + formatter={formatPacketsPerSecondString} /> ), }, @@ -65,7 +66,7 @@ const NetworkCharts = ({ ethernetData, dateRange }) => { type: "network-errors", data: ethernetData, dataKeys: ["errors"], - heading: t("errors"), + heading: t("networkErrors"), strokeColor: theme.palette.error.main, gradientStartColor: theme.palette.error.main, yLabel: t("errors"), @@ -74,7 +75,7 @@ const NetworkCharts = ({ ethernetData, dateRange }) => { Math.round(value).toLocaleString()} /> @@ -84,7 +85,7 @@ const NetworkCharts = ({ ethernetData, dateRange }) => { type: "network-drops", data: ethernetData, dataKeys: ["drops"], - heading: t("drops"), + heading: t("networkDrops"), strokeColor: theme.palette.warning.main, gradientStartColor: theme.palette.warning.main, yLabel: t("drops"), @@ -93,7 +94,7 @@ const NetworkCharts = ({ ethernetData, dateRange }) => { Math.round(value).toLocaleString()} /> diff --git a/client/src/Pages/Infrastructure/Details/Components/NetworkStats/index.jsx b/client/src/Pages/Infrastructure/Details/Components/NetworkStats/index.jsx index 623720aba..227523439 100644 --- a/client/src/Pages/Infrastructure/Details/Components/NetworkStats/index.jsx +++ b/client/src/Pages/Infrastructure/Details/Components/NetworkStats/index.jsx @@ -1,15 +1,21 @@ import PropTypes from "prop-types"; +import { useState, useEffect } from "react"; +import { FormControl, InputLabel, Select, MenuItem, Box } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@emotion/react"; import NetworkStatBoxes from "./NetworkStatBoxes"; import NetworkCharts from "./NetworkCharts"; import MonitorTimeFrameHeader from "../../../../../Components/MonitorTimeFrameHeader"; -const getInterfaceName = (net) => { - const interfaceNames = ["eth0", "Ethernet", "en0"]; - const found = (net || []).find((iface) => interfaceNames.includes(iface.name)); - return found ? found.name : null; +const getAvailableInterfaces = (net) => { + return (net || []).map((iface) => iface.name).filter(Boolean); }; const getNetworkInterfaceData = (checks, ifaceName) => { + if (!ifaceName) return []; + + // Transform backend data structure for the selected interface + // Backend already calculates deltas, we just reshape the data return (checks || []) .map((check) => { const networkInterface = (check.net || []).find( @@ -28,21 +34,63 @@ const getNetworkInterfaceData = (checks, ifaceName) => { }; const Network = ({ net, checks, isLoading, dateRange, setDateRange }) => { - const ifaceName = getInterfaceName(net); - const ethernetData = getNetworkInterfaceData(checks, ifaceName); + const { t } = useTranslation(); + const theme = useTheme(); + + const availableInterfaces = getAvailableInterfaces(net); + const [selectedInterface, setSelectedInterface] = useState(""); + + // Set default interface when data loads + useEffect(() => { + if (availableInterfaces.length > 0 && !selectedInterface) { + setSelectedInterface(availableInterfaces[0]); + } + }, [availableInterfaces, selectedInterface]); + + const ethernetData = getNetworkInterfaceData(checks, selectedInterface); return ( <> - + + {availableInterfaces.length > 0 && ( + + {t("networkInterface")} + + + )} + + { } }; - const formatBytesString = (bytes, space = false) => { + const formatBytesPerSecondString = (bytesPerSec, space = false) => { if ( - bytes === undefined || - bytes === null || - typeof bytes !== "number" || - bytes === 0 + bytesPerSec === undefined || + bytesPerSec === null || + typeof bytesPerSec !== "number" || + bytesPerSec === 0 ) { - return `0${space ? " " : ""}${t("gb")}`; + return `0${space ? " " : ""}B/s`; } - const GB = bytes / (1024 * 1024 * 1024); - const MB = bytes / (1024 * 1024); + const GB = bytesPerSec / (1024 * 1024 * 1024); + const MB = bytesPerSec / (1024 * 1024); + const KB = bytesPerSec / 1024; if (GB >= 1) { - return `${Number(GB.toFixed(2))}${space ? " " : ""}${t("gb")}`; + return `${Number(GB.toFixed(1))}${space ? " " : ""}GB/s`; + } else if (MB >= 1) { + return `${Number(MB.toFixed(1))}${space ? " " : ""}MB/s`; + } else if (KB >= 1) { + return `${Number(KB.toFixed(1))}${space ? " " : ""}KB/s`; } else { - return `${Number(MB.toFixed(2))}${space ? " " : ""}${t("mb")}`; + return `${Number(bytesPerSec.toFixed(1))}${space ? " " : ""}B/s`; + } + }; + + const formatPacketsPerSecondString = (packetsPerSec, space = false) => { + if ( + packetsPerSec === undefined || + packetsPerSec === null || + typeof packetsPerSec !== "number" || + packetsPerSec === 0 + ) { + return `0${space ? " " : ""}pps`; + } + + const M = packetsPerSec / (1000 * 1000); + const K = packetsPerSec / 1000; + + if (M >= 1) { + return `${Number(M.toFixed(1))}${space ? " " : ""}Mpps`; + } else if (K >= 1) { + return `${Number(K.toFixed(1))}${space ? " " : ""}Kpps`; + } else { + return `${Math.round(packetsPerSec)}${space ? " " : ""}pps`; } }; @@ -154,7 +181,8 @@ const useHardwareUtils = () => { decimalToPercentage, buildTemps, getDimensions, - formatBytesString, + formatBytesPerSecondString, + formatPacketsPerSecondString, }; }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 08c60c372..857825c9f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -205,6 +205,10 @@ "bytesPerSecond": "Bytes per second", "bytesReceived": "Bytes Received", "bytesSent": "Bytes Sent", + "dataReceived": "Data Received", + "dataSent": "Data Sent", + "dataRate": "Data Rate", + "rate": "Rate", "bulkImport": { "fallbackPage": "Import a file to upload a list of servers in bulk", "invalidFileType": "Invalid file type", @@ -341,6 +345,10 @@ "errors": "Errors", "errorsIn": "Errors In", "errorsOut": "Errors Out", + "networkErrors": "Network Errors", + "networkDrops": "Network Drops", + "networkInterface": "Network Interface", + "selectInterface": "Select Interface", "details": "Details", "displayName": "Display name", "distributedRightCategoryTitle": "Monitor", @@ -763,6 +771,7 @@ }, "packetsPerSecond": "Packets per second", "packetsReceived": "Packets Received", + "packetsReceivedRate": "Packets Received Rate", "packetsSent": "Packets Sent", "pause": "Pause", "pingMonitoring": "Ping monitoring", From 22e0507fbdac36eaf165ceaf4c82bb9965005949 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 14 Aug 2025 09:34:30 -0700 Subject: [PATCH 226/226] fix arm dist health check --- docker/dist-arm/docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/dist-arm/docker-compose.yaml b/docker/dist-arm/docker-compose.yaml index 37a5f4636..96bbf8f3f 100644 --- a/docker/dist-arm/docker-compose.yaml +++ b/docker/dist-arm/docker-compose.yaml @@ -23,7 +23,7 @@ services: volumes: - ./mongo/data:/data/db healthcheck: - test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')", "--quiet"] + test: ["CMD", "mongo", "--eval", "db.runCommand({ ping: 1 })", "--quiet"] interval: 5s timeout: 30s start_period: 0s