Merge branch 'develop' into feat/incidents-clear

This commit is contained in:
Alex Holliday committed 2025-06-25 09:03:00 +08:00
commit 95b832c1c6
93 files changed
+3104 -2182

No files matched your search

+4
View File
@@ -16,4 +16,8 @@ module.exports = {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"react/no-unescaped-entities": "off",
},
globals: {
__APP_VERSION__: "readonly",
process: "readonly",
},
};
+246 -153
View File
@@ -45,7 +45,7 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.6",
"prettier": "^3.3.3",
"vite": "^5.4.19"
"vite": "6.3.5"
},
"optionalDependencies": {
"@rollup/rollup-linux-arm64-musl": "4.41.0"
@@ -492,9 +492,9 @@
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==",
"cpu": [
"ppc64"
],
@@ -504,13 +504,13 @@
"aix"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz",
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==",
"cpu": [
"arm"
],
@@ -520,13 +520,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz",
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==",
"cpu": [
"arm64"
],
@@ -536,13 +536,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz",
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==",
"cpu": [
"x64"
],
@@ -552,13 +552,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
"cpu": [
"arm64"
],
@@ -568,13 +568,13 @@
"darwin"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz",
"integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==",
"cpu": [
"x64"
],
@@ -584,13 +584,13 @@
"darwin"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz",
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==",
"cpu": [
"arm64"
],
@@ -600,13 +600,13 @@
"freebsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz",
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==",
"cpu": [
"x64"
],
@@ -616,13 +616,13 @@
"freebsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz",
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==",
"cpu": [
"arm"
],
@@ -632,13 +632,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz",
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==",
"cpu": [
"arm64"
],
@@ -648,13 +648,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz",
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==",
"cpu": [
"ia32"
],
@@ -664,13 +664,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz",
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==",
"cpu": [
"loong64"
],
@@ -680,13 +680,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz",
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==",
"cpu": [
"mips64el"
],
@@ -696,13 +696,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz",
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==",
"cpu": [
"ppc64"
],
@@ -712,13 +712,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz",
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==",
"cpu": [
"riscv64"
],
@@ -728,13 +728,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz",
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==",
"cpu": [
"s390x"
],
@@ -744,13 +744,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz",
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==",
"cpu": [
"x64"
],
@@ -760,13 +760,29 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz",
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz",
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==",
"cpu": [
"x64"
],
@@ -776,13 +792,29 @@
"netbsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz",
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz",
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==",
"cpu": [
"x64"
],
@@ -792,13 +824,13 @@
"openbsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==",
"cpu": [
"x64"
],
@@ -808,13 +840,13 @@
"sunos"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz",
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==",
"cpu": [
"arm64"
],
@@ -824,13 +856,13 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz",
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==",
"cpu": [
"ia32"
],
@@ -840,13 +872,13 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz",
"integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==",
"cpu": [
"x64"
],
@@ -856,7 +888,7 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
@@ -3040,9 +3072,9 @@
}
},
"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==",
"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": {
@@ -3172,23 +3204,6 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -3259,6 +3274,15 @@
"node": ">=10"
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3820,41 +3844,43 @@
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
"@esbuild/aix-ppc64": "0.25.5",
"@esbuild/android-arm": "0.25.5",
"@esbuild/android-arm64": "0.25.5",
"@esbuild/android-x64": "0.25.5",
"@esbuild/darwin-arm64": "0.25.5",
"@esbuild/darwin-x64": "0.25.5",
"@esbuild/freebsd-arm64": "0.25.5",
"@esbuild/freebsd-x64": "0.25.5",
"@esbuild/linux-arm": "0.25.5",
"@esbuild/linux-arm64": "0.25.5",
"@esbuild/linux-ia32": "0.25.5",
"@esbuild/linux-loong64": "0.25.5",
"@esbuild/linux-mips64el": "0.25.5",
"@esbuild/linux-ppc64": "0.25.5",
"@esbuild/linux-riscv64": "0.25.5",
"@esbuild/linux-s390x": "0.25.5",
"@esbuild/linux-x64": "0.25.5",
"@esbuild/netbsd-arm64": "0.25.5",
"@esbuild/netbsd-x64": "0.25.5",
"@esbuild/openbsd-arm64": "0.25.5",
"@esbuild/openbsd-x64": "0.25.5",
"@esbuild/sunos-x64": "0.25.5",
"@esbuild/win32-arm64": "0.25.5",
"@esbuild/win32-ia32": "0.25.5",
"@esbuild/win32-x64": "0.25.5"
}
},
"node_modules/escalade": {
@@ -4052,6 +4078,23 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/eslint/node_modules/globals": {
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
@@ -4184,6 +4227,20 @@
"reusify": "^1.0.4"
}
},
"node_modules/fdir": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"license": "MIT",
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -6713,6 +6770,22 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -6923,20 +6996,23 @@
}
},
"node_modules/vite": {
"version": "5.4.19",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -6945,19 +7021,25 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"jiti": ">=1.21.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
@@ -6978,6 +7060,12 @@
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
@@ -7133,12 +7221,17 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 6"
"node": ">= 14.6"
}
},
"node_modules/yocto-queue": {
+1 -1
View File
@@ -58,7 +58,7 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.6",
"prettier": "^3.3.3",
"vite": "^5.4.19"
"vite": "6.3.5"
},
"optionalDependencies": {
"@rollup/rollup-linux-arm64-musl": "4.41.0"
@@ -2,16 +2,17 @@ import Box from "@mui/material/Box";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import BackgroundSVG from "../../../assets/Images/background.svg";
import { useSelector } from "react-redux";
const AppLayout = ({ children }) => {
const theme = useTheme();
const ui = useSelector((state) => state.ui);
return (
<Box
sx={{
minHeight: "100vh",
backgroundColor: theme.palette.primaryBackground.main,
backgroundImage: `url("${BackgroundSVG}")`,
backgroundImage: ui?.mode === "dark" ? `url("${BackgroundSVG}")` : "none",
backgroundSize: "100% 100%",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
+8 -4
View File
@@ -1,4 +1,6 @@
import { Link as MuiLink, useTheme } from "@mui/material";
import { Link as RouterLink } from "react-router-dom";
import PropTypes from "prop-types";
/**
@@ -10,7 +12,7 @@ import PropTypes from "prop-types";
* @returns {JSX.Element}
*/
const Link = ({ level, label, url }) => {
const Link = ({ level, label, url, external = true }) => {
const theme = useTheme();
const levelConfig = {
@@ -49,11 +51,12 @@ const Link = ({ level, label, url }) => {
const { sx, color } = levelConfig[level];
return (
<MuiLink
href={url}
component={external ? "a" : RouterLink}
to={external ? undefined : url}
href={external ? url : undefined}
sx={{ width: "fit-content", ...sx }}
color={color}
target="_blank"
rel="noreferrer"
{...(external && { target: "_blank", rel: "noreferrer" })}
>
{label}
</MuiLink>
@@ -64,6 +67,7 @@ Link.propTypes = {
url: PropTypes.string.isRequired,
level: PropTypes.oneOf(["primary", "secondary", "tertiary", "error"]),
label: PropTypes.string.isRequired,
external: PropTypes.bool,
};
export default Link;
@@ -0,0 +1,116 @@
import * as React from "react";
import Button from "@mui/material/Button";
import ButtonGroup from "@mui/material/ButtonGroup";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import Grow from "@mui/material/Grow";
import Paper from "@mui/material/Paper";
import Popper from "@mui/material/Popper";
import MenuItem from "@mui/material/MenuItem";
import MenuList from "@mui/material/MenuList";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { createToast } from "../../Utils/toastUtils";
import { useExportMonitors } from "../../Hooks/monitorHooks";
const MonitorActions = ({ isLoading }) => {
const [open, setOpen] = React.useState(false);
const anchorRef = React.useRef(null);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const navigate = useNavigate();
const { t } = useTranslation();
const [exportMonitors, isExporting] = useExportMonitors();
const options = [t("monitorActions.import"), t("monitorActions.export")];
const handleClick = async () => {
if (selectedIndex === 0) {
// Import
navigate("/uptime/bulk-import");
} else {
// Export
const [success, error] = await exportMonitors();
if (!success) {
createToast({ body: error || t("export.failed") });
}
}
};
const handleMenuItemClick = (event, index) => {
setSelectedIndex(index);
setOpen(false);
};
const handleToggle = () => {
setOpen((prevOpen) => !prevOpen);
};
const handleClose = (event) => {
if (anchorRef.current && anchorRef.current.contains(event.target)) {
return;
}
setOpen(false);
};
return (
<React.Fragment>
<ButtonGroup
variant="contained"
color="accent"
ref={anchorRef}
aria-label="Monitor actions"
disabled={isLoading || isExporting}
>
<Button onClick={handleClick}>{options[selectedIndex]}</Button>
<Button
size="small"
aria-controls={open ? "split-button-menu" : undefined}
aria-expanded={open ? "true" : undefined}
aria-label="select monitor action"
aria-haspopup="menu"
onClick={handleToggle}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
<Popper
sx={{ zIndex: 1 }}
open={open}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin: placement === "bottom" ? "center top" : "center bottom",
}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList
id="split-button-menu"
autoFocusItem
>
{options.map((option, index) => (
<MenuItem
key={option}
selected={index === selectedIndex}
onClick={(event) => handleMenuItemClick(event, index)}
>
{option}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</React.Fragment>
);
};
export default MonitorActions;
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
import MonitorActions from "../MonitorActions";
const CreateMonitorHeader = ({ isAdmin, label, isLoading = true, path, bulkPath }) => {
const navigate = useNavigate();
@@ -28,18 +29,7 @@ const CreateMonitorHeader = ({ isAdmin, label, isLoading = true, path, bulkPath
>
{label || t("createNew")}
</Button>
{bulkPath && (
<Button
loading={isLoading}
variant="contained"
color="accent"
onClick={() => {
navigate(`${bulkPath}`);
}}
>
{t("bulkImport.title")}
</Button>
)}
{/* {bulkPath && <MonitorActions isLoading={isLoading} />} */}
</Stack>
);
};
+2 -1
View File
@@ -8,7 +8,6 @@ import {
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
Menu,
MenuItem,
Stack,
@@ -39,6 +38,7 @@ 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";
@@ -64,6 +64,7 @@ const getMenu = (t) => [
{ name: t("menu.statusPages"), path: "status", icon: <StatusPages /> },
{ name: t("menu.maintenance"), path: "maintenance", icon: <Maintenance /> },
{ name: t("menu.logs"), path: "logs", icon: <Logs /> },
{
name: t("menu.settings"),
+1 -1
View File
@@ -4,7 +4,7 @@ import SkeletonLayout from "./skeleton";
// Utils
import { useTheme } from "@mui/material/styles";
import PropTypes from "prop-types";
const StatusBoxes = ({ shouldRender, flexWrap = "nowrap", children }) => {
const StatusBoxes = ({ shouldRender = true, flexWrap = "nowrap", children }) => {
const theme = useTheme();
if (!shouldRender) {
return (
+11
View File
@@ -0,0 +1,11 @@
export const createHeaderFactory = (getCellSx = () => {}) => {
return ({ id, content, onClick = () => {}, render = () => {} }) => {
return {
id,
content,
onClick,
getCellSx,
render,
};
};
};
+3 -1
View File
@@ -5,7 +5,7 @@ import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Link as RouterLink } from "react-router-dom";
const TextLink = ({ text, linkText, href }) => {
const TextLink = ({ text, linkText, href, target = "_self" }) => {
const theme = useTheme();
return (
@@ -18,6 +18,7 @@ const TextLink = ({ text, linkText, href }) => {
color="accent"
to={href}
component={RouterLink}
target={target}
>
{linkText}
</Link>
@@ -29,6 +30,7 @@ TextLink.propTypes = {
text: PropTypes.string,
linkText: PropTypes.string,
href: PropTypes.string,
target: PropTypes.string,
};
export default TextLink;
+35
View File
@@ -0,0 +1,35 @@
import { useState, useEffect } from "react";
import { networkService } from "../main";
import { createToast } from "../Utils/toastUtils";
import { useTranslation } from "react-i18next";
const useFetchLogs = () => {
const { t } = useTranslation();
const [logs, setLogs] = useState(undefined);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
useEffect(() => {
const fetchLogs = async () => {
try {
setIsLoading(true);
const response = await networkService.getLogs();
setLogs(response.data.data);
createToast({
body: t("logsPage.toast.fetchLogsSuccess"),
});
} catch (error) {
setError(error);
createToast({
message: error.message,
});
} finally {
setIsLoading(false);
}
};
fetchLogs();
}, [t]);
return [logs, isLoading, error];
};
export { useFetchLogs };
+34
View File
@@ -453,6 +453,39 @@ const useCreateBulkMonitors = () => {
return [createBulkMonitors, isLoading];
};
const useExportMonitors = () => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const exportMonitors = async () => {
setIsLoading(true);
try {
const response = await networkService.exportMonitors();
// Create a download link
const url = window.URL.createObjectURL(response.data);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "monitors.csv");
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
createToast({ body: t("export.success") });
return [true, null];
} catch (err) {
const errorMessage = err?.response?.data?.msg || err.message;
createToast({ body: errorMessage || t("export.failed") });
return [false, errorMessage];
} finally {
setIsLoading(false);
}
};
return [exportMonitors, isLoading];
};
export {
useFetchMonitorsWithSummary,
useFetchMonitorsWithChecks,
@@ -469,4 +502,5 @@ export {
useDeleteAllMonitors,
useDeleteMonitorStats,
useCreateBulkMonitors,
useExportMonitors,
};
+57
View File
@@ -0,0 +1,57 @@
import { useState, useEffect } from "react";
import { networkService } from "../main";
import { createToast } from "../Utils/toastUtils";
const useFetchQueueData = (trigger) => {
const [jobs, setJobs] = useState(undefined);
const [metrics, setMetrics] = useState(undefined);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
useEffect(() => {
const fetchJobs = async () => {
try {
setIsLoading(true);
const response = await networkService.getQueueData();
if (response.status === 200) {
setJobs(response.data.data.jobs);
setMetrics(response.data.data.metrics);
}
} catch (error) {
setError(error);
} finally {
setIsLoading(false);
}
};
fetchJobs();
}, [trigger]);
return [jobs, metrics, isLoading, error];
};
const useFlushQueue = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(undefined);
const flushQueue = async (trigger, setTrigger) => {
try {
setIsLoading(true);
await networkService.flushQueue();
createToast({
body: "Queue flushed",
});
} catch (error) {
setError(error);
createToast({
body: error.message,
});
} finally {
setIsLoading(false);
setTrigger(!trigger);
}
};
return [flushQueue, isLoading, error];
};
export { useFetchQueueData, useFlushQueue };
+1 -4
View File
@@ -98,12 +98,9 @@ const useGetNotificationById = (id, setNotification) => {
const notification = response?.data?.data ?? null;
const notificationData = {
userId: notification?.userId,
teamId: notification?.teamId,
address: notification?.address,
notificationName: notification?.notificationName,
type: NOTIFICATION_TYPES.find((type) => type.value === notification?.type)?._id,
config: notification?.config,
};
setNotification(notificationData);
@@ -164,7 +161,7 @@ const useTestNotification = () => {
} catch (error) {
setError(error);
createToast({
body: t("notifications.test.failed"),
body: error?.response?.data?.msg || t("notifications.test.failed"),
});
} finally {
setIsLoading(false);
+97
View File
@@ -0,0 +1,97 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Select from "../../../Components/Inputs/Select";
import Typography from "@mui/material/Typography";
import { useFetchLogs } from "../../../Hooks/logHooks";
import { useTheme } from "@emotion/react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
const formatLog = (theme, log, idx) => {
const levelColors = {
info: theme.palette.success.main,
warn: theme.palette.warning.main,
error: theme.palette.error.main,
debug: theme.palette.accent.main,
};
const color = levelColors[log.level] || theme.palette.primary.contrastText;
return (
<span key={idx}>
<span>[{log.timestamp}]</span>{" "}
<span style={{ color, fontWeight: "bold" }}>{log.level.toUpperCase()}</span>
{": "}
{`(${log.service})`}
{`(${log.method})`}
{": "}
{log.message}
<br />
</span>
);
};
const Logs = () => {
// Local state
const [logLevel, setLogLevel] = useState("all");
// Hooks
const theme = useTheme();
const { t } = useTranslation();
const [logs, isLoading, error] = useFetchLogs();
// Setup
const LOG_LEVELS = [
{ _id: "all", name: t("logsPage.logLevelSelect.values.all") },
{ _id: "info", name: t("logsPage.logLevelSelect.values.info") },
{ _id: "warn", name: t("logsPage.logLevelSelect.values.warn") },
{ _id: "error", name: t("logsPage.logLevelSelect.values.error") },
{ _id: "debug", name: t("logsPage.logLevelSelect.values.debug") },
];
return (
<Stack gap={theme.spacing(4)}>
<Box>
<Typography variant="body">{t("logsPage.description")}</Typography>
</Box>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(4)}
>
<Typography>{t("logsPage.logLevelSelect.title")}</Typography>
<Select
items={LOG_LEVELS}
value={logLevel}
onChange={(e) => {
setLogLevel(e.target.value);
}}
/>
</Stack>
<Box
component="pre"
sx={{
fontFamily: "monospace",
color: theme.palette.primary.contrastText,
padding: 2,
borderRadius: 1,
overflowX: "auto",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
}}
>
<code>
{logs
?.filter((log) => {
if (logLevel === "all") return true;
return log.level === logLevel;
})
.reverse()
.map((log, idx) => formatLog(theme, log, idx))}
</code>
</Box>
</Stack>
);
};
export default Logs;
@@ -0,0 +1,82 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import DataTable from "../../../../../Components/Table";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@emotion/react";
import { TypeToPathMap } from "../../../../../Utils/monitorUtils";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
const FailedJobTable = ({ metrics = {} }) => {
const { t } = useTranslation();
const theme = useTheme();
const jobsWithFailures = metrics?.jobsWithFailures;
const navigate = useNavigate();
const headers = [
{
id: "monitorId",
content: t("queuePage.failedJobTable.monitorIdHeader"),
render: (row) => {
return row.monitorId;
},
},
{
id: "monitorUrl",
content: t("queuePage.failedJobTable.monitorUrlHeader"),
render: (row) => {
return row.monitorUrl;
},
},
{
id: "failCount",
content: t("queuePage.failedJobTable.failCountHeader"),
render: (row) => {
return row.failCount;
},
},
{
id: "failedAt",
content: t("queuePage.failedJobTable.failedAtHeader"),
render: (row) => {
return row.failedAt;
},
},
{
id: "failReason",
content: t("queuePage.failedJobTable.failReasonHeader"),
render: (row) => {
return row.failReason;
},
},
];
return (
<Stack gap={theme.spacing(2)}>
<Typography variant="h2">{t("queuePage.failedJobTable.title")}</Typography>
<DataTable
headers={headers}
data={jobsWithFailures}
config={{
onRowClick: (row) => {
const path = TypeToPathMap[row.monitorType];
navigate(`/${path}/${row.monitorId}`);
},
rowSX: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.tertiary.main,
transition: "background-color .3s ease",
},
},
}}
/>
</Stack>
);
};
FailedJobTable.propTypes = {
metrics: PropTypes.object,
};
export default FailedJobTable;
@@ -0,0 +1,125 @@
import Stack from "@mui/material/Stack";
import DataTable from "../../../../../Components/Table";
import Typography from "@mui/material/Typography";
// Utils
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { TypeToPathMap } from "../../../../../Utils/monitorUtils";
import { useTranslation } from "react-i18next";
import { createHeaderFactory } from "../../../../../Components/Table/TableUtils";
const JobTable = ({ jobs = [] }) => {
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
const buildSx = (row) => {
if (row.lockedAt) {
return {
color: `${theme.palette.success.main} !important`,
};
}
if (!row.active) {
return {
color: `${theme.palette.warning.main} !important`,
};
}
if (row.failCount > 0 && row.lastFailedAt >= row.lastFinishedAt) {
return {
color: `${theme.palette.error.main} !important`,
};
}
return {};
};
const createHeader = createHeaderFactory(buildSx);
const headersData = [
{
id: "id",
content: t("queuePage.jobTable.idHeader"),
render: (row) => row.monitorId,
},
{
id: "url",
content: t("queuePage.jobTable.urlHeader"),
render: (row) => row.monitorUrl,
},
{
id: "type",
content: t("queuePage.jobTable.typeHeader"),
render: (row) => row.monitorType,
},
{
id: "active",
content: t("queuePage.jobTable.activeHeader"),
render: (row) => row.active.toString(),
},
{
id: "runCount",
content: t("queuePage.jobTable.runCountHeader"),
render: (row) => row.runCount,
},
{
id: "failCount",
content: t("queuePage.jobTable.failCountHeader"),
render: (row) => row.failCount,
},
{
id: "lastRun",
content: t("queuePage.jobTable.lastRunHeader"),
render: (row) => row.lastRunAt || "-",
},
{
id: "lockedAt",
content: t("queuePage.jobTable.lockedAtHeader"),
render: (row) => row.lockedAt || "-",
},
{
id: "lastFinish",
content: t("queuePage.jobTable.lastFinishedAtHeader"),
render: (row) => row.lastFinishedAt || "-",
},
{
id: "lastRunTook",
content: t("queuePage.jobTable.lastRunTookHeader"),
render: (row) => {
const value = row.lastRunTook ? row.lastRunTook + " ms" : "-";
return value;
},
},
];
const headers = headersData.map((header) => createHeader(header));
return (
<Stack gap={theme.spacing(2)}>
<Typography variant="h2">{t("queuePage.jobTable.title")}</Typography>
<DataTable
headers={headers}
data={jobs}
config={{
onRowClick: (row) => {
const path = TypeToPathMap[row.monitorType];
navigate(`/${path}/${row.monitorId}`);
},
rowSX: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.tertiary.main,
transition: "background-color .3s ease",
},
},
}}
/>
</Stack>
);
};
JobTable.propTypes = {
jobs: PropTypes.array,
};
export default JobTable;
@@ -0,0 +1,43 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import StatBox from "../../../../../Components/StatBox";
import StatusBoxes from "../../../../../Components/StatusBoxes";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
const camelToTitle = (str) => {
return str
.replace(/([A-Z])/g, " $1")
.toLowerCase()
.replace(/^./, (m) => m.toUpperCase());
};
const Metrics = ({ metrics = {} }) => {
const { t } = useTranslation();
const theme = useTheme();
const data = Object.keys(metrics)
.filter((key) => key !== "jobsWithFailures")
.map((key) => {
return { key, title: camelToTitle(key), value: metrics[key] };
});
return (
<Stack gap={theme.spacing(2)}>
<Typography variant="h2">{t("queuePage.metricsTable.title")}</Typography>
<StatusBoxes flexWrap="wrap">
{data.map((metric) => {
return (
<StatBox
key={metric.key}
heading={metric.title}
subHeading={metric.value}
/>
);
})}
</StatusBoxes>
</Stack>
);
};
export default Metrics;
@@ -0,0 +1,60 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import DataTable from "../../../../../Components/Table";
// Utils
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const camelToTitle = (str) => {
return str
.replace(/([A-Z])/g, " $1")
.toLowerCase()
.replace(/^./, (m) => m.toUpperCase());
};
const Metrics = ({ metrics = {} }) => {
const { t } = useTranslation();
const theme = useTheme();
const keys = Object.keys(metrics);
const headers = [
{
id: "metric",
content: t("queuePage.metricsTable.metricHeader"),
render: (row) => {
return <Typography>{row.key}</Typography>;
},
},
{
id: "value",
content: t("queuePage.metricsTable.valueHeader"),
render: (row) => {
return <Typography>{row.value}</Typography>;
},
},
];
const data = keys
.filter((key) => key !== "jobsWithFailures")
.map((key) => {
return { key: camelToTitle(key), value: metrics[key] };
});
return (
<Stack gap={theme.spacing(2)}>
<Typography variant="h2">{t("queuePage.metricsTable.title")}</Typography>
<DataTable
headers={headers}
data={data}
/>
</Stack>
);
};
Metrics.propTypes = {
metrics: PropTypes.object,
};
export default Metrics;
+66
View File
@@ -0,0 +1,66 @@
// Components
import Stack from "@mui/material/Stack";
import JobTable from "./components/JobTable";
import Metrics from "./components/Metrics";
import FailedJobTable from "./components/FailedJobTable";
import ButtonGroup from "@mui/material/ButtonGroup";
import Button from "@mui/material/Button";
// Utils
import { useState } from "react";
import { useFetchQueueData, useFlushQueue } from "../../../Hooks/queueHooks";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
const QueueDetails = () => {
// Local state
const [trigger, setTrigger] = useState(false);
// Hooks
const { t } = useTranslation();
const theme = useTheme();
const [jobs, metrics, isLoading, error] = useFetchQueueData(trigger);
const [flushQueue, isFlushing, flushError] = useFlushQueue();
if (isLoading) return <div>Loading...</div>;
if (error || flushError) return <div>Error: {error.message}</div>;
return (
<Stack gap={theme.spacing(20)}>
<Metrics metrics={metrics} />
<JobTable jobs={jobs} />
<FailedJobTable metrics={metrics} />
<ButtonGroup
variant="contained"
color="accent"
sx={{
position: "sticky",
bottom: 0,
zIndex: 1000,
backgroundColor: theme.palette.primary.main,
p: theme.spacing(4),
border: `1px solid ${theme.palette.primary.lowContrast}`,
borderRadius: theme.spacing(2),
}}
>
<Button
onClick={() => {
setTrigger(!trigger);
}}
loading={isLoading}
>
{t("queuePage.refreshButton")}
</Button>
<Button
onClick={() => flushQueue(trigger, setTrigger)}
loading={isFlushing}
>
{t("queuePage.flushButton")}
</Button>
</ButtonGroup>
</Stack>
);
};
export default QueueDetails;
+41
View File
@@ -0,0 +1,41 @@
import Stack from "@mui/material/Stack";
import Breadcrumbs from "../../Components/Breadcrumbs";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import Queue from "./Queue";
import LogsComponent from "./Logs";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
const Logs = () => {
const { t } = useTranslation();
const theme = useTheme();
// Local state
const [value, setValue] = useState(0);
// Handlers
const handleChange = (event, newValue) => {
setValue(newValue);
};
const BREADCRUMBS = [{ name: t("logsPage.title"), path: "/logs" }];
return (
<Stack gap={theme.spacing(20)}>
<Breadcrumbs list={BREADCRUMBS} />
<Tabs
value={value}
onChange={handleChange}
>
<Tab label={t("logsPage.tabs.logs")} />
<Tab label={t("logsPage.tabs.queue")} />
</Tabs>
{value === 0 && <LogsComponent />}
{value === 1 && <Queue />}
</Stack>
);
};
export default Logs;
@@ -11,6 +11,8 @@ import { formatDurationRounded } from "../../../Utils/timeUtils";
import { StatusLabel } from "../../../Components/Label";
import { setRowsPerPage } from "../../../Features/UI/uiSlice";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import dayjs from "dayjs";
/**
* Component for pagination actions (first, previous, next, last).
@@ -36,7 +38,8 @@ const MaintenanceTable = ({
}) => {
const rowsPerPage = useSelector((state) => state?.ui?.maintenance?.rowsPerPage ?? 5);
const dispatch = useDispatch();
const theme = useTheme();
const navigate = useNavigate();
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
@@ -180,6 +183,18 @@ const MaintenanceTable = ({
return (
<>
<DataTable
config={{
rowSX: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.tertiary.main,
transition: "background-color .3s ease",
},
},
onRowClick: (row) => {
navigate(`/maintenance/create/${row._id}`);
},
}}
headers={headers}
data={maintenanceWindows}
/>
+33 -280
View File
@@ -17,15 +17,17 @@ import {
useEditNotification,
useTestNotification,
} from "../../../Hooks/useNotifications";
import {
notificationEmailValidation,
notificationWebhookValidation,
notificationPagerDutyValidation,
} from "../../../Validation/validation";
import { notificationValidation } from "../../../Validation/validation";
import { createToast } from "../../../Utils/toastUtils";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { NOTIFICATION_TYPES } from "../utils";
import {
NOTIFICATION_TYPES,
TITLE_MAP,
DESCRIPTION_MAP,
LABEL_MAP,
PLACEHOLDER_MAP,
} from "../utils";
// Setup
@@ -49,11 +51,6 @@ const CreateNotifications = () => {
notificationName: "",
address: "",
type: NOTIFICATION_TYPES[0]._id,
config: {
webhookUrl: "",
platform: "",
routingKey: "",
},
});
const [errors, setErrors] = useState({});
const { t } = useTranslation();
@@ -71,42 +68,17 @@ const CreateNotifications = () => {
type: NOTIFICATION_TYPES.find((type) => type._id === notification.type).value,
};
if (form.type === "slack" || form.type === "discord") {
form.type = "webhook";
}
let error = null;
if (form.type === "email") {
error = notificationEmailValidation.validate(
{ notificationName: form.notificationName, address: form.address },
{ abortEarly: false }
).error;
} else if (form.type === "webhook") {
form.config = {
platform: form.config.platform,
webhookUrl: form.config.webhookUrl,
};
error = notificationWebhookValidation.validate(
{ notificationName: form.notificationName, config: form.config },
{ abortEarly: false }
).error;
} else if (form.type === "pager_duty") {
form.config = {
platform: form.config.platform,
routingKey: form.config.routingKey,
};
error = notificationPagerDutyValidation.validate(
{ notificationName: form.notificationName, config: form.config },
{ abortEarly: false }
).error;
}
error = notificationValidation.validate(form, { abortEarly: false }).error;
if (error) {
const newErrors = {};
error.details.forEach((err) => {
newErrors[err.path[0]] = err.message;
});
console.log(JSON.stringify(newErrors));
console.log(JSON.stringify(form, null, 2));
createToast({ body: "Please check the form for errors." });
setErrors(newErrors);
return;
@@ -124,63 +96,10 @@ const CreateNotifications = () => {
const newNotification = { ...notification, [name]: value };
// Handle config/platform initialization if type is webhook
const type = NOTIFICATION_TYPES.find(
(type) => type._id === newNotification.type
).value;
if (type === "email") {
newNotification.config = null;
} else if (type === "slack" || type === "webhook" || type === "discord") {
newNotification.address = "";
newNotification.config = newNotification.config || {};
if (name === "config") {
newNotification.config = value;
}
if (type === "webhook") {
newNotification.config.platform = "webhook";
}
if (type === "slack") {
newNotification.config.platform = "slack";
}
if (type === "discord") {
newNotification.config.platform = "discord";
}
} else if (type === "pager_duty") {
newNotification.config = newNotification.config || {};
if (name === "config") {
newNotification.config = value;
}
newNotification.config.platform = "pager_duty";
}
// Field-level validation
let fieldError;
if (name === "notificationName") {
const { error } = notificationEmailValidation.extract(name).validate(value);
fieldError = error?.message;
}
if (type === "email" && name === "address") {
const { error } = notificationEmailValidation.extract(name).validate(value);
fieldError = error?.message;
}
if (
(type === "slack" || type === "webhook" || type === "discord") &&
name === "config"
) {
// Validate only webhookUrl inside config
const { error } = notificationWebhookValidation.extract("config").validate(value);
fieldError = error?.message;
}
// Set field-level error
const { error } = notificationValidation.extract(name).validate(value);
setErrors((prev) => ({
...prev,
[name]: fieldError,
[name]: error?.message,
}));
setNotification(newNotification);
@@ -192,36 +111,9 @@ const CreateNotifications = () => {
type: NOTIFICATION_TYPES.find((type) => type._id === notification.type).value,
};
if (form.type === "slack" || form.type === "discord") {
form.type = "webhook";
}
let error = null;
if (form.type === "email") {
error = notificationEmailValidation.validate(
{ notificationName: form.notificationName, address: form.address },
{ abortEarly: false }
).error;
} else if (form.type === "webhook") {
form.config = {
platform: form.config.platform,
webhookUrl: form.config.webhookUrl,
};
error = notificationWebhookValidation.validate(
{ notificationName: form.notificationName, config: form.config },
{ abortEarly: false }
).error;
} else if (form.type === "pager_duty") {
form.config = {
platform: form.config.platform,
routingKey: form.config.routingKey,
};
error = notificationPagerDutyValidation.validate(
{ notificationName: form.notificationName, config: form.config },
{ abortEarly: false }
).error;
}
error = notificationValidation.validate(form, { abortEarly: false }).error;
if (error) {
const newErrors = {};
@@ -236,6 +128,7 @@ const CreateNotifications = () => {
testNotification(form);
};
const type = NOTIFICATION_TYPES.find((type) => type._id === notification.type).value;
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
@@ -287,171 +180,31 @@ const CreateNotifications = () => {
/>
</Stack>
</ConfigBox>
{notification.type === 1 && (
<ConfigBox>
<Box>
<Typography component="h2">
{t("createNotifications.emailSettings.title")}
</Typography>
<Typography component="p">
{t("createNotifications.emailSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<TextInput
label={t("createNotifications.emailSettings.emailLabel")}
name="address"
placeholder={t("createNotifications.emailSettings.emailPlaceholder")}
value={notification.address}
onChange={onChange}
error={Boolean(errors.address)}
helperText={errors["address"]}
/>
</Stack>
</ConfigBox>
)}
{notification.type === 2 && (
<ConfigBox>
<Box>
<Typography component="h2">
{t("createNotifications.slackSettings.title")}
</Typography>
<Typography component="p">
{t("createNotifications.slackSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<TextInput
label={t("createNotifications.slackSettings.webhookLabel")}
value={notification.config.webhookUrl || ""}
error={Boolean(errors.config)}
helperText={errors["config"]}
onChange={(e) => {
const updatedConfig = {
...notification.config,
webhookUrl: e.target.value,
};
<ConfigBox>
<Box>
<Typography component="h2">{t(TITLE_MAP[type])}</Typography>
<Typography component="p">{t(DESCRIPTION_MAP[type])}</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<TextInput
label={t(LABEL_MAP[type])}
name="address"
placeholder={t(PLACEHOLDER_MAP[type])}
value={notification.address}
onChange={onChange}
error={Boolean(errors.address)}
helperText={errors["address"]}
/>
</Stack>
</ConfigBox>
onChange({
target: {
name: "config",
value: updatedConfig,
},
});
}}
/>
</Stack>
</ConfigBox>
)}
{notification.type === 3 && (
<ConfigBox>
<Box>
<Typography component="h2">
{t("createNotifications.pagerdutySettings.title")}
</Typography>
<Typography component="p">
{t("createNotifications.pagerdutySettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<TextInput
label={t("createNotifications.pagerdutySettings.integrationKeyLabel")}
placeholder={t(
"createNotifications.pagerdutySettings.integrationKeyPlaceholder"
)}
value={notification.config.routingKey || ""}
error={Boolean(errors.config)}
helperText={errors["config"]}
onChange={(e) => {
const updatedConfig = {
...notification.config,
routingKey: e.target.value,
};
onChange({
target: {
name: "config",
value: updatedConfig,
},
});
}}
/>
</Stack>
</ConfigBox>
)}
{notification.type === 4 && (
<ConfigBox>
<Box>
<Typography component="h2">
{t("createNotifications.genericWebhookSettings.title")}
</Typography>
<Typography component="p">
{t("createNotifications.genericWebhookSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<TextInput
label={t("createNotifications.genericWebhookSettings.webhookLabel")}
value={notification.config.webhookUrl || ""}
error={Boolean(errors.config)}
helperText={errors["config"]}
onChange={(e) => {
const updatedConfig = {
...notification.config,
webhookUrl: e.target.value,
};
onChange({
target: {
name: "config",
value: updatedConfig,
},
});
}}
/>
</Stack>
</ConfigBox>
)}
{notification.type === 5 && (
<ConfigBox>
<Box>
<Typography component="h2">
{t("createNotifications.discordSettings.title")}
</Typography>
<Typography component="p">
{t("createNotifications.discordSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(12)}>
<TextInput
label={t("createNotifications.discordSettings.webhookLabel")}
value={notification.config.webhookUrl || ""}
error={Boolean(errors.config)}
helperText={errors["config"]}
onChange={(e) => {
const updatedConfig = {
...notification.config,
webhookUrl: e.target.value,
};
onChange({
target: {
name: "config",
value: updatedConfig,
},
});
}}
/>
</Stack>
</ConfigBox>
)}
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
>
<Button
loading={isCreating || isEditing || notificationIsLoading}
loading={isTesting}
variant="contained"
color="secondary"
onClick={onTestNotification}
+1 -17
View File
@@ -44,27 +44,11 @@ const Notifications = () => {
return row.notificationName;
},
},
{
id: "type",
content: "Type",
render: (row) => {
return row.type;
},
},
{
id: "target",
content: "Target",
render: (row) => {
const type = row.type;
if (type === "email") {
return row.address;
}
if (type === "webhook") {
return row.config?.webhookUrl;
}
if (type === "pager_duty") {
return row.config?.routingKey;
}
return row.address;
},
},
{
+32
View File
@@ -5,3 +5,35 @@ export const NOTIFICATION_TYPES = [
{ _id: 4, name: "Webhook", value: "webhook" },
{ _id: 5, name: "Discord", value: "discord" },
];
export const TITLE_MAP = {
email: "createNotifications.emailSettings.title",
slack: "createNotifications.slackSettings.title",
pager_duty: "createNotifications.pagerDutySettings.title",
webhook: "createNotifications.webhookSettings.title",
discord: "createNotifications.discordSettings.title",
};
export const DESCRIPTION_MAP = {
email: "createNotifications.emailSettings.description",
slack: "createNotifications.slackSettings.description",
pager_duty: "createNotifications.pagerDutySettings.description",
webhook: "createNotifications.webhookSettings.description",
discord: "createNotifications.discordSettings.description",
};
export const LABEL_MAP = {
email: "createNotifications.emailSettings.emailLabel",
slack: "createNotifications.slackSettings.webhookLabel",
pager_duty: "createNotifications.pagerDutySettings.integrationKeyLabel",
webhook: "createNotifications.webhookSettings.webhookLabel",
discord: "createNotifications.discordSettings.webhookLabel",
};
export const PLACEHOLDER_MAP = {
email: "createNotifications.emailSettings.emailPlaceholder",
slack: "createNotifications.slackSettings.webhookPlaceholder",
pager_duty: "createNotifications.pagerDutySettings.integrationKeyPlaceholder",
webhook: "createNotifications.webhookSettings.webhookPlaceholder",
discord: "createNotifications.discordSettings.webhookPlaceholder",
};
+3 -3
View File
@@ -16,15 +16,15 @@ const SettingsAbout = () => {
component="h1"
variant="h2"
>
{t("settingsAbout")}
{t("settingsPage.aboutSettings.title")}
</Typography>
</Box>
<Box>
<Typography component="h2">
{t("common.appName")} {2.1}
{t("common.appName")} {__APP_VERSION__}
</Typography>
<Typography sx={{ mt: theme.spacing(2), mb: theme.spacing(6), opacity: 0.6 }}>
{t("settingsDevelopedBy")}
{t("settingsPage.aboutSettings.labelDevelopedBy")}
</Typography>
<Link
level="secondary"
@@ -27,12 +27,13 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
component="h1"
variant="h2"
>
{t("settingsDemoMonitors")}
{t("settingsPage.demoMonitorsSettings.title")}
</Typography>
<Typography sx={HEADER_SX}>
{t("settingsPage.demoMonitorsSettings.description")}
</Typography>
<Typography sx={HEADER_SX}>{t("settingsDemoMonitorsDescription")}</Typography>
</Box>
<Box>
<Typography>{t("settingsAddDemoMonitors")}</Typography>
<Button
variant="contained"
color="accent"
@@ -47,7 +48,7 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
}}
sx={{ mt: theme.spacing(4) }}
>
{t("settingsAddDemoMonitorsButton")}
{t("settingsPage.demoMonitorsSettings.buttonAddMonitors")}
</Button>
</Box>
</ConfigBox>
@@ -57,14 +58,13 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
component="h1"
variant="h2"
>
{t("settingsSystemReset")}
{t("settingsPage.systemResetSettings.title")}
</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
{t("settingsSystemResetDescription")}
{t("settingsPage.systemResetSettings.description")}
</Typography>
</Box>
<Box>
<Typography>{t("settingsRemoveAllMonitors")}</Typography>
<Button
variant="contained"
color="error"
@@ -72,15 +72,15 @@ const SettingsDemoMonitors = ({ isAdmin, HEADER_SX, handleChange, isLoading }) =
onClick={() => setIsOpen(true)}
sx={{ mt: theme.spacing(4) }}
>
{t("settingsRemoveAllMonitorsButton")}
{t("settingsPage.systemResetSettings.buttonRemoveAllMonitors")}
</Button>
</Box>
<Dialog
open={isOpen}
theme={theme}
title={t("settingsRemoveAllMonitorsDialogTitle")}
title={t("settingsPage.systemResetSettings.dialogTitle")}
onCancel={() => setIsOpen(false)}
confirmationButtonLabel={t("settingsRemoveAllMonitorsDialogConfirm")}
confirmationButtonLabel={t("settingsPage.systemResetSettings.dialogConfirm")}
onConfirm={() => {
const syntheticEvent = {
target: {
+100 -43
View File
@@ -4,15 +4,16 @@ import ConfigBox from "../../Components/ConfigBox";
import TextInput from "../../Components/Inputs/TextInput";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import { Switch } from "@mui/material";
import TextLink from "../../Components/TextLink";
// Utils
import { useTheme } from "@emotion/react";
import { PropTypes } from "prop-types";
import { useState, useEffect } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { PasswordEndAdornment } from "../../Components/Inputs/TextInput/Adornments";
import { useSendTestEmail } from "../../Hooks/useSendTestEmail";
import { createToast } from "../../Utils/toastUtils";
import { Switch } from "@mui/material";
const SettingsEmail = ({
isAdmin,
@@ -110,14 +111,16 @@ const SettingsEmail = ({
component="h1"
variant="h2"
>
{t("settingsEmail")}
{t("settingsPage.emailSettings.title")}
</Typography>
<Typography sx={HEADER_SX}>
{t("settingsPage.emailSettings.description")}
</Typography>
<Typography sx={HEADER_SX}>{t("settingsEmailDescription")}</Typography>
</Box>
<Box>
<Stack gap={theme.spacing(10)}>
<Box>
<Typography>{t("settingsEmailHost")}</Typography>
<Typography>{t("settingsPage.emailSettings.labelHost")}</Typography>
<TextInput
name="systemEmailHost"
placeholder="smtp.gmail.com"
@@ -126,7 +129,7 @@ const SettingsEmail = ({
/>
</Box>
<Box>
<Typography>{t("settingsEmailPort")}</Typography>
<Typography>{t("settingsPage.emailSettings.labelPort")}</Typography>
<TextInput
name="systemEmailPort"
placeholder="425"
@@ -136,16 +139,16 @@ const SettingsEmail = ({
/>
</Box>
<Box>
<Typography>{t("settingsEmailUser")}</Typography>
<Typography>{t("settingsPage.emailSettings.labelUser")}</Typography>
<TextInput
name="systemEmailUser"
placeholder="Leave empty if not required"
placeholder={t("settingsPage.emailSettings.placeholderUser")}
value={systemEmailUser}
onChange={handleChange}
/>
</Box>
<Box>
<Typography>{t("settingsEmailAddress")}</Typography>
<Typography>{t("settingsPage.emailSettings.labelAddress")}</Typography>
<TextInput
name="systemEmailAddress"
placeholder="uptime@bluewavelabs.ca"
@@ -155,7 +158,7 @@ const SettingsEmail = ({
</Box>
{(isEmailPasswordSet === false || emailPasswordHasBeenReset === true) && (
<Box>
<Typography>{t("settingsEmailPassword")}</Typography>
<Typography>{t("settingsPage.emailSettings.labelPassword")}</Typography>
<TextInput
name="systemEmailPassword"
type="password"
@@ -169,7 +172,7 @@ const SettingsEmail = ({
{isEmailPasswordSet === true && emailPasswordHasBeenReset === false && (
<Box>
<Typography>{t("settingsEmailFieldResetLabel")}</Typography>
<Typography>{t("settingsPage.emailSettings.labelPasswordSet")}</Typography>
<Button
onClick={() => {
setPassword("");
@@ -188,7 +191,7 @@ const SettingsEmail = ({
</Box>
)}
<Box>
<Typography>{t("settingsEmailTLSServername")}</Typography>
<Typography>{t("settingsPage.emailSettings.labelTLSServername")}</Typography>
<TextInput
name="systemEmailTLSServername"
placeholder="bluewavelabs.ca"
@@ -197,7 +200,7 @@ const SettingsEmail = ({
/>
</Box>
<Box>
<Typography>{t("settingsEmailConnectionHost")}</Typography>
<Typography>{t("settingsPage.emailSettings.labelConnectionHost")}</Typography>
<TextInput
name="systemEmailConnectionHost"
placeholder="bluewavelabs.ca"
@@ -212,37 +215,91 @@ const SettingsEmail = ({
gap: theme.spacing(4),
}}
>
<Typography>{t("settingsEmailSecure")}</Typography>
<Switch
name="systemEmailSecure"
checked={systemEmailSecure}
onChange={handleChange}
/>
<Typography>{t("settingsEmailPool")}</Typography>
<Switch
name="systemEmailPool"
checked={systemEmailPool}
onChange={handleChange}
/>
<Typography>{t("settingsEmailIgnoreTLS")}</Typography>
<Switch
name="systemEmailIgnoreTLS"
checked={systemEmailIgnoreTLS}
onChange={handleChange}
/>
<Typography>{t("settingsEmailRequireTLS")}</Typography>
<Switch
name="systemEmailRequireTLS"
checked={systemEmailRequireTLS}
onChange={handleChange}
/>
<Typography>{t("settingsEmailRejectUnauthorized")}</Typography>
<Switch
name="systemEmailRejectUnauthorized"
checked={systemEmailRejectUnauthorized}
onChange={handleChange}
{[
[
"settingsPage.emailSettings.labelSecure",
"systemEmailSecure",
systemEmailSecure,
],
[
"settingsPage.emailSettings.labelPool",
"systemEmailPool",
systemEmailPool,
],
[
"settingsPage.emailSettings.labelIgnoreTLS",
"systemEmailIgnoreTLS",
systemEmailIgnoreTLS,
],
[
"settingsPage.emailSettings.labelRequireTLS",
"systemEmailRequireTLS",
systemEmailRequireTLS,
],
[
"settingsPage.emailSettings.labelRejectUnauthorized",
"systemEmailRejectUnauthorized",
systemEmailRejectUnauthorized,
],
].map(([labelKey, name, value]) => (
<Box
key={name}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Typography>{t(labelKey)}</Typography>
<Switch
name={name}
checked={value}
onChange={handleChange}
/>
</Box>
))}
<TextLink
text={t("settingsPage.emailSettings.descriptionTransport")}
linkText={t("settingsPage.emailSettings.linkTransport")}
href="https://nodemailer.com/smtp"
target="_blank"
/>
<Box
component={"pre"}
sx={{
fontFamily: "monospace",
p: 2,
borderRadius: 1,
overflow: "auto",
}}
>
<code>
{JSON.stringify(
{
host: systemEmailHost,
port: systemEmailPort,
secure: systemEmailSecure,
auth: {
user: systemEmailUser || systemEmailAddress,
pass: "<your_password>",
},
name: systemEmailConnectionHost || "localhost",
pool: systemEmailPool,
tls: {
rejectUnauthorized: systemEmailRejectUnauthorized,
ignoreTLS: systemEmailIgnoreTLS,
requireTLS: systemEmailRequireTLS,
servername: systemEmailTLSServername,
},
},
null,
2
)}
</code>
</Box>
</Box>
<Box>
{systemEmailHost &&
systemEmailPort &&
@@ -254,7 +311,7 @@ const SettingsEmail = ({
loading={isSending}
onClick={handleSendTestEmail}
>
{t("settingsTestEmail", "Send test e-mail")}
{t("settingsPage.emailSettings.buttonSendTestEmail")}
</Button>
)}
</Box>
@@ -8,7 +8,7 @@ import { PasswordEndAdornment } from "../../Components/Inputs/TextInput/Adornmen
// Utils
import { useTheme } from "@emotion/react";
import { PropTypes } from "prop-types";
import { useState, useEffect } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
const SettingsPagespeed = ({
@@ -46,15 +46,17 @@ const SettingsPagespeed = ({
component="h1"
variant="h2"
>
{t("pageSpeedApiKeyFieldTitle")}
{t("settingsPage.pageSpeedSettings.title")}
</Typography>
<Typography sx={HEADING_SX}>
{t("settingsPage.pageSpeedSettings.description")}
</Typography>
<Typography sx={HEADING_SX}>{t("pageSpeedApiKeyFieldDescription")}</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
{(isApiKeySet === false || apiKeyHasBeenReset === true) && (
<TextInput
name="pagespeedApiKey"
label={t("pageSpeedApiKeyFieldLabel")}
label={t("settingsPage.pageSpeedSettings.labelApiKey")}
value={apiKey}
type={"password"}
onChange={handleChange}
@@ -65,7 +67,7 @@ const SettingsPagespeed = ({
{isApiKeySet === true && apiKeyHasBeenReset === false && (
<Box>
<Typography>{t("pageSpeedApiKeyFieldResetLabel")}</Typography>
<Typography>{t("settingsPage.pageSpeedSettings.labelApiKeySet")}</Typography>
<Button
onClick={() => {
setApiKey("");
+13 -9
View File
@@ -29,17 +29,17 @@ const SettingsStats = ({ isAdmin, HEADING_SX, handleChange, settingsData, errors
variant="h2"
sx={HEADING_SX}
>
{t("settingsHistoryAndMonitoring")}
{t("settingsPage.statsSettings.title")}
</Typography>
<Typography sx={{ mt: theme.spacing(2) }}>
{t("settingsHistoryAndMonitoringDescription")}
{t("settingsPage.statsSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<TextInput
name="checkTTL"
label={t("settingsTTLLabel")}
optionalLabel={t("settingsTTLOptionalLabel")}
label={t("settingsPage.statsSettings.labelTTL")}
optionalLabel={t("settingsPage.statsSettings.labelTTLOptional")}
value={settingsData?.settings?.checkTTL ?? ""}
onChange={handleChange}
type="number"
@@ -47,24 +47,28 @@ const SettingsStats = ({ isAdmin, HEADING_SX, handleChange, settingsData, errors
helperText={errors.checkTTL}
/>
<Box>
<Typography>{t("settingsClearAllStats")}</Typography>
<Typography>
{t("settingsPage.statsSettings.clearAllStatsDescription")}
</Typography>
<Button
variant="contained"
color="error"
onClick={() => setIsOpen(true)}
sx={{ mt: theme.spacing(4) }}
>
{t("settingsClearAllStatsButton")}
{t("settingsPage.statsSettings.clearAllStatsButton")}
</Button>
</Box>
</Stack>
<Dialog
open={isOpen}
theme={theme}
title={t("settingsClearAllStatsDialogTitle")}
description={t("settingsClearAllStatsDialogDescription")}
title={t("settingsPage.statsSettings.clearAllStatsDialogTitle")}
description={t("settingsPage.statsSettings.clearAllStatsDialogDescription")}
onCancel={() => setIsOpen(false)}
confirmationButtonLabel={t("settingsClearAllStatsDialogConfirm")}
confirmationButtonLabel={t(
"settingsPage.statsSettings.clearAllStatsDialogConfirm"
)}
onConfirm={() => {
const syntheticEvent = {
target: {
@@ -19,16 +19,17 @@ const SettingsTimeZone = ({ HEADING_SX, handleChange, timezone }) => {
component="h1"
variant="h2"
>
{t("settingsGeneralSettings")}
{t("settingsPage.timezoneSettings.title")}
</Typography>
<Typography sx={HEADING_SX}>
<Typography component="span">{t("settingsDisplayTimezone")}</Typography>-{" "}
{t("settingsDisplayTimezoneDescription")}
<Typography component="span">
{t("settingsPage.timezoneSettings.description")}
</Typography>
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
label={t("settingsDisplayTimezone")}
label={t("settingsPage.timezoneSettings.label")}
name="timezone"
value={timezone}
onChange={handleChange}
+6 -4
View File
@@ -20,14 +20,16 @@ const SettingsUI = ({ HEADING_SX, handleChange, mode, language }) => {
component="h1"
variant="h2"
>
{t("settingsAppearance")}
{t("settingsPage.uiSettings.title")}
</Typography>
<Typography sx={HEADING_SX}>
{t("settingsPage.uiSettings.description")}
</Typography>
<Typography sx={HEADING_SX}>{t("settingsAppearanceDescription")}</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
name="mode"
label={t("settingsThemeMode")}
label={t("settingsPage.uiSettings.labelTheme")}
value={mode}
onChange={handleChange}
items={[
@@ -37,7 +39,7 @@ const SettingsUI = ({ HEADING_SX, handleChange, mode, language }) => {
></Select>
<Select
name="language"
label={t("settingsLanguage")}
label={t("settingsPage.uiSettings.labelLanguage")}
value={language}
onChange={handleChange}
items={languages.map((lang) => ({ _id: lang, name: lang.toUpperCase() }))}
+7 -5
View File
@@ -15,18 +15,20 @@ const SettingsURL = ({ HEADING_SX, handleChange, showURL = false }) => {
return (
<ConfigBox>
<Box>
<Typography component="h1">{t("settingsURLTitle")}</Typography>
<Typography sx={HEADING_SX}>{t("settingsURLDescription")}</Typography>
<Typography component="h1">{t("settingsPage.urlSettings.title")}</Typography>
<Typography sx={HEADING_SX}>
{t("settingsPage.urlSettings.description")}
</Typography>
</Box>
<Stack gap={theme.spacing(20)}>
<Select
name="showURL"
label={t("settingsURLSelectTitle")}
label={t("settingsPage.urlSettings.label")}
value={showURL || ""}
onChange={handleChange}
items={[
{ _id: true, name: t("settingsURLEnabled") },
{ _id: false, name: t("settingURLDisabled") },
{ _id: true, name: t("settingsPage.urlSettings.selectEnabled") },
{ _id: false, name: t("settingsPage.urlSettings.selectDisabled") },
]}
></Select>
</Stack>
+3 -2
View File
@@ -157,7 +157,7 @@ const Settings = () => {
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<Typography variant="h1">Settings</Typography>
<Typography variant="h1">{t("settingsPage.title")}</Typography>
<SettingsTimeZone
HEADING_SX={HEADING_SX}
handleChange={handleChange}
@@ -208,6 +208,7 @@ const Settings = () => {
emailPasswordHasBeenReset={emailPasswordHasBeenReset}
setEmailPasswordHasBeenReset={setEmailPasswordHasBeenReset}
/>
<SettingsAbout />
<Stack
direction="row"
@@ -241,7 +242,7 @@ const Settings = () => {
sx={{ px: theme.spacing(12), py: theme.spacing(8) }}
onClick={handleSave}
>
{t("settingsSave")}
{t("settingsPage.saveButtonLabel")}
</Button>
</Stack>
</Stack>
@@ -90,6 +90,13 @@ const Content = ({
isChecked={form.showUptimePercentage}
onChange={handleFormChange}
/>
<Checkbox
id="showAdminLoginLink"
name="showAdminLoginLink"
label={t("showAdminLoginLink")}
isChecked={form.showAdminLoginLink}
onChange={handleFormChange}
/>
</Stack>
</ConfigStack>
</Stack>
+3 -1
View File
@@ -20,7 +20,7 @@ const TAB_LIST = ["General settings", "Contents"];
const ERROR_TAB_MAPPING = [
["companyName", "url", "timezone", "color", "isPublished", "logo"],
["monitors", "showUptimePercentage", "showCharts"],
["monitors", "showUptimePercentage", "showCharts", "showAdminLoginLink"],
];
const CreateStatusPage = () => {
@@ -39,6 +39,7 @@ const CreateStatusPage = () => {
monitors: [],
showCharts: true,
showUptimePercentage: true,
showAdminLoginLink: false,
});
const [errors, setErrors] = useState({});
const [selectedMonitors, setSelectedMonitors] = useState([]);
@@ -195,6 +196,7 @@ const CreateStatusPage = () => {
logo: newLogo,
showCharts: statusPage?.showCharts ?? true,
showUptimePercentage: statusPage?.showUptimePercentage ?? true,
showAdminLoginLink: statusPage?.showAdminLoginLink ?? false,
};
});
setSelectedMonitors(statusPageMonitors);
+1 -1
View File
@@ -48,7 +48,7 @@ const PublicStatus = () => {
let link = undefined;
const isPublic = location.pathname.startsWith("/status/uptime/public");
// Public status page
if (isPublic) {
if (isPublic && statusPage && statusPage.showAdminLoginLink === true) {
sx = {
paddingTop: theme.spacing(20),
paddingLeft: "20vw",
+14
View File
@@ -111,6 +111,7 @@ const UptimeDetails = () => {
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<MonitorDetailsControlHeader
path={"uptime"}
isAdmin={isAdmin}
@@ -118,6 +119,19 @@ const UptimeDetails = () => {
monitor={monitor}
triggerUpdate={triggerUpdate}
/>
<UptimeStatusBoxes
isLoading={monitorIsLoading}
monitor={monitor}
monitorStats={monitorStats}
certificateExpiry={certificateExpiry}
/>
<MonitorTimeFrameHeader
isLoading={monitorIsLoading}
hasDateRange={true}
dateRange={dateRange}
setDateRange={setDateRange}
/>
<GenericFallback>
<Typography>{t("distributedUptimeDetailsNoMonitorHistory")}</Typography>
</GenericFallback>
+6
View File
@@ -51,6 +51,7 @@ import ProtectedRoute from "../Components/ProtectedRoute";
import CreateNewMaintenanceWindow from "../Pages/Maintenance/CreateMaintenance";
import withAdminCheck from "../Components/HOC/withAdminCheck";
import BulkImport from "../Pages/Uptime/BulkImport";
import Logs from "../Pages/Logs";
const Routes = () => {
const AdminCheckedRegister = withAdminCheck(AuthRegister);
@@ -186,6 +187,11 @@ const Routes = () => {
path="account/team"
element={<Account open={"team"} />}
/>
<Route
path="logs"
element={<Logs />}
/>
</Route>
<Route
+30
View File
@@ -893,6 +893,9 @@ class NetworkService {
if (form.showUptimePercentage !== undefined) {
fd.append("showUptimePercentage", String(form.showUptimePercentage));
}
if (form.showAdminLoginLink !== undefined) {
fd.append("showAdminLoginLink", String(form.showAdminLoginLink));
}
form.monitors &&
form.monitors.forEach((monitorId) => {
fd.append("monitors[]", monitorId);
@@ -1044,6 +1047,33 @@ class NetworkService {
const { id, notification } = config;
return this.axiosInstance.put(`/notifications/${id}`, notification);
}
async getQueueData() {
return this.axiosInstance.get(`/queue/all-metrics`, {
headers: {
"Content-Type": "application/json",
},
});
}
async flushQueue() {
return this.axiosInstance.post(`/queue/flush`);
}
async exportMonitors() {
const response = await this.axiosInstance.get("/monitors/export", {
responseType: "blob",
});
return response;
}
async getLogs() {
return this.axiosInstance.get(`/logs`, {
headers: {
"Content-Type": "application/json",
},
});
}
}
export default NetworkService;
+2 -1
View File
@@ -85,6 +85,7 @@ const semanticColors = {
const newColors = {
offWhite: "#FEFEFE",
offBlack: "#131315",
gray0: "#FDFDFD",
gray10: "#F4F4FF",
gray100: "#F3F3F3",
gray200: "#EFEFEF",
@@ -178,7 +179,7 @@ const newSemanticColors = {
},
primaryBackground: {
main: {
light: newColors.gray10,
light: newColors.gray0,
dark: "#000000",
},
},
+8
View File
@@ -36,6 +36,11 @@ const baseTheme = (palette) => ({
color: palette.primary.contrastTextTertiary,
fontWeight: 400,
},
label: {
fontSize: "var(--env-var-font-size-medium)",
color: palette.primary.contrastTextSecondary,
fontWeight: 500,
},
},
/* TODO change to 4 */
spacing: 2,
@@ -407,6 +412,7 @@ const baseTheme = (palette) => ({
}),
},
},
MuiTab: {
styleOverrides: {
root: ({ theme }) => ({
@@ -475,6 +481,8 @@ const baseTheme = (palette) => ({
MuiTabs: {
styleOverrides: {
root: ({ theme }) => ({
height: "34px",
minHeight: "34px",
display: "inline-flex",
borderRadius: 0,
"& .MuiTabs-indicator": {
+1
View File
@@ -24,6 +24,7 @@ i18n.use(initReactI18next).init({
interpolation: {
escapeValue: false,
},
returnEmptyString: false,
});
export default i18n;
+9
View File
@@ -36,3 +36,12 @@ export const parseDomainName = (url) => {
return url;
};
export const TypeToPathMap = {
http: "uptime",
port: "uptime",
docker: "uptime",
ping: "uptime",
hardware: "infrastructure",
pagespeed: "pagespeed",
};
+10 -49
View File
@@ -277,6 +277,7 @@ const statusPageValidation = joi.object({
logo: logoImageValidation,
showUptimePercentage: joi.boolean(),
showCharts: joi.boolean(),
showAdminLoginLink: joi.boolean(),
});
const settingsValidation = joi.object({
@@ -404,57 +405,19 @@ const infrastructureMonitorValidation = joi.object({
notifications: joi.array().items(joi.string()),
});
const notificationEmailValidation = joi.object({
const notificationValidation = joi.object({
notificationName: joi.string().required().messages({
"string.empty": "Notification name is required",
"any.required": "Notification name is required",
}),
address: joi
.string()
.email({ tlds: { allow: false } })
.required()
.messages({
"string.empty": "E-mail address cannot be empty",
"string.email": "Please enter a valid e-mail address",
"string.base": "E-mail address must be a string",
"any.required": "E-mail address is required",
}),
});
const notificationWebhookValidation = joi.object({
notificationName: joi.string().required().messages({
"string.empty": "Notification name is required",
"any.required": "Notification name is required",
address: joi.string().required().messages({
"string.empty": "This field cannot be empty",
"string.base": "This field must be a string",
"any.required": "This field is required",
}),
config: joi
.object({
webhookUrl: joi.string().uri().required().messages({
"string.empty": "Webhook URL is required",
"string.uri": "Webhook URL must be a valid URI",
"any.required": "Webhook URL is required",
}),
platform: joi.string().required().messages({
"string.base": "Platform must be a string",
"any.required": "Platform is required",
}),
})
.unknown(true),
});
const notificationPagerDutyValidation = joi.object({
notificationName: joi.string().required().messages({
"string.empty": "Notification name is required",
"any.required": "Notification name is required",
}),
config: joi.object({
platform: joi.string().required().messages({
"string.base": "Platform must be a string",
"any.required": "Platform is required",
}),
routingKey: joi.string().required().messages({
"string.empty": "Routing key is required",
"any.required": "Routing key is required",
}),
type: joi.string().required().messages({
"string.empty": "This field is required",
"any.required": "This field is required",
}),
});
@@ -469,7 +432,5 @@ export {
infrastructureMonitorValidation,
statusPageValidation,
logoImageValidation,
notificationEmailValidation,
notificationWebhookValidation,
notificationPagerDutyValidation,
notificationValidation,
};
+5
View File
@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M21 5C21 6.65685 16.9706 8 12 8C7.02944 8 3 6.65685 3 5M21 5C21 3.34315 16.9706 2 12 2C7.02944 2 3 3.34315 3 5M21 5V19C21 20.66 17 22 12 22C7 22 3 20.66 3 19V5M21 12C21 13.66 17 15 12 15C7 15 3 13.66 3 12"
stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 421 B

+11 -5
View File
@@ -23,9 +23,7 @@
"settingsClearAllStatsDialogConfirm": "",
"settingsDemoMonitors": "",
"settingsDemoMonitorsDescription": "",
"settingsAddDemoMonitors": "",
"settingsAddDemoMonitorsButton": "",
"settingsRemoveAllMonitors": "",
"settingsRemoveAllMonitorsButton": "",
"settingsRemoveAllMonitorsDialogTitle": "",
"settingsRemoveAllMonitorsDialogConfirm": "",
@@ -583,6 +581,13 @@
},
"errors": {
"validation": ""
},
"fields": {
"password": {
"errors": {
"incorrect": ""
}
}
}
},
"login": {
@@ -709,13 +714,13 @@
"integrationKeyLabel": "",
"integrationKeyPlaceholder": ""
},
"genericWebhookSettings": {
"discordSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
},
"discordSettings": {
"webhookSettings": {
"title": "",
"description": "",
"webhookLabel": "",
@@ -740,5 +745,6 @@
"settingsEmailRejectUnauthorized": "",
"settingsEmailSecure": "",
"settingsEmailPool": "",
"sendTestNotifications": ""
"sendTestNotifications": "",
"selectAll": ""
}
+11 -5
View File
@@ -23,9 +23,7 @@
"settingsClearAllStatsDialogConfirm": "",
"settingsDemoMonitors": "",
"settingsDemoMonitorsDescription": "",
"settingsAddDemoMonitors": "",
"settingsAddDemoMonitorsButton": "",
"settingsRemoveAllMonitors": "",
"settingsRemoveAllMonitorsButton": "",
"settingsRemoveAllMonitorsDialogTitle": "",
"settingsRemoveAllMonitorsDialogConfirm": "",
@@ -583,6 +581,13 @@
},
"errors": {
"validation": "Nastala chyba při ověřování dat."
},
"fields": {
"password": {
"errors": {
"incorrect": ""
}
}
}
},
"login": {
@@ -709,13 +714,13 @@
"integrationKeyLabel": "",
"integrationKeyPlaceholder": ""
},
"genericWebhookSettings": {
"discordSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
},
"discordSettings": {
"webhookSettings": {
"title": "",
"description": "",
"webhookLabel": "",
@@ -740,5 +745,6 @@
"settingsEmailRejectUnauthorized": "",
"settingsEmailSecure": "",
"settingsEmailPool": "",
"sendTestNotifications": ""
"sendTestNotifications": "",
"selectAll": ""
}
+11 -5
View File
@@ -23,9 +23,7 @@
"settingsClearAllStatsDialogConfirm": "",
"settingsDemoMonitors": "",
"settingsDemoMonitorsDescription": "",
"settingsAddDemoMonitors": "",
"settingsAddDemoMonitorsButton": "",
"settingsRemoveAllMonitors": "",
"settingsRemoveAllMonitorsButton": "",
"settingsRemoveAllMonitorsDialogTitle": "",
"settingsRemoveAllMonitorsDialogConfirm": "",
@@ -583,6 +581,13 @@
},
"errors": {
"validation": ""
},
"fields": {
"password": {
"errors": {
"incorrect": ""
}
}
}
},
"login": {
@@ -709,13 +714,13 @@
"integrationKeyLabel": "",
"integrationKeyPlaceholder": ""
},
"genericWebhookSettings": {
"discordSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
},
"discordSettings": {
"webhookSettings": {
"title": "",
"description": "",
"webhookLabel": "",
@@ -740,5 +745,6 @@
"settingsEmailRejectUnauthorized": "",
"settingsEmailSecure": "",
"settingsEmailPool": "",
"sendTestNotifications": ""
"sendTestNotifications": "",
"selectAll": ""
}
+757 -667
View File
File diff suppressed because it is too large. Load diff
+11 -5
View File
@@ -23,9 +23,7 @@
"settingsClearAllStatsDialogConfirm": "",
"settingsDemoMonitors": "",
"settingsDemoMonitorsDescription": "",
"settingsAddDemoMonitors": "",
"settingsAddDemoMonitorsButton": "",
"settingsRemoveAllMonitors": "",
"settingsRemoveAllMonitorsButton": "",
"settingsRemoveAllMonitorsDialogTitle": "",
"settingsRemoveAllMonitorsDialogConfirm": "",
@@ -583,6 +581,13 @@
},
"errors": {
"validation": ""
},
"fields": {
"password": {
"errors": {
"incorrect": ""
}
}
}
},
"login": {
@@ -709,13 +714,13 @@
"integrationKeyLabel": "",
"integrationKeyPlaceholder": ""
},
"genericWebhookSettings": {
"discordSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
},
"discordSettings": {
"webhookSettings": {
"title": "",
"description": "",
"webhookLabel": "",
@@ -740,5 +745,6 @@
"settingsEmailRejectUnauthorized": "",
"settingsEmailSecure": "",
"settingsEmailPool": "",
"sendTestNotifications": ""
"sendTestNotifications": "",
"selectAll": ""
}
+11 -5
View File
@@ -23,9 +23,7 @@
"settingsClearAllStatsDialogConfirm": "",
"settingsDemoMonitors": "",
"settingsDemoMonitorsDescription": "",
"settingsAddDemoMonitors": "",
"settingsAddDemoMonitorsButton": "",
"settingsRemoveAllMonitors": "",
"settingsRemoveAllMonitorsButton": "",
"settingsRemoveAllMonitorsDialogTitle": "",
"settingsRemoveAllMonitorsDialogConfirm": "",
@@ -583,6 +581,13 @@
},
"errors": {
"validation": ""
},
"fields": {
"password": {
"errors": {
"incorrect": ""
}
}
}
},
"login": {
@@ -709,13 +714,13 @@
"integrationKeyLabel": "",
"integrationKeyPlaceholder": ""
},
"genericWebhookSettings": {
"discordSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
},
"discordSettings": {
"webhookSettings": {
"title": "",
"description": "",
"webhookLabel": "",
@@ -740,5 +745,6 @@
"settingsEmailRejectUnauthorized": "",
"settingsEmailSecure": "",
"settingsEmailPool": "",
"sendTestNotifications": ""
"sendTestNotifications": "",
"selectAll": ""
}
+11 -5
View File
@@ -23,9 +23,7 @@
"settingsClearAllStatsDialogConfirm": "Oui, effacer les stats",
"settingsDemoMonitors": "",
"settingsDemoMonitorsDescription": "",
"settingsAddDemoMonitors": "",
"settingsAddDemoMonitorsButton": "",
"settingsRemoveAllMonitors": "",
"settingsRemoveAllMonitorsButton": "",
"settingsRemoveAllMonitorsDialogTitle": "",
"settingsRemoveAllMonitorsDialogConfirm": "",
@@ -583,6 +581,13 @@
},
"errors": {
"validation": ""
},
"fields": {
"password": {
"errors": {
"incorrect": ""
}
}
}
},
"login": {
@@ -709,13 +714,13 @@
"integrationKeyLabel": "",
"integrationKeyPlaceholder": ""
},
"genericWebhookSettings": {
"discordSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
},
"discordSettings": {
"webhookSettings": {
"title": "",
"description": "",
"webhookLabel": "",
@@ -740,5 +745,6 @@
"settingsEmailRejectUnauthorized": "",
"settingsEmailSecure": "",
"settingsEmailPool": "",
"sendTestNotifications": ""
"sendTestNotifications": "",
"selectAll": ""
}
+12 -5
View File
@@ -23,9 +23,7 @@
"settingsClearAllStatsDialogConfirm": "Sim, limpar todas as estatísticas",
"settingsDemoMonitors": "Monitores de demonstração",
"settingsDemoMonitorsDescription": "Adicione monitores de genéricos para fins de demonstração.",
"settingsAddDemoMonitors": "Adicionando monitores de demonstração",
"settingsAddDemoMonitorsButton": "Adicionar monitores de demonstração",
"settingsRemoveAllMonitors": "Removendo todos os monitores",
"settingsRemoveAllMonitorsButton": "Remover todos os monitores",
"settingsRemoveAllMonitorsDialogTitle": "Você deseja remover todos os monitores?",
"settingsRemoveAllMonitorsDialogConfirm": "Sim, remova todos os monitores",
@@ -312,6 +310,7 @@
"statusPageCreateTabsContentFeaturesDescription": "Mostrar mais detalhes na página de status",
"showCharts": "Mostrar gráficos",
"showUptimePercentage": "Mostrar porcentagem de Uptime",
"showAdminLoginLink": "Mostrar o link \"Administrador? Efetue login aqui\" na página de status",
"removeLogo": "Remover logo",
"statusPageStatus": "Uma página de status pública não está configurada.",
"statusPageStatusContactAdmin": "Entre em contato com seu administrador",
@@ -583,6 +582,13 @@
},
"errors": {
"validation": ""
},
"fields": {
"password": {
"errors": {
"incorrect": ""
}
}
}
},
"login": {
@@ -709,13 +715,13 @@
"integrationKeyLabel": "",
"integrationKeyPlaceholder": ""
},
"genericWebhookSettings": {
"discordSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
},
"discordSettings": {
"webhookSettings": {
"title": "",
"description": "",
"webhookLabel": "",
@@ -740,5 +746,6 @@
"settingsEmailRejectUnauthorized": "",
"settingsEmailSecure": "",
"settingsEmailPool": "",
"sendTestNotifications": ""
"sendTestNotifications": "",
"selectAll": ""
}
+11 -5
View File
@@ -23,9 +23,7 @@
"settingsClearAllStatsDialogConfirm": "Да, очистить всю статистику",
"settingsDemoMonitors": "Демо мониторы",
"settingsDemoMonitorsDescription": "Здесь вы можете добавлять и удалять демонстрационные мониторы.",
"settingsAddDemoMonitors": "Добавьте демонстрационные мониторы",
"settingsAddDemoMonitorsButton": "Добавьте демонстрационные мониторы",
"settingsRemoveAllMonitors": "Удалить все демонстрационные мониторы",
"settingsRemoveAllMonitorsButton": "Удалить все демонстрационные мониторы",
"settingsRemoveAllMonitorsDialogTitle": "Хотите удалить все мониторы?",
"settingsRemoveAllMonitorsDialogConfirm": "Да, очистить все мониторы",
@@ -583,6 +581,13 @@
},
"errors": {
"validation": ""
},
"fields": {
"password": {
"errors": {
"incorrect": ""
}
}
}
},
"login": {
@@ -709,13 +714,13 @@
"integrationKeyLabel": "",
"integrationKeyPlaceholder": ""
},
"genericWebhookSettings": {
"discordSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
},
"discordSettings": {
"webhookSettings": {
"title": "",
"description": "",
"webhookLabel": "",
@@ -740,5 +745,6 @@
"settingsEmailRejectUnauthorized": "",
"settingsEmailSecure": "",
"settingsEmailPool": "",
"sendTestNotifications": ""
"sendTestNotifications": "",
"selectAll": ""
}
+131 -125
View File
@@ -23,9 +23,7 @@
"settingsClearAllStatsDialogConfirm": "Evet, tüm istatistikleri temizle",
"settingsDemoMonitors": "Demo monitörler",
"settingsDemoMonitorsDescription": "Burada demo monitörler ekleyebilir ve kaldırabilirsiniz.",
"settingsAddDemoMonitors": "Demo monitörler ekle",
"settingsAddDemoMonitorsButton": "Demo monitörler ekle",
"settingsRemoveAllMonitors": "Tüm monitörleri kaldır",
"settingsRemoveAllMonitorsButton": "Tüm monitörleri kaldır",
"settingsRemoveAllMonitorsDialogTitle": "Tüm monitörleri kaldırmak istiyor musunuz?",
"settingsRemoveAllMonitorsDialogConfirm": "Evet, tüm monitörleri temizle",
@@ -117,11 +115,11 @@
"title": "",
"checks": [""]
},
"createButton": "",
"createTitle": "",
"createButton": "Bildirim kanalı oluştur",
"createTitle": "Bildirim kanalı",
"create": {
"success": "",
"failed": ""
"failed": "Bildirim kanalı oluşturulamadı"
},
"fetch": {
"success": "",
@@ -136,7 +134,7 @@
"failed": ""
},
"test": {
"success": "",
"success": "Test bildirimi başarıyla gönderildi",
"failed": ""
}
},
@@ -373,33 +371,33 @@
"pageSpeedApiKeyFieldDescription": "Sayfa hızı izlemeyi etkinleştirmek için Google PageSpeed API anahtarınızı girin. Anahtarı güncellemek için Sıfırla'ya tıklayın.",
"pageSpeedApiKeyFieldResetLabel": "API anahtarı ayarlandı. Değiştirmek için Sıfırla'ya tıklayın.",
"reset": "Sıfırla",
"ignoreTLSError": "",
"tlsErrorIgnored": "",
"ignoreTLSError": "TLS/SSL hatalarını gözardı et",
"tlsErrorIgnored": "TLS/SSL hataları gözardı ediliyor",
"ignoreTLSErrorDescription": "",
"createNew": "",
"createNew": "Yeni oluştur",
"greeting": {
"prepend": "",
"prepend": "Merhaba",
"append": "",
"overview": ""
},
"roles": {
"superAdmin": "",
"admin": "",
"teamMember": "",
"demoUser": ""
"superAdmin": "Süper yönetici",
"admin": "Yönetici",
"teamMember": "Ekip elemanı",
"demoUser": "Demo kullanıcısı"
},
"teamPanel": {
"teamMembers": "",
"teamMembers": "Ekip elemanları",
"filter": {
"all": "",
"member": ""
"all": "Tümü",
"member": "Eleman"
},
"inviteTeamMember": "",
"inviteNewTeamMember": "",
"inviteTeamMember": "Ekip elemanı davet et",
"inviteNewTeamMember": "Yeni ekip elemanı davet et",
"inviteDescription": "",
"email": "",
"selectRole": "",
"inviteLink": "",
"email": "Eposta",
"selectRole": "Rolü seçin",
"inviteLink": "Davet linki",
"cancel": "",
"noMembers": "",
"getToken": "",
@@ -422,21 +420,21 @@
"infrastructure": "",
"incidents": "",
"statusPages": "",
"maintenance": "",
"integrations": "",
"settings": "",
"support": "",
"discussions": "",
"docs": "",
"changelog": "",
"profile": "",
"password": "",
"team": "",
"maintenance": "Bakım",
"integrations": "Entegrasyonlar",
"settings": "Ayarlar",
"support": "Destek",
"discussions": "Soru-cevap",
"docs": "Dokümanlar",
"changelog": "Değişiklik günlüğü",
"profile": "Profil",
"password": "Parola",
"team": "Ekip",
"logOut": "Çıkış yap",
"notifications": ""
"notifications": "Bildirimler"
},
"settingsEmail": "",
"settingsEmailDescription": "",
"settingsEmail": "Eposta",
"settingsEmailDescription": "Sistem için eposta ayarlarını düzenleyin. Bu işlem eposta üzerinden bildirim ve alarm almak için gereklidir.",
"settingsEmailHost": "",
"settingsEmailPort": "",
"settingsEmailAddress": "",
@@ -490,73 +488,73 @@
"port": ""
},
"common": {
"appName": "",
"appName": "Checkmate",
"monitoringAgentName": "",
"buttons": {
"toggleTheme": ""
},
"toasts": {
"networkError": "",
"checkConnection": "",
"unknownError": ""
"checkConnection": "Lütfen bağlantınızı kontrol edin",
"unknownError": "Bilinmeyen hata"
}
},
"auth": {
"common": {
"navigation": {
"continue": "",
"back": ""
"continue": "Devam et",
"back": "Geri"
},
"inputs": {
"email": {
"label": "",
"label": "Eposta",
"placeholder": "",
"errors": {
"empty": "",
"empty": "Devam etmek için eposta adresinizi girin",
"invalid": ""
}
},
"password": {
"label": "",
"label": "Parola",
"rules": {
"length": {
"beginning": "",
"highlighted": ""
"highlighted": "8 karakter uzunluğunda"
},
"special": {
"beginning": "",
"highlighted": ""
"beginning": "En az içermelidir: ",
"highlighted": "bir özel karakter"
},
"number": {
"beginning": "",
"highlighted": ""
"beginning": "En az içermelidir: ",
"highlighted": "bir sayı"
},
"uppercase": {
"beginning": "",
"highlighted": ""
"beginning": "En az içermelidir: ",
"highlighted": "Bir büyük harf"
},
"lowercase": {
"beginning": "",
"highlighted": ""
"beginning": "En az içermelidir: ",
"highlighted": "Bir küçük harf"
},
"match": {
"beginning": "",
"highlighted": ""
"highlighted": "eşleşmelidir"
}
},
"errors": {
"empty": "",
"length": "",
"uppercase": "",
"lowercase": "",
"number": "",
"special": "",
"incorrect": ""
"empty": "Lütfen parolanızı girin",
"length": "Parolanız en az 8 karakter uzunluğunda olmalıdır",
"uppercase": "Parola en az 1 büyük harf içermelidir",
"lowercase": "Parola en az 1 küçük harf içermelidir",
"number": "Parola en az 1 sayı içermelidir",
"special": "Parola en az 1 özel karakter içermelidir",
"incorrect": "Parolanız sistemdeki ile uyuşmuyor"
}
},
"passwordConfirm": {
"label": "",
"placeholder": "",
"label": "Parolayı onayla",
"placeholder": "Onaylamak için parolayı yeniden gir",
"errors": {
"empty": "",
"different": ""
@@ -572,78 +570,85 @@
}
},
"lastName": {
"label": "",
"placeholder": "",
"label": "Soyadı",
"placeholder": "Ellis",
"errors": {
"empty": "",
"length": "",
"empty": "Lütfen soyadınızı girin",
"length": "Soyadı en çok 50 karakter olmalıdır",
"pattern": ""
}
}
},
"errors": {
"validation": ""
},
"fields": {
"password": {
"errors": {
"incorrect": ""
}
}
}
},
"login": {
"heading": "",
"subheadings": {
"stepOne": "",
"stepTwo": ""
"stepOne": "Eposta adresinizi girin",
"stepTwo": "Parolanızı girin"
},
"links": {
"forgotPassword": "",
"register": "",
"forgotPasswordLink": "",
"registerLink": ""
"forgotPassword": "Parolanızı mı unuttunuz?",
"register": "Hesabınız yok mu?",
"forgotPasswordLink": "Parolayı sıfırla",
"registerLink": "Buradan kayıt olun"
},
"toasts": {
"success": "",
"incorrectPassword": ""
"success": "Tekrar hoşgeldiniz! Başarıyla sisteme giriş yaptınız.",
"incorrectPassword": "Hatalı parola"
},
"errors": {
"password": {
"incorrect": ""
"incorrect": "Giriş yaptığınız parola sistemdekiyle uyuşmuyor"
}
}
},
"registration": {
"heading": {
"superAdmin": "",
"user": ""
"superAdmin": "Süper yönetici oluştur",
"user": "Kayıt ol"
},
"subheadings": {
"stepOne": "",
"stepTwo": "",
"stepThree": ""
"stepOne": "Kişisel bilgilerinizi girin",
"stepTwo": "Eposta adresinizi girin",
"stepThree": "Parolanızı oluturun"
},
"description": {
"superAdmin": "",
"superAdmin": "Başlamak için süper yönetici hesabı oluşturun",
"user": ""
},
"gettingStartedButton": {
"superAdmin": "",
"superAdmin": "Süper yönetici hesabı oluşturun",
"user": ""
},
"termsAndPolicies": "",
"links": {
"login": ""
"login": "Bir hesabınız mı var? <a>Giriş yapın</a>"
},
"toasts": {
"success": ""
"success": "Hoşgeldiniz! Hesabınız başarıyla oluşturuldu."
}
},
"forgotPassword": {
"heading": "",
"heading": "Parolanızı mı kaybettiniz?",
"subheadings": {
"stepOne": "",
"stepTwo": "",
"stepOne": "Endişelenmeyin, sıfırlamak için gerekli bilgileri göndereceğiz.",
"stepTwo": "Parola sıfırlama için gerekli bilgileri <email/> hesabına ilettik.",
"stepThree": "",
"stepFour": ""
},
"buttons": {
"openEmail": "",
"resetPassword": ""
"openEmail": "Eposta uygulamasını aç",
"resetPassword": "Parolayı sıfırla"
},
"imageAlts": {
"passwordKey": "",
@@ -670,75 +675,76 @@
"reconnected": "",
"stillUnreachable": ""
},
"alertBox": "",
"alertBox": "Sunucu bağlantı hatası",
"description": "",
"retryButton": {
"default": "",
"processing": ""
"default": "Bağlantıyı tekrarla",
"processing": "Bağlanıyor..."
}
}
},
"createNotifications": {
"title": "",
"title": "Bildirim kanalları oluştur",
"nameSettings": {
"title": "",
"description": "",
"nameLabel": "",
"namePlaceholder": ""
"title": "İsim",
"description": "Entegrasyon için açıklama",
"nameLabel": "İsim",
"namePlaceholder": "örn. Slack bildirimleri"
},
"typeSettings": {
"title": "",
"title": "Tür",
"description": "",
"typeLabel": ""
"typeLabel": "Tür"
},
"emailSettings": {
"title": "",
"description": "",
"emailLabel": "",
"emailPlaceholder": ""
"title": "Eposta",
"description": "Hedef eposta adresi",
"emailLabel": "Eposta adresi",
"emailPlaceholder": "örn. john@example.com"
},
"slackSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
"title": "Slack",
"description": "Slack webhook'u burada tanımlayın",
"webhookLabel": "Slack webhook adresi",
"webhookPlaceholder": "https://hooks.slack.com/services/...\n"
},
"pagerdutySettings": {
"title": "",
"description": "",
"integrationKeyLabel": "",
"integrationKeyPlaceholder": ""
},
"genericWebhookSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
"title": "PagerDuty",
"description": "PagerDuty entegrasyonunu burada yapın",
"integrationKeyLabel": "Entegrasyon anahtarı",
"integrationKeyPlaceholder": "1234567890"
},
"discordSettings": {
"title": "",
"title": "Discord",
"description": "Discord webhook'u buradan ayarlayın",
"webhookLabel": "Discord Webhook adresi",
"webhookPlaceholder": "https://sunucu-adı.com/webhook\n"
},
"webhookSettings": {
"title": "Webhook",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
"webhookPlaceholder": "https://sunucu-adı.com/webhook\n"
}
},
"notificationConfig": {
"title": "",
"title": "Bildirimler",
"description": ""
},
"monitorStatus": {
"checkingEvery": "",
"withCaptureAgent": "",
"up": "",
"down": "",
"paused": ""
"up": "ayakta",
"down": "çalışmıyor",
"paused": "beklemede"
},
"advancedMatching": "",
"settingsEmailTLSServername": "",
"settingsEmailIgnoreTLS": "",
"settingsEmailRequireTLS": "",
"settingsEmailRejectUnauthorized": "",
"settingsEmailSecure": "",
"settingsEmailSecure": "Güvenli - SSL kullan",
"settingsEmailPool": "",
"sendTestNotifications": ""
"sendTestNotifications": "Bildirimleri dene",
"selectAll": "Tümünü seç"
}
+11 -5
View File
@@ -23,9 +23,7 @@
"settingsClearAllStatsDialogConfirm": "",
"settingsDemoMonitors": "",
"settingsDemoMonitorsDescription": "",
"settingsAddDemoMonitors": "",
"settingsAddDemoMonitorsButton": "",
"settingsRemoveAllMonitors": "",
"settingsRemoveAllMonitorsButton": "",
"settingsRemoveAllMonitorsDialogTitle": "",
"settingsRemoveAllMonitorsDialogConfirm": "",
@@ -583,6 +581,13 @@
},
"errors": {
"validation": ""
},
"fields": {
"password": {
"errors": {
"incorrect": ""
}
}
}
},
"login": {
@@ -709,13 +714,13 @@
"integrationKeyLabel": "",
"integrationKeyPlaceholder": ""
},
"genericWebhookSettings": {
"discordSettings": {
"title": "",
"description": "",
"webhookLabel": "",
"webhookPlaceholder": ""
},
"discordSettings": {
"webhookSettings": {
"title": "",
"description": "",
"webhookLabel": "",
@@ -740,5 +745,6 @@
"settingsEmailRejectUnauthorized": "",
"settingsEmailSecure": "",
"settingsEmailPool": "",
"sendTestNotifications": ""
"sendTestNotifications": "",
"selectAll": ""
}
+23 -8
View File
@@ -1,12 +1,27 @@
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import { execSync } from "child_process";
// https://vitejs.dev/config/
export default defineConfig({
base: "/",
plugins: [svgr(), react()],
optimizeDeps: {
include: ["@mui/material/Tooltip", "@emotion/styled"],
},
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
let version;
try {
version =
env.VITE_APP_VERSION ||
execSync("git describe --tags --abbrev=0").toString().trim();
} catch (error) {
version = "unknown";
}
return {
base: "/",
plugins: [svgr(), react()],
optimizeDeps: {
include: ["@mui/material/Tooltip", "@emotion/styled"],
},
define: {
__APP_VERSION__: JSON.stringify(version),
},
};
});
+1
View File
@@ -10,4 +10,5 @@ prod/redis/data/*
dist/docker-compose-test.yaml
/dist-mono/mongo/data/*
/dist-mono/redis/data/*
/dist-arm/mongo/data/*
*.env
+1 -10
View File
@@ -1,6 +1,6 @@
services:
server:
image: ghcr.io/bluewave-labs/checkmate:backend-dist-mono-multiarch
image: ghcr.io/bluewave-labs/checkmate-backend-mono-multiarch:latest
restart: always
ports:
- "52345:52345"
@@ -12,16 +12,7 @@ services:
- CLIENT_HOST=http://localhost
- JWT_SECRET=my_secret
depends_on:
- redis
- mongodb
redis:
image: redis:7
container_name: checkmate-redis
ports:
- "6379:6379"
volumes:
- ./redis/data:/data
restart: unless-stopped
mongodb:
image: mongo:4.4.18
container_name: checkmate-mongodb
-12
View File
@@ -12,19 +12,7 @@ services:
- CLIENT_HOST=http://localhost
- JWT_SECRET=my_secret
depends_on:
- redis
- mongodb
redis:
image: ghcr.io/bluewave-labs/checkmate-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: ghcr.io/bluewave-labs/checkmate-mongo:latest
restart: always
-12
View File
@@ -16,7 +16,6 @@ services:
ports:
- "52345:52345"
depends_on:
- redis
- mongodb
environment:
- DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db?replicaSet=rs0
@@ -25,17 +24,6 @@ services:
- JWT_SECRET=my_secret
# volumes:
# - /var/run/docker.sock:/var/run/docker.sock:ro
redis:
image: ghcr.io/bluewave-labs/checkmate-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: ghcr.io/bluewave-labs/checkmate-mongo:latest
restart: always
+2 -12
View File
@@ -21,6 +21,8 @@ services:
volumes:
- ./certbot/www/:/var/www/certbot/:rw
- ./certbot/conf/:/etc/letsencrypt/:rw
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot; sleep 12h & wait $${!}; done;'"
server:
image: ghcr.io/bluewave-labs/checkmate:backend-demo
restart: always
@@ -29,21 +31,9 @@ services:
env_file:
- server.env
depends_on:
- redis
- mongodb
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
redis:
image: ghcr.io/bluewave-labs/checkmate:redis-demo
restart: always
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-demo
restart: always
+3 -11
View File
@@ -82,7 +82,7 @@ class AuthController {
}
const newUser = await this.db.insertUser({ ...req.body }, req.file);
logger.info({
this.logger.info({
message: this.stringService.authCreateUser,
service: SERVICE_NAME,
details: newUser._id,
@@ -111,7 +111,7 @@ class AuthController {
});
});
} catch (error) {
logger.warn({
this.logger.warn({
message: error.message,
service: SERVICE_NAME,
method: "registerUser",
@@ -412,8 +412,7 @@ class AuthController {
// 1. Find all the monitors associated with the team ID if superadmin
const result = await this.db.getMonitorsByTeamId({
query: {},
params: { teamId: user.teamId.toString() },
teamId: user.teamId.toString(),
});
if (user.role.includes("superadmin")) {
@@ -424,13 +423,6 @@ class AuthController {
await this.jobQueue.deleteJob(monitor);
})
));
// 3. Delete team
await this.db.deleteTeam(user.teamId);
// 4. Delete all other team members
await this.db.deleteAllOtherUsers();
// 5. Delete each monitor
await this.db.deleteMonitorsByUserId(user._id);
}
// 6. Delete the user by id
await this.db.deleteUser(user._id);
+23
View File
@@ -0,0 +1,23 @@
import { handleError } from "./controllerUtils.js";
const SERVICE_NAME = "JobQueueController";
class LogController {
constructor(logger) {
this.logger = logger;
}
getLogs = async (req, res, next) => {
try {
const logs = await this.logger.getLogs();
res.success({
msg: "Logs fetched successfully",
data: logs,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getLogs"));
return;
}
};
}
export default LogController;
+37 -1
View File
@@ -681,7 +681,7 @@ class MonitorController {
explain,
});
return res.success({
msg: "OK", // TODO
msg: "OK",
data: result,
});
} catch (error) {
@@ -698,6 +698,42 @@ class MonitorController {
next(handleError(error, SERVICE_NAME, "seedDb"));
}
};
exportMonitorsToCSV = async (req, res, next) => {
try {
const { teamId } = req.user;
const monitors = await this.db.getMonitorsByTeamId({ teamId });
if (!monitors || monitors.length === 0) {
return res.success({
msg: this.stringService.noMonitorsFound,
data: null,
});
}
const csvData = monitors?.filteredMonitors?.map((monitor) => ({
name: monitor.name,
description: monitor.description,
type: monitor.type,
url: monitor.url,
interval: monitor.interval,
port: monitor.port,
ignoreTlsErrors: monitor.ignoreTlsErrors,
isActive: monitor.isActive,
}));
const csv = pkg.unparse(csvData);
return res.file({
data: csv,
headers: {
"Content-Type": "text/csv",
"Content-Disposition": "attachment; filename=monitors.csv",
},
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "exportMonitorsToCSV"));
}
};
}
export default MonitorController;
+1 -73
View File
@@ -23,85 +23,13 @@ class NotificationController {
this.stringService = stringService;
this.statusService = statusService;
this.db = db;
this.triggerNotification = this.triggerNotification.bind(this);
}
async triggerNotification(req, res, next) {
try {
await triggerNotificationBodyValidation.validateAsync(req.body, {
abortEarly: false,
stripUnknown: true,
});
} catch (error) {
next(handleValidationError(error, SERVICE_NAME));
return;
}
try {
const { monitorId, type, platform, config, status = false } = req.body;
// Create a simplified networkResponse similar to what would come from monitoring
const networkResponse = {
monitorId,
status,
};
// Use the statusService to get monitor details and handle status change logic
// This returns { monitor, statusChanged, prevStatus } exactly like your job queue uses
const statusResult = await this.statusService.updateStatus(networkResponse);
if (type === NOTIFICATION_TYPES.WEBHOOK) {
const notification = {
type,
platform,
config,
};
await this.notificationService.sendWebhookNotification(
statusResult, // Contains monitor, statusChanged, and prevStatus
notification
);
}
return res.success({
msg: this.stringService.webhookSendSuccess,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "triggerNotification"));
}
}
createTestNetworkResponse() {
return {
monitor: {
_id: "test-monitor-id",
name: "Test Monitor",
url: "https://example.com",
},
status: true,
statusChanged: true,
prevStatus: false,
};
}
testNotification = async (req, res, next) => {
try {
const notification = req.body;
let success;
if (notification?.type === "email") {
success = await this.notificationService.sendTestEmail(notification);
}
if (notification?.type === "webhook") {
success =
await this.notificationService.sendTestWebhookNotification(notification);
}
if (notification?.type === "pager_duty") {
success =
await this.notificationService.sendTestPagerDutyNotification(notification);
}
const success = await this.notificationService.sendTestNotification(notification);
if (!success) {
return res.error({
+15 -1
View File
@@ -25,7 +25,7 @@ class JobQueueController {
try {
const jobs = await this.jobQueue.getJobs();
return res.success({
msg: this.stringService.queueGetMetrics,
msg: this.stringService.queueGetJobs,
data: jobs,
});
} catch (error) {
@@ -34,6 +34,20 @@ class JobQueueController {
}
};
getAllMetrics = async (req, res, next) => {
try {
const jobs = await this.jobQueue.getJobs();
const metrics = await this.jobQueue.getMetrics();
return res.success({
msg: this.stringService.queueGetAllMetrics,
data: { jobs, metrics },
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getAllMetrics"));
return;
}
};
addJob = async (req, res, next) => {
try {
await this.jobQueue.addJob(Math.random().toString(36).substring(7));
+62 -7
View File
@@ -69,6 +69,15 @@ const MonitorSchema = mongoose.Schema(
type: Number,
default: undefined,
},
notifications: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "Notification",
},
],
secret: {
type: String,
},
thresholds: {
type: {
usage_cpu: { type: Number },
@@ -78,14 +87,33 @@ const MonitorSchema = mongoose.Schema(
},
_id: false,
},
notifications: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "Notification",
alertThreshold: {
type: Number,
default: 5,
},
cpuAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
memoryAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
diskAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
tempAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
],
secret: {
type: String,
},
},
{
@@ -132,6 +160,33 @@ MonitorSchema.pre("deleteMany", async function (next) {
next();
});
MonitorSchema.pre("save", function (next) {
if (!this.cpuAlertThreshold || this.isModified("alertThreshold")) {
this.cpuAlertThreshold = this.alertThreshold;
}
if (!this.memoryAlertThreshold || this.isModified("alertThreshold")) {
this.memoryAlertThreshold = this.alertThreshold;
}
if (!this.diskAlertThreshold || this.isModified("alertThreshold")) {
this.diskAlertThreshold = this.alertThreshold;
}
if (!this.tempAlertThreshold || this.isModified("alertThreshold")) {
this.tempAlertThreshold = this.alertThreshold;
}
next();
});
MonitorSchema.pre("findOneAndUpdate", function (next) {
const update = this.getUpdate();
if (update.alertThreshold) {
update.cpuAlertThreshold = update.alertThreshold;
update.memoryAlertThreshold = update.alertThreshold;
update.diskAlertThreshold = update.alertThreshold;
update.tempAlertThreshold = update.alertThreshold;
}
next();
});
MonitorSchema.index({ teamId: 1, type: 1 });
export default mongoose.model("Monitor", MonitorSchema);
+46
View File
@@ -0,0 +1,46 @@
import mongoose from "mongoose";
import { BaseCheckSchema } from "./Check.js";
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 captureSchema = mongoose.Schema({
version: { type: String, default: "" },
mode: { type: String, default: "" },
});
const NetworkCheckSchema = mongoose.Schema(
{
...BaseCheckSchema.obj,
data: {
type: [networkInterfaceSchema],
default: () => [],
},
capture: {
type: captureSchema,
default: () => ({}),
},
errors: {
type: mongoose.Schema.Types.Mixed,
default: null,
},
},
{ timestamps: true }
);
NetworkCheckSchema.index({ createdAt: 1 });
NetworkCheckSchema.index({ monitorId: 1, createdAt: 1 });
NetworkCheckSchema.index({ monitorId: 1, createdAt: -1 });
export default mongoose.model("NetworkCheck", NetworkCheckSchema);
+1 -50
View File
@@ -1,19 +1,5 @@
import mongoose from "mongoose";
const configSchema = mongoose.Schema(
{
webhookUrl: { type: String },
botToken: { type: String },
chatId: { type: String },
platform: {
type: String,
enum: ["slack", "pager_duty", "webhook", "discord"],
},
routingKey: { type: String },
},
{ _id: false }
);
const NotificationSchema = mongoose.Schema(
{
userId: {
@@ -30,14 +16,7 @@ const NotificationSchema = mongoose.Schema(
},
type: {
type: String,
enum: ["email", "sms", "webhook", "pager_duty"],
},
platform: {
type: String,
},
config: {
type: configSchema,
default: () => ({}),
enum: ["email", "slack", "discord", "webhook", "pager_duty"],
},
notificationName: {
type: String,
@@ -49,34 +28,6 @@ const NotificationSchema = mongoose.Schema(
phone: {
type: String,
},
alertThreshold: {
type: Number,
default: 5,
},
cpuAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
memoryAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
diskAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
tempAlertThreshold: {
type: Number,
default: function () {
return this.alertThreshold;
},
},
},
{
timestamps: true,
+4
View File
@@ -70,6 +70,10 @@ const StatusPageSchema = mongoose.Schema(
type: Boolean,
default: true,
},
showAdminLoginLink: {
type: Boolean,
default: false,
},
},
{ timestamps: true }
);
+22
View File
@@ -1,6 +1,9 @@
import mongoose from "mongoose";
import bcrypt from "bcryptjs";
import logger from "../../utils/logger.js";
import Monitor from "./Monitor.js";
import Team from "./Team.js";
import Notification from "./Notification.js";
const UserSchema = mongoose.Schema(
{
@@ -74,6 +77,25 @@ UserSchema.pre("findOneAndUpdate", function (next) {
next();
});
UserSchema.pre("findOneAndDelete", async function (next) {
try {
const userToDelete = await this.model.findOne(this.getFilter());
if (!userToDelete) return next();
if (userToDelete.role.includes("superadmin")) {
await Team.deleteOne({ _id: userToDelete.teamId });
await Monitor.deleteMany({ userId: userToDelete._id });
await this.model.deleteMany({
teamId: userToDelete.teamId,
_id: { $ne: userToDelete._id },
});
await Notification.deleteMany({ teamId: userToDelete.teamId });
}
next();
} catch (error) {
next(error);
}
});
UserSchema.methods.comparePassword = async function (submittedPassword) {
const res = await bcrypt.compare(submittedPassword, this.password);
return res;
@@ -0,0 +1,46 @@
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<object>} 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;
}
};
/**
* 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<Array<object>>} 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 };
@@ -153,6 +153,7 @@ const getStatusPage = async (url) => {
showCharts: 1,
showUptimePercentage: 1,
timezone: 1,
showAdminLoginLink: 1,
url: 1,
},
monitors: {
+17 -3
View File
@@ -36,6 +36,9 @@ 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";
@@ -73,6 +76,7 @@ import StatusService from "./service/statusService.js";
// Notification Service and dependencies
import NotificationService from "./service/notificationService.js";
import NotificationUtils from "./service/notificationUtils.js";
// Buffer Service and dependencies
import BufferService from "./service/bufferService.js";
@@ -173,13 +177,19 @@ const startApp = async () => {
);
const bufferService = new BufferService({ db, logger });
const statusService = new StatusService({ db, logger, buffer: bufferService });
const notificationService = new NotificationService(
const notificationUtils = new NotificationUtils({
stringService,
emailService,
});
const notificationService = new NotificationService({
emailService,
db,
logger,
networkService,
stringService
);
stringService,
notificationUtils,
});
const redisService = new RedisService({ Redis: IORedis, logger });
@@ -287,6 +297,8 @@ const startApp = async () => {
ServiceRegistry.get(StringService.SERVICE_NAME)
);
const logController = new LogController(logger);
const statusPageController = new StatusPageController(
ServiceRegistry.get(MongoDB.SERVICE_NAME),
ServiceRegistry.get(StringService.SERVICE_NAME)
@@ -313,6 +325,7 @@ const startApp = async () => {
maintenanceWindowController
);
const queueRoutes = new QueueRoutes(queueController);
const logRoutes = new LogRoutes(logController);
const statusPageRoutes = new StatusPageRoutes(statusPageController);
const notificationRoutes = new NotificationRoutes(notificationController);
@@ -365,6 +378,7 @@ const startApp = async () => {
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());
+1
View File
@@ -123,6 +123,7 @@
"monitorCertificate": "Got monitor certificate successfully",
"monitorDemoAdded": "Successfully added demo monitors",
"queueGetMetrics": "Got metrics successfully",
"queueGetJobs": "Got jobs successfully",
"queueAddJob": "Job added successfully",
"queueObliterate": "Queue obliterated",
"jobQueueDeleteJobSuccess": "Job removed successfully",
+21 -1
View File
@@ -16,7 +16,12 @@ const responseHandler = (req, res, next) => {
* @param {*} [options.data=null] - Response data payload
* @returns {Object} Express response object
*/
res.success = ({ status = 200, msg = "OK", data = null }) => {
res.success = ({ status = 200, msg = "OK", data = null, headers = {} }) => {
// Set custom headers if provided
Object.entries(headers).forEach(([key, value]) => {
res.set(key, value);
});
return res.status(status).json({
success: true,
msg: msg,
@@ -40,6 +45,21 @@ const responseHandler = (req, res, next) => {
data,
});
};
/**
* Sends a raw file response (for CSV, PDF, etc.)
* @param {Object} options
* @param {Buffer|string} options.data - The file content
* @param {Object} options.headers - Headers to set (e.g. Content-Type, Content-Disposition)
* @param {number} [options.status=200] - HTTP status code
*/
res.file = ({ data, headers = {}, status = 200 }) => {
Object.entries(headers).forEach(([key, value]) => {
res.setHeader(key, value);
});
return res.status(status).send(data);
};
next();
};
+8 -2
View File
@@ -1,14 +1,20 @@
import logger from "../utils/logger.js";
import ServiceRegistry from "../service/serviceRegistry.js";
import StringService from "../service/stringService.js";
import { ObjectId } from "mongodb";
const SERVICE_NAME = "verifyOwnership";
const verifyOwnership = (Model, paramName) => {
const stringService = ServiceRegistry.get(StringService.SERVICE_NAME);
return async (req, res, next) => {
const userId = req.user._id;
const documentId = req.params[paramName];
let documentId = req.params[paramName];
try {
if (typeof documentId === "string") {
documentId = ObjectId.createFromHexString(documentId);
}
const doc = await Model.findById(documentId);
//If the document is not found, return a 404 error
if (!doc) {
@@ -35,7 +41,7 @@ const verifyOwnership = (Model, paramName) => {
// If the userID does not match the document's userID, return a 403 error
if (userId.toString() !== doc.userId.toString()) {
const error = new Error(stringService.verifyOwnerUnauthorized);
const error = new Error("Unauthorized");
error.status = 403;
throw error;
}
+38
View File
@@ -0,0 +1,38 @@
const SERVICE_NAME = "verifyTeamAccess";
const verifyTeamAccess = (Model, paramName) => {
return async (req, res, next) => {
try {
const documentId = req.params[paramName];
const doc = await Model.findById(documentId);
if (!doc) {
const error = new Error("Document not found");
error.status = 404;
throw error;
}
if (!req?.user?.teamId || !doc.teamId) {
const error = new Error("Missing team information");
error.status = 400;
throw error;
}
if (req.user.teamId.toString() === doc.teamId.toString()) {
next();
return;
}
const error = new Error("Unauthorized");
error.status = 403;
throw error;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "verifyTeamAccess";
next(error);
return;
}
};
};
export { verifyTeamAccess };
+18
View File
@@ -0,0 +1,18 @@
import { Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
class LogRoutes {
constructor(logController) {
this.router = Router();
this.logController = logController;
this.initRoutes();
}
initRoutes() {
this.router.get("/", isAllowed(["admin", "superadmin"]), this.logController.getLogs);
}
getRouter() {
return this.router;
}
}
export default LogRoutes;
+12 -3
View File
@@ -1,7 +1,8 @@
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) {
this.router = Router();
@@ -24,9 +25,17 @@ class MaintenanceWindowRoutes {
this.router.get("/:id", this.maintenanceWindowController.getMaintenanceWindowById);
this.router.put("/:id", this.maintenanceWindowController.editMaintenanceWindow);
this.router.put(
"/:id",
verifyTeamAccess(MaintenanceWindow, "id"),
this.maintenanceWindowController.editMaintenanceWindow
);
this.router.delete("/:id", this.maintenanceWindowController.deleteMaintenanceWindow);
this.router.delete(
"/:id",
verifyTeamAccess(MaintenanceWindow, "id"),
this.maintenanceWindowController.deleteMaintenanceWindow
);
}
getRouter() {
+10 -1
View File
@@ -2,7 +2,9 @@ import { Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import multer from "multer";
import { fetchMonitorCertificate } from "../controllers/controllerUtils.js";
import Monitor from "../db/models/Monitor.js";
import { verifyOwnership } from "../middleware/verifyOwnership.js";
import { verifyTeamAccess } from "../middleware/verifyTeamAccess.js";
const upload = multer({
storage: multer.memoryStorage(), // Store file in memory as Buffer
});
@@ -17,6 +19,11 @@ class MonitorRoutes {
initRoutes() {
this.router.get("/", this.monitorController.getAllMonitors);
this.router.get("/uptime", this.monitorController.getAllMonitorsWithUptimeStats);
this.router.get(
"/export",
isAllowed(["admin", "superadmin"]),
this.monitorController.exportMonitorsToCSV
);
this.router.get("/stats/:monitorId", this.monitorController.getMonitorStatsById);
this.router.get(
"/hardware/details/:monitorId",
@@ -57,6 +64,7 @@ class MonitorRoutes {
this.router.delete(
"/:monitorId",
verifyOwnership(Monitor, "monitorId"),
isAllowed(["admin", "superadmin"]),
this.monitorController.deleteMonitor
);
@@ -69,6 +77,7 @@ class MonitorRoutes {
this.router.put(
"/:monitorId",
verifyTeamAccess(Monitor, "monitorId"),
isAllowed(["admin", "superadmin"]),
this.monitorController.editMonitor
);
+13 -5
View File
@@ -1,6 +1,8 @@
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 +13,6 @@ class NotificationRoutes {
initializeRoutes() {
this.router.use(verifyJWT);
this.router.post("/trigger", this.notificationController.triggerNotification);
this.router.post("/test", this.notificationController.testNotification);
this.router.post("/test/all", this.notificationController.testAllNotifications);
@@ -20,10 +20,18 @@ class NotificationRoutes {
this.router.get("/team", this.notificationController.getNotificationsByTeamId);
this.router.delete("/: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", this.notificationController.editNotification);
this.router.put(
"/:id",
verifyTeamAccess(Notification, "id"),
this.notificationController.editNotification
);
}
getRouter() {
+6
View File
@@ -19,6 +19,12 @@ class QueueRoutes {
this.queueController.getJobs
);
this.router.get(
"/all-metrics",
isAllowed(["admin", "superadmin"]),
this.queueController.getAllMetrics
);
this.router.post(
"/jobs",
isAllowed(["admin", "superadmin"]),
+23 -2
View File
@@ -1,5 +1,4 @@
import { Pulse } from "@pulsecron/pulse";
import { ObjectId } from "mongodb";
const SERVICE_NAME = "PulseQueue";
class PulseQueue {
@@ -58,6 +57,9 @@ class PulseQueue {
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();
};
@@ -137,6 +139,8 @@ class PulseQueue {
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++;
@@ -148,13 +152,22 @@ class PulseQueue {
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: [] }
{
jobs: 0,
activeJobs: 0,
failingJobs: 0,
jobsWithFailures: [],
totalRuns: 0,
totalFailures: 0,
}
);
return metrics;
};
@@ -165,10 +178,18 @@ class PulseQueue {
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,
};
});
};
+4 -10
View File
@@ -432,9 +432,9 @@ class NetworkService {
throw err;
}
async requestWebhook(platform, url, message) {
async requestWebhook(type, url, body) {
try {
const response = await this.axios.post(url, message, {
const response = await this.axios.post(url, body, {
headers: {
"Content-Type": "application/json",
},
@@ -444,7 +444,7 @@ class NetworkService {
type: "webhook",
status: true,
code: response.status,
message: `Successfully sent ${platform} notification`,
message: `Successfully sent ${type} notification`,
payload: response.data,
};
} catch (error) {
@@ -452,19 +452,13 @@ class NetworkService {
message: error.message,
service: this.SERVICE_NAME,
method: "requestWebhook",
url,
platform,
error: error.message,
statusCode: error.response?.status,
responseData: error.response?.data,
requestPayload: message,
});
return {
type: "webhook",
status: false,
code: error.response?.status || this.NETWORK_ERROR,
message: `Failed to send ${platform} notification`,
message: `Failed to send ${type} notification`,
payload: error.response?.data,
};
}
+83 -472
View File
@@ -1,512 +1,123 @@
const SERVICE_NAME = "NotificationService";
const TELEGRAM_API_BASE_URL = "https://api.telegram.org/bot";
const PLATFORM_TYPES = ["telegram", "slack", "discord", "webhook"];
const MESSAGE_FORMATTERS = {
telegram: (messageText, chatId) => ({ chat_id: chatId, text: messageText }),
slack: (messageText) => ({ text: messageText }),
discord: (messageText) => ({ content: messageText }),
webhook: (messageText) => ({ text: messageText }),
};
class NotificationService {
static SERVICE_NAME = SERVICE_NAME;
/**
* Creates an instance of NotificationService.
*
* @param {Object} emailService - The email service used for sending notifications.
* @param {Object} db - The database instance for storing notification data.
* @param {Object} logger - The logger instance for logging activities.
* @param {Object} networkService - The network service for sending webhook notifications.
*/
constructor(emailService, db, logger, networkService, stringService) {
this.SERVICE_NAME = SERVICE_NAME;
constructor({
emailService,
db,
logger,
networkService,
stringService,
notificationUtils,
}) {
this.emailService = emailService;
this.db = db;
this.logger = logger;
this.networkService = networkService;
this.stringService = stringService;
this.notificationUtils = notificationUtils;
}
/**
* Formats a notification message based on the monitor status and platform.
*
* @param {Object} monitor - The monitor object.
* @param {string} monitor.name - The name of the monitor.
* @param {string} monitor.url - The URL of the monitor.
* @param {boolean} status - The current status of the monitor (true for up, false for down).
* @param {string} platform - The notification platform (e.g., "telegram", "slack", "discord").
* @param {string} [chatId] - The chat ID for platforms that require it (e.g., Telegram).
* @returns {Object|null} The formatted message object for the specified platform, or null if the platform is unsupported.
*/
sendNotification = async ({ notification, subject, content, html }) => {
const { type, address } = notification;
formatNotificationMessage(monitor, status, platform, chatId, code, timestamp) {
// Format timestamp using the local system timezone
const formatTime = (timestamp) => {
const date = new Date(timestamp);
// Get timezone abbreviation and format the date
const timeZoneAbbr = date
.toLocaleTimeString("en-US", { timeZoneName: "short" })
.split(" ")
.pop();
// Format the date with readable format
return (
date
.toLocaleString("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
.replace(/(\d+)\/(\d+)\/(\d+),\s/, "$3-$1-$2 ") +
" " +
timeZoneAbbr
);
};
// Get formatted time
const formattedTime = timestamp
? formatTime(timestamp)
: formatTime(new Date().getTime());
// Create different messages based on status with extra spacing
let messageText;
if (status === true) {
messageText = this.stringService.monitorUpAlert
.replace("{monitorName}", monitor.name)
.replace("{time}", formattedTime)
.replace("{code}", code || "Unknown");
} else {
messageText = this.stringService.monitorDownAlert
.replace("{monitorName}", monitor.name)
.replace("{time}", formattedTime)
.replace("{code}", code || "Unknown");
}
if (!PLATFORM_TYPES.includes(platform)) {
return undefined;
}
return MESSAGE_FORMATTERS[platform](messageText, chatId);
}
formatHardwareNotificationMessage = (
monitor,
status,
platform,
chatId,
code,
timestamp,
alerts
) => {
const messageText = alerts.map((alert) => alert).join("\n");
return MESSAGE_FORMATTERS[platform](messageText, chatId);
};
sendTestWebhookNotification = async (notification) => {
const config = notification.config;
const response = await this.networkService.requestWebhook(
config.platform,
config.webhookUrl,
"This is a test notification"
);
return response.status;
};
/**
* Sends a webhook notification to a specified platform.
*
* @param {Object} networkResponse - The response object from the network.
* @param {Object} networkResponse.monitor - The monitor object.
* @param {boolean} networkResponse.status - The monitor's status (true for up, false for down).
* @param {Object} notification - The notification settings.
* @param {string} notification.platform - The target platform ("telegram", "slack", "discord").
* @param {Object} notification.config - The configuration object for the webhook.
* @param {string} notification.config.webhookUrl - The webhook URL for the platform.
* @param {string} [notification.config.botToken] - The bot token for Telegram notifications.
* @param {string} [notification.config.chatId] - The chat ID for Telegram notifications.
* @returns {Promise<boolean>} A promise that resolves to true if the notification was sent successfully, otherwise false.
*/
async sendWebhookNotification(networkResponse, notification, alerts = []) {
const { monitor, status, code } = networkResponse;
const { webhookUrl, platform, botToken, chatId } = notification.config;
// Early return if platform is not supported
if (!PLATFORM_TYPES.includes(platform)) {
this.logger.warn({
message: this.stringService.getWebhookUnsupportedPlatform(platform),
service: this.SERVICE_NAME,
method: "sendWebhookNotification",
details: { platform },
});
return false;
}
// Early return for telegram if required fields are missing
if (platform === "telegram" && (!botToken || !chatId)) {
this.logger.warn({
message: "Missing required fields for Telegram notification",
service: this.SERVICE_NAME,
method: "sendWebhookNotification",
details: { platform },
});
return false;
}
let url = webhookUrl;
if (platform === "telegram") {
url = `${TELEGRAM_API_BASE_URL}${botToken}/sendMessage`;
}
let message;
if (monitor.type === "hardware") {
message = this.formatHardwareNotificationMessage(
monitor,
status,
platform,
chatId,
code,
networkResponse.timestamp,
alerts
);
} else {
message = this.formatNotificationMessage(
monitor,
status,
platform,
chatId,
code, // Pass the code field directly
networkResponse.timestamp
);
}
try {
const response = await this.networkService.requestWebhook(platform, url, message);
return response.status;
} catch (error) {
this.logger.error({
message: this.stringService.getWebhookSendError(platform),
service: this.SERVICE_NAME,
method: "sendWebhookNotification",
stack: error.stack,
});
return false;
}
}
/**
* Sends an email notification for hardware infrastructure alerts
*
* @async
* @function sendHardwareEmail
* @param {Object} networkResponse - Response object containing monitor information
* @param {string} address - Email address to send the notification to
* @param {Array} [alerts=[]] - List of hardware alerts to include in the email
* @returns {Promise<boolean>} - Indicates whether email was sent successfully
* @throws {Error}
*/
async sendHardwareEmail(networkResponse, address, alerts = []) {
if (alerts.length === 0) return false;
const { monitor, status, prevStatus } = networkResponse;
const template = "hardwareIncidentTemplate";
const context = { monitor: monitor.name, url: monitor.url, alerts };
const subject = `Monitor ${monitor.name} infrastructure alerts`;
const html = await this.emailService.buildEmail(template, context);
this.emailService.sendEmail(address, subject, html).catch((error) => {
this.logger.warn({
message: error.message,
service: this.SERVICE_NAME,
method: "sendHardwareEmail",
stack: error.stack,
});
});
return true;
}
sendTestEmail = async (notification) => {
const to = notification?.address;
if (!to || typeof to !== "string") {
throw new Error(this.stringService.errorForValidEmailAddress);
}
const subject = this.stringService.testEmailSubject;
const context = { testName: "Monitoring System" };
try {
const html = await this.emailService.buildEmail("testEmailTemplate", context);
const messageId = await this.emailService.sendEmail(to, subject, html);
if (messageId) {
return true;
}
} catch (error) {
this.logger.warn({
message: error.message,
service: this.SERVICE_NAME,
method: "sendTestEmail",
stack: error.stack,
});
return false;
}
};
/**
* Sends an email notification about monitor status change
*
* @async
* @function sendEmail
* @param {Object} networkResponse - Response object containing monitor status information
* @param {string} address - Email address to send the notification to
* @returns {Promise<boolean>} - Indicates email was sent successfully
*/
async sendEmail(networkResponse, address) {
const { monitor, status, prevStatus } = networkResponse;
const template = prevStatus === false ? "serverIsUpTemplate" : "serverIsDownTemplate";
const context = { monitor: monitor.name, url: monitor.url };
const subject = `Monitor ${monitor.name} is ${status === true ? "up" : "down"}`;
const html = await this.emailService.buildEmail(template, context);
this.emailService.sendEmail(address, subject, html).catch((error) => {
this.logger.warn({
message: error.message,
service: this.SERVICE_NAME,
method: "sendEmail",
stack: error.stack,
});
});
return true;
}
async sendTestPagerDutyNotification(notification) {
const { routingKey } = notification.config;
const response = await this.networkService.requestPagerDuty({
message: "This is a test notification",
monitorUrl: "Test notification",
routingKey,
});
return response;
}
async sendPagerDutyNotification(networkResponse, notification, alerts = []) {
const { monitor, status, code } = networkResponse;
const { routingKey, platform } = notification.config;
let message;
if (monitor.type === "hardware") {
message = this.formatHardwareNotificationMessage(
monitor,
status,
platform,
null,
code,
networkResponse.timestamp,
alerts
);
} else {
message = this.formatNotificationMessage(
monitor,
status,
platform,
null,
code, // Pass the code field directly
networkResponse.timestamp
);
}
try {
const response = await this.networkService.requestPagerDuty({
message,
routingKey,
monitorUrl: monitor.url,
});
return response;
} catch (error) {
this.logger.error({
message: "Failed to send PagerDuty notification",
details: error.details,
service: error.service || this.SERVICE_NAME,
method: error.method || "sendPagerDutyNotification",
stack: error.stack,
});
return false;
}
}
async handleStatusNotifications(networkResponse) {
try {
// If status hasn't changed, we're done
if (networkResponse.statusChanged === false) return false;
// if prevStatus is undefined, monitor is resuming, we're done
if (networkResponse.prevStatus === undefined) return false;
const notificationIDs = networkResponse.monitor?.notifications ?? [];
const notifications = await this.db.getNotificationsByIds(notificationIDs);
for (const notification of notifications) {
if (notification.type === "email") {
await this.sendEmail(networkResponse, notification.address);
} else if (notification.type === "webhook") {
await this.sendWebhookNotification(networkResponse, notification);
} else if (notification.type === "pager_duty") {
await this.sendPagerDutyNotification(networkResponse, notification);
}
}
if (type === "email") {
const messageId = await this.emailService.sendEmail(address, subject, html);
if (!messageId) return false;
return true;
} catch (error) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
method: "handleNotifications",
stack: error.stack,
});
}
}
/**
* Handles status change notifications for a monitor
*
* @async
* @function handleStatusNotifications
* @param {Object} networkResponse - Response object containing monitor status information
* @returns {Promise<boolean>} - Indicates whether notifications were processed
* @throws {Error}
*/
async handleHardwareNotifications(networkResponse) {
const thresholds = networkResponse?.monitor?.thresholds;
if (thresholds === undefined) return false; // No thresholds set, we're done
// Get thresholds from monitor
const {
usage_cpu: cpuThreshold = -1,
usage_memory: memoryThreshold = -1,
usage_disk: diskThreshold = -1,
} = thresholds;
// Create a body for webhooks
let body = { text: content };
if (type === "discord") {
body = { content };
}
// Get metrics from response
const metrics = networkResponse?.payload?.data ?? null;
if (metrics === null) return false;
if (type === "slack" || type === "discord" || type === "webhook") {
const response = await this.networkService.requestWebhook(type, address, body);
return response.status;
}
if (type === "pager_duty") {
const response = await this.networkService.requestPagerDuty({
message: content,
monitorUrl: subject,
routingKey: address,
});
const {
cpu: { usage_percent: cpuUsage = -1 } = {},
memory: { usage_percent: memoryUsage = -1 } = {},
disk = [],
} = metrics;
return response;
}
};
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,
};
async handleNotifications(networkResponse) {
const { monitor, statusChanged, prevStatus } = networkResponse;
const { type } = monitor;
if (type !== "hardware" && statusChanged === false) return false;
// if prevStatus is undefined, monitor is resuming, we're done
if (type !== "hardware" && prevStatus === undefined) return false;
const notificationIDs = networkResponse.monitor?.notifications ?? [];
const notifications = await this.db.getNotificationsByIds(notificationIDs);
if (notificationIDs.length === 0) return false;
for (const notification of notifications) {
const alertsToSend = [];
const alertTypes = ["cpu", "memory", "disk"];
if (networkResponse.monitor.type === "hardware") {
const thresholds = networkResponse?.monitor?.thresholds;
for (const type of alertTypes) {
// Iterate over each alert type to see if any need to be decremented
if (alerts[type] === true) {
notification[`${type}AlertThreshold`]--; // Decrement threshold if an alert is triggered
if (thresholds === undefined) return false; // No thresholds set, we're done
const metrics = networkResponse?.payload?.data ?? null;
if (metrics === null) return false; // No metrics, we're done
if (notification[`${type}AlertThreshold`] <= 0) {
// If threshold drops below 0, reset and send notification
notification[`${type}AlertThreshold`] = notification.alertThreshold;
const alerts = await this.notificationUtils.buildHardwareAlerts(networkResponse);
if (alerts.length === 0) return false;
const formatAlert = {
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)}%)`,
};
alertsToSend.push(formatAlert[type]());
}
}
}
const { subject, html } = await this.notificationUtils.buildHardwareEmail(
networkResponse,
alerts
);
const content =
await this.notificationUtils.buildHardwareNotificationMessage(alerts);
await notification.save();
if (alertsToSend.length === 0) continue; // No alerts to send, we're done
if (notification.type === "email") {
await this.sendHardwareEmail(networkResponse, notification.address, alertsToSend);
} else if (notification.type === "webhook") {
await this.sendWebhookNotification(networkResponse, notification, alertsToSend);
} else if (notification.type === "pager_duty") {
await this.sendPagerDutyNotification(networkResponse, notification, alertsToSend);
}
const success = await this.notifyAll({ notificationIDs, subject, html, content });
return success;
}
return true;
// Status monitors
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;
}
/**
* Handles notifications for different monitor types
*
* @async
* @function handleNotifications
* @param {Object} networkResponse - Response object containing monitor information
* @returns {Promise<boolean>} - Indicates whether notifications were processed successfully
*/
async handleNotifications(networkResponse) {
try {
if (networkResponse.monitor.type === "hardware") {
this.handleHardwareNotifications(networkResponse);
}
this.handleStatusNotifications(networkResponse);
return true;
} catch (error) {
this.logger.error({
message: error.message,
service: this.SERVICE_NAME,
method: "handleNotifications",
stack: error.stack,
});
}
}
async testAllNotifications(notificationIDs) {
async notifyAll({ notificationIDs, subject, html, content }) {
const notifications = await this.db.getNotificationsByIds(notificationIDs);
// Map each notification to a test promise
const testPromises = notifications.map(async (notification) => {
const promises = notifications.map(async (notification) => {
try {
if (notification.type === "email") {
await this.sendTestEmail(notification);
} else if (notification.type === "webhook") {
await this.sendTestWebhookNotification(notification);
} else if (notification.type === "pager_duty") {
await this.sendTestPagerDutyNotification(notification);
}
await this.sendNotification({ notification, subject, content, html });
return true;
} catch (err) {
return false;
}
});
const results = await Promise.all(testPromises);
const results = await Promise.all(promises);
return results.every((r) => r === true);
}
async getTestNotification() {
const html = await this.notificationUtils.buildTestEmail();
const content = "This is a test notification";
const subject = "Test Notification";
return { subject, html, content };
}
async testAllNotifications(notificationIDs) {
const { subject, html, content } = await this.getTestNotification();
return this.notifyAll({ notificationIDs, subject, html, content });
}
async sendTestNotification(notification) {
const { subject, html, content } = await this.getTestNotification();
const success = await this.sendNotification({ notification, subject, content, html });
return success;
}
}
export default NotificationService;
+146
View File
@@ -0,0 +1,146 @@
class NotificationUtils {
constructor({ stringService, emailService }) {
this.stringService = stringService;
this.emailService = emailService;
}
buildTestEmail = async () => {
const context = { testName: "Monitoring System" };
const html = await this.emailService.buildEmail("testEmailTemplate", context);
return html;
};
buildStatusEmail = async (networkResponse) => {
const { monitor, status, prevStatus } = networkResponse;
const template = prevStatus === false ? "serverIsUpTemplate" : "serverIsDownTemplate";
const context = { monitor: monitor.name, url: monitor.url };
const subject = `Monitor ${monitor.name} is ${status === true ? "up" : "down"}`;
const html = await this.emailService.buildEmail(template, context);
return { subject, html };
};
buildWebhookMessage = (networkResponse) => {
const { monitor, status, code, timestamp } = networkResponse;
// Format timestamp using the local system timezone
const formatTime = (timestamp) => {
const date = new Date(timestamp);
// Get timezone abbreviation and format the date
const timeZoneAbbr = date
.toLocaleTimeString("en-US", { timeZoneName: "short" })
.split(" ")
.pop();
// Format the date with readable format
return (
date
.toLocaleString("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
.replace(/(\d+)\/(\d+)\/(\d+),\s/, "$3-$1-$2 ") +
" " +
timeZoneAbbr
);
};
// Get formatted time
const formattedTime = timestamp
? formatTime(timestamp)
: formatTime(new Date().getTime());
// Create different messages based on status with extra spacing
let messageText;
if (status === true) {
messageText = this.stringService.monitorUpAlert
.replace("{monitorName}", monitor.name)
.replace("{time}", formattedTime)
.replace("{code}", code || "Unknown");
} else {
messageText = this.stringService.monitorDownAlert
.replace("{monitorName}", monitor.name)
.replace("{time}", formattedTime)
.replace("{code}", code || "Unknown");
}
return messageText;
};
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 metrics = networkResponse?.payload?.data;
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,
};
const alertsToSend = [];
const alertTypes = ["cpu", "memory", "disk"];
for (const type of alertTypes) {
// Iterate over each alert type to see if any need to be decremented
if (alerts[type] === true) {
monitor[`${type}AlertThreshold`]--; // Decrement threshold if an alert is triggered
if (monitor[`${type}AlertThreshold`] <= 0) {
// If threshold drops below 0, reset and send notification
monitor[`${type}AlertThreshold`] = monitor.alertThreshold;
const formatAlert = {
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)}%)`,
};
alertsToSend.push(formatAlert[type]());
}
}
}
await monitor.save();
return alertsToSend;
};
buildHardwareEmail = async (networkResponse, alerts) => {
const { monitor } = networkResponse;
const template = "hardwareIncidentTemplate";
const context = { monitor: monitor.name, url: monitor.url, alerts };
const subject = `Monitor ${monitor.name} infrastructure alerts`;
const html = await this.emailService.buildEmail(template, context);
return { subject, html };
};
buildHardwareNotificationMessage = (alerts) => {
return alerts.map((alert) => alert).join("\n");
};
}
export default NotificationUtils;
+16
View File
@@ -276,6 +276,10 @@ class StringService {
return this.translationService.getTranslation("queueGetMetrics");
}
get queueGetJobs() {
return this.translationService.getTranslation("queueGetJobs");
}
get queueAddJob() {
return this.translationService.getTranslation("queueAddJob");
}
@@ -432,6 +436,18 @@ class StringService {
get testEmailSubject() {
return this.translationService.getTranslation("testEmailSubject");
}
get verifyOwnerNotFound() {
return this.translationService.getTranslation("verifyOwnerNotFound");
}
get verifyOwnerUnauthorized() {
return this.translationService.getTranslation("verifyOwnerUnauthorized");
}
get dbUserNotFound() {
return this.translationService.getTranslation("dbUserNotFound");
}
}
export default StringService;
+33 -18
View File
@@ -4,6 +4,8 @@ dotenv.config();
class Logger {
constructor() {
this.logCache = [];
this.maxCacheSize = 1000;
const consoleFormat = format.printf(
({ level, message, service, method, details, timestamp, stack }) => {
if (message instanceof Object) {
@@ -72,11 +74,9 @@ class Logger {
* @param {Object} config.details - Additional details.
*/
info(config) {
this.logger.info(config.message, {
service: config.service,
method: config.method,
details: config.details,
});
const logEntry = this.buildLogEntry("info", config);
this.cacheLog(logEntry);
this.logger.info(config.message, logEntry);
}
/**
@@ -88,11 +88,9 @@ class Logger {
* @param {Object} config.details - Additional details.
*/
warn(config) {
this.logger.warn(config.message, {
service: config.service,
method: config.method,
details: config.details,
});
const logEntry = this.buildLogEntry("warn", config);
this.cacheLog(logEntry);
this.logger.warn(config.message, logEntry);
}
/**
@@ -104,12 +102,9 @@ class Logger {
* @param {Object} config.details - Additional details.
*/
error(config) {
this.logger.error(config.message, {
service: config.service,
method: config.method,
details: config.details,
stack: config.stack,
});
const logEntry = this.buildLogEntry("error", config);
this.cacheLog(logEntry);
this.logger.error(config.message, logEntry);
}
/**
* Logs a debug message.
@@ -120,12 +115,32 @@ class Logger {
* @param {Object} config.details - Additional details.
*/
debug(config) {
this.logger.debug(config.message, {
const logEntry = this.buildLogEntry("debug", config);
this.cacheLog(logEntry);
this.logger.debug(config.message, logEntry);
}
cacheLog(entry) {
this.logCache.push(entry);
if (this.logCache.length > this.maxCacheSize) {
this.logCache.shift();
}
}
getLogs() {
return this.logCache;
}
buildLogEntry(level, config) {
return {
level,
message: config.message,
service: config.service,
method: config.method,
details: config.details,
stack: config.stack,
});
timestamp: new Date().toISOString(),
};
}
}
+9 -17
View File
@@ -478,6 +478,7 @@ const createStatusPageBodyValidation = joi.object({
isPublished: joi.boolean(),
showCharts: joi.boolean().optional(),
showUptimePercentage: joi.boolean(),
showAdminLoginLink: joi.boolean().optional(),
});
const imageValidation = joi
@@ -585,24 +586,15 @@ const createNotificationBodyValidation = joi.object({
"string.email": "Please enter a valid e-mail address",
}),
}),
type: joi.string().valid("email", "webhook", "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",
}),
config: joi.when("type", {
is: "webhook",
then: joi.object({
webhookUrl: joi.string().uri().required().messages({
"string.uri": "Webhook URL must be a valid URI",
"any.required": "Webhook URL is required",
}),
platform: joi.string().required().messages({
"string.empty": "Platform is required",
"any.required": "Platform 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",
}),
}),
});
//****************************************