Compare commits

..
Author SHA1 Message Date
gorkem-bwl 056e40be8f chore(deps): bump rollup (transitive)
Patches CVE-2026-27606 (high — arbitrary file write via path traversal in Rollup 4).

Transitive bump within existing semver range; no package.json change.
2026-04-30 01:45:06 -04:00
100 changed files with 5849 additions and 14276 deletions

No files matched your search

+2 -14
View File
@@ -159,26 +159,14 @@ When working on anything related to check scheduling, incident lifecycle, or not
## Code Conventions
### Coding conventions (mandatory)
Read `docs/coding-conventions.md` before touching any `.tsx` or `.ts` file. The doc covers both frontend and backend rules, all enforced in code review.
**Universal — rule 0:** look at peer files first and follow the established pattern. Don't sneak in novel shapes.
**Frontend (`client/src`):**
### Frontend conventions (mandatory for `client/src`)
Read `docs/frontend-conventions.md` before touching any `.tsx` file. Five rules, all enforced in code review:
1. Prefer MUI native props over `sx` (e.g. `color={…}`, `bgcolor={…}`, `mt={…}` — not `sx={{ color, bgcolor, mt }}`).
2. Use the full theme path for colors: `color={theme.palette.text.secondary}`, never `color="text.secondary"` (greppability).
3. No hardcoded literals — use `LAYOUT.*`, `typographyLevels.*`, `theme.shape.borderRadius`, `theme.palette.*`.
4. Use `useTheme()` inside components; don't `import { theme } from "@/Utils/Theme/Theme"`.
5. Pair runtime tuples with derived types: `const X = [...] as const; type X = (typeof X)[number]`.
**Backend (`server/src`):**
6. Layering: Controller → Service → Repository → Mongoose. No DB calls outside repositories.
7. Provider conventions: DI for stdlib clients, `SERVICE_NAME` + `TIMEOUT_MS` constants, `timeRequest()` helper, inline `setTimeout` race for timeout, outer try/catch into `AppError`.
8. Mongoose schema fields with closed value sets must declare `enum` reusing the same `types/*` const tuple.
9. Provider tests at `server/test/unit/providers/network/<name>.test.ts`, `from "@jest/globals"`, `testStatusProviderContract`, inline `setup()` per test (not `beforeEach`).
10. Centralize validation enums in `types/*` const tuples; never inline `z.enum([…])` more than once.
11. New entity fields must land in the validator's `*ResponseSchema` so the auto-generated OpenAPI spec sees them.
### Internationalization
All user-facing strings must use the translation function:
```javascript
+1
View File
@@ -7,6 +7,7 @@
![](https://img.shields.io/github/languages/top/bluewave-labs/checkmate)
![](https://img.shields.io/github/issues/bluewave-labs/checkmate)
![](https://img.shields.io/github/issues-pr/bluewave-labs/checkmate)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9901/badge)](https://www.bestpractices.dev/projects/9901)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate)
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
+131 -146
View File
@@ -18,7 +18,7 @@
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
"@types/maplibre-gl": "^1.13.2",
"axios": "^1.15.2",
"axios": "^1.7.4",
"dayjs": "1.11.13",
"html2canvas": "^1.4.1",
"human-interval": "2.0.1",
@@ -59,7 +59,7 @@
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "^6.4.2"
"vite": "6.4.1"
}
},
"node_modules/@babel/code-frame": {
@@ -1635,9 +1635,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
"cpu": [
"arm"
],
@@ -1648,9 +1648,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
"cpu": [
"arm64"
],
@@ -1661,9 +1661,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
"cpu": [
"arm64"
],
@@ -1674,9 +1674,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
"cpu": [
"x64"
],
@@ -1687,9 +1687,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
"cpu": [
"arm64"
],
@@ -1700,9 +1700,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
"cpu": [
"x64"
],
@@ -1713,9 +1713,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
"cpu": [
"arm"
],
@@ -1726,9 +1726,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
"cpu": [
"arm"
],
@@ -1739,9 +1739,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
"cpu": [
"arm64"
],
@@ -1752,9 +1752,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
"cpu": [
"arm64"
],
@@ -1765,9 +1765,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
"cpu": [
"loong64"
],
@@ -1778,9 +1778,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
"cpu": [
"loong64"
],
@@ -1791,9 +1791,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
"cpu": [
"ppc64"
],
@@ -1804,9 +1804,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
"cpu": [
"ppc64"
],
@@ -1817,9 +1817,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
"cpu": [
"riscv64"
],
@@ -1830,9 +1830,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
"cpu": [
"riscv64"
],
@@ -1843,9 +1843,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
"cpu": [
"s390x"
],
@@ -1856,9 +1856,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
"cpu": [
"x64"
],
@@ -1869,9 +1869,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
"cpu": [
"x64"
],
@@ -1882,9 +1882,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
"cpu": [
"x64"
],
@@ -1895,9 +1895,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
"cpu": [
"arm64"
],
@@ -1908,9 +1908,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
"cpu": [
"arm64"
],
@@ -1921,9 +1921,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
"cpu": [
"ia32"
],
@@ -1934,9 +1934,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
"cpu": [
"x64"
],
@@ -1947,9 +1947,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
"cpu": [
"x64"
],
@@ -2925,14 +2925,14 @@
}
},
"node_modules/axios": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-plugin-macros": {
@@ -3239,9 +3239,9 @@
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"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"
@@ -4191,16 +4191,12 @@
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"version": "3.3.3",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"version": "1.15.11",
"funding": [
{
"type": "individual",
@@ -5317,9 +5313,7 @@
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"version": "4.17.23",
"license": "MIT"
},
"node_modules/lodash.merge": {
@@ -5476,9 +5470,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5887,9 +5881,7 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -5920,9 +5912,7 @@
}
},
"node_modules/postcss": {
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"version": "8.5.6",
"funding": [
{
"type": "opencollective",
@@ -6014,19 +6004,14 @@
"license": "MIT"
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
"version": "1.1.0",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -6434,9 +6419,9 @@
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -6449,31 +6434,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"@rollup/rollup-android-arm-eabi": "4.60.2",
"@rollup/rollup-android-arm64": "4.60.2",
"@rollup/rollup-darwin-arm64": "4.60.2",
"@rollup/rollup-darwin-x64": "4.60.2",
"@rollup/rollup-freebsd-arm64": "4.60.2",
"@rollup/rollup-freebsd-x64": "4.60.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
"@rollup/rollup-linux-arm64-musl": "4.60.2",
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
"@rollup/rollup-linux-loong64-musl": "4.60.2",
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
"@rollup/rollup-linux-x64-gnu": "4.60.2",
"@rollup/rollup-linux-x64-musl": "4.60.2",
"@rollup/rollup-openbsd-x64": "4.60.2",
"@rollup/rollup-openharmony-arm64": "4.60.2",
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
"@rollup/rollup-win32-x64-gnu": "4.60.2",
"@rollup/rollup-win32-x64-msvc": "4.60.2",
"fsevents": "~2.3.2"
}
},
@@ -7380,9 +7365,9 @@
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
@@ -7663,9 +7648,9 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"devOptional": true,
"license": "ISC",
"bin": {
+2 -2
View File
@@ -27,7 +27,7 @@
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
"@types/maplibre-gl": "^1.13.2",
"axios": "^1.15.2",
"axios": "^1.7.4",
"dayjs": "1.11.13",
"html2canvas": "^1.4.1",
"human-interval": "2.0.1",
@@ -68,6 +68,6 @@
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "^6.4.2"
"vite": "6.4.1"
}
}
@@ -5,7 +5,7 @@ import { getResponseColor } from "@/Utils/DataUtils";
import { HeatmapResponseTimeTooltip } from "@/Components/common/charts/HeatmapResponseTimeTooltip";
import type { SxProps } from "@mui/material/styles";
import type { ResponsiveStyleValue } from "@mui/system";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface PlaceholderCheck {
status: "placeholder";
responseTime: 0;
@@ -32,11 +32,11 @@ export const HeatmapResponseTime = ({
if (!checks || checks.length === 0) return null;
const latestChecks = checks.slice(-MAX_RECENT_CHECKS);
const latestChecks = checks.slice(-25);
let data: Array<CheckSnapshot | PlaceholderCheck>;
if (latestChecks.length !== MAX_RECENT_CHECKS) {
const placeholders = Array(MAX_RECENT_CHECKS - latestChecks.length).fill({
if (latestChecks.length !== 25) {
const placeholders = Array(25 - latestChecks.length).fill({
status: "placeholder" as const,
});
data = [...latestChecks, ...placeholders];
@@ -44,13 +44,13 @@ export const HeatmapResponseTime = ({
data = latestChecks;
}
return (
<Box width={"100%"}>
<Box sx={{ width: "100%" }}>
<Box
width={"100%"}
display={"grid"}
gap={gap}
sx={{
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gap,
alignItems: "stretch",
}}
>
@@ -82,50 +82,50 @@ export const HeatmapResponseTime = ({
check={check}
>
<Box
display={"grid"}
sx={{
display: "grid",
gridTemplateRows: "auto auto",
}}
>
<Box
display={"flex"}
gap={theme.spacing(2)}
sx={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(2),
}}
>
<Box
width={"100%"}
sx={{
position: "relative",
width: "100%",
aspectRatio: "10",
}}
>
<Box
bgcolor={statusBg}
borderRadius={theme.shape.borderRadius}
sx={{
position: "absolute",
inset: 0,
bgcolor: statusBg,
borderRadius: theme.spacing(0.5),
...availabilityCellSx,
}}
/>
<Box
borderRadius={theme.shape.borderRadius}
sx={{
position: "absolute",
inset: 0,
pointerEvents: "none",
borderRadius: theme.spacing(0.5),
boxShadow: `inset 0 0 0 2px ${statusBorder}`,
}}
/>
</Box>
<Box
width={"100%"}
bgcolor={respBg}
borderRadius={theme.shape.borderRadius}
sx={{
width: "100%",
aspectRatio: "1 / 1",
bgcolor: respBg,
borderRadius: theme.spacing(0.5),
...responseCellSx,
}}
/>
@@ -7,7 +7,6 @@ import type { CheckSnapshot } from "@/Types/Check";
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface HistogramResponseTimeProps {
checks: CheckSnapshot[];
@@ -58,11 +57,8 @@ export const HistogramResponseTime = ({
if (!Array.isArray(checks) || checks.length === 0) return null;
const data =
checks.length !== MAX_RECENT_CHECKS
? [
...checks,
...Array(MAX_RECENT_CHECKS - checks.length).fill({ status: "placeholder" }),
]
checks.length !== 25
? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })]
: checks;
const chartHeight = typeof height === "number" ? `${height}px` : height;
@@ -90,17 +86,17 @@ export const HistogramResponseTime = ({
<Stack
direction={statsPosition === "left" ? "row-reverse" : "row"}
alignItems="center"
width={"100%"}
sx={{ width: "100%" }}
>
<Box sx={{ flex: 1 }}>
<Box
width={"100%"}
display={"grid"}
gap={gridGap}
height={chartHeight}
sx={{
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gridGap,
alignItems: "end",
height: chartHeight,
}}
>
{data.map((check, index) => {
@@ -112,23 +108,23 @@ export const HistogramResponseTime = ({
: theme.palette.error.light;
const bar = (
<Box
width={"100%"}
height={"100%"}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
sx={{
position: "relative",
width: "100%",
height: "100%",
borderRadius: theme.spacing(1),
bgcolor: theme.palette.action.hover,
overflow: "hidden",
}}
>
<Box
width={"100%"}
height={isPlaceholder ? 0 : heightPct}
bgcolor={barColor}
sx={{
position: "absolute",
bottom: 0,
left: 0,
width: "100%",
height: isPlaceholder ? 0 : heightPct,
bgcolor: barColor,
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
@@ -137,8 +133,8 @@ export const HistogramResponseTime = ({
return isPlaceholder ? (
<Box
height={"100%"}
key={index}
sx={{ height: "100%" }}
>
{bar}
</Box>
@@ -35,9 +35,6 @@ import useMediaQuery from "@mui/material/useMediaQuery";
export type Header<T> = {
id: number | string;
content: React.ReactNode;
mobileLabel?: React.ReactNode;
hideMobileLabel?: boolean;
align?: "left" | "center" | "right" | "justify" | "inherit";
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
render: (row: T) => React.ReactNode;
};
@@ -112,24 +109,6 @@ export function DataTable<
key={key}
>
{headers.map((header) => {
if (header.hideMobileLabel) {
return (
<Grid2
container
key={header.id}
>
<Grid2
size={12}
display="flex"
alignItems="center"
justifyContent="flex-end"
>
{header.render(row)}
</Grid2>
</Grid2>
);
}
return (
<Grid2
container
@@ -144,7 +123,7 @@ export function DataTable<
component="div"
color={theme.palette.text.primary}
>
{header.mobileLabel ?? header.content}
{header.content}
</Typography>
</Grid2>
<Grid2
@@ -214,7 +193,7 @@ export function DataTable<
{headers.map((header, idx) => {
return (
<TableCell
align={header.align ?? (idx === 0 ? "left" : "center")}
align={idx === 0 ? "left" : "center"}
key={header.id}
>
{header.content}
@@ -243,7 +222,7 @@ export function DataTable<
{headers.map((header, index) => {
return (
<TableCell
align={header.align ?? (index === 0 ? "left" : "center")}
align={index === 0 ? "left" : "center"}
key={header.id}
onClick={
header.onClick ? (e) => header.onClick!(e, row) : undefined
@@ -1,102 +0,0 @@
import React from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
import Slide from "@mui/material/Slide";
import IconButton from "@mui/material/IconButton";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useSidebar } from "@/Hooks/useSidebar";
interface BulkActionsBarProps {
selectedCount: number;
onCancel: () => void;
children?: React.ReactNode;
}
export const BulkActionsBar = ({
selectedCount,
onCancel,
children,
}: BulkActionsBarProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isOpen = selectedCount > 0;
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { width, collapsedWidth, transition } = useSidebar();
return (
<Box
sx={{
position: "fixed",
bottom: theme.spacing(LAYOUT.XS),
left: isSmall ? collapsedWidth : width,
right: 0,
display: "flex",
justifyContent: "center",
pointerEvents: "none",
zIndex: theme.zIndex.snackbar,
transition: transition.replace("width", "left"),
}}
>
<Slide
direction="up"
in={isOpen}
mountOnEnter
unmountOnExit
>
<Paper
elevation={6}
sx={{
pointerEvents: "auto",
px: LAYOUT.MD,
py: LAYOUT.XS,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
display: "flex",
alignItems: "center",
gap: LAYOUT.XS,
boxShadow: theme.shadows[8],
}}
>
<Stack
direction="row"
alignItems="center"
gap={LAYOUT.XXS}
>
<Typography
variant="body1"
fontWeight={600}
color={theme.palette.text.primary}
>
{t("pages.common.monitors.actions.bulkSelected", {
count: selectedCount,
})}
</Typography>
<IconButton
size="small"
onClick={onCancel}
aria-label={t("common.buttons.cancel")}
>
<X size={18} />
</IconButton>
</Stack>
<Stack
direction="row"
alignItems="center"
gap={LAYOUT.XXS}
>
{children}
</Stack>
</Paper>
</Slide>
</Box>
);
};
-1
View File
@@ -14,4 +14,3 @@ export * from "./charts/HistogramPageSpeedDetails";
export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar";
-94
View File
@@ -1,94 +0,0 @@
import { useState, useEffect } from "react";
import axios from "axios";
import { post } from "@/Utils/ApiClient";
import { useToast } from "@/Hooks/UseToast";
import { useTranslation } from "react-i18next";
import { logger } from "@/Utils/logger";
import type { Monitor } from "@/Types/Monitor";
interface ApiResponse {
success: boolean;
msg: string;
data: Monitor[];
}
interface UseBulkMonitorActionsReturn {
selectedRows: string[];
setSelectedRows: (rows: string[]) => void;
handleBulkPause: () => Promise<void>;
handleBulkResume: () => Promise<void>;
handleCancelSelection: () => void;
}
export const useBulkMonitorActions = (
refetch: () => void,
page?: number
): UseBulkMonitorActionsReturn => {
const [selectedRows, setSelectedRows] = useState<string[]>([]);
const { toastSuccess, toastError, toastInfo } = useToast();
const { t } = useTranslation();
// Clear selection when page changes
useEffect(() => {
setSelectedRows([]);
}, [page]);
const executeBulkAction = async (pause: boolean) => {
try {
const res = await post<ApiResponse>("/monitors/bulk/pause", {
monitorIds: selectedRows,
pause,
});
const affectedCount = res.data?.data?.length ?? 0;
if (affectedCount === 0) {
const key = pause
? "pages.common.monitors.bulkPause.alreadyPaused"
: "pages.common.monitors.bulkPause.alreadyRunning";
toastInfo(t(key, { count: selectedRows.length }));
} else {
const key = pause
? "pages.common.monitors.bulkPause.paused"
: "pages.common.monitors.bulkPause.resumed";
toastSuccess(t(key, { count: affectedCount }));
}
setSelectedRows([]);
refetch();
} catch (err: unknown) {
let errMsg = "An error occurred";
if (axios.isAxiosError(err)) {
errMsg = err.response?.data?.msg || err.message || errMsg;
} else if (err instanceof Error) {
errMsg = err.message;
}
logger.error("Bulk pause/resume failed", err instanceof Error ? err : undefined, {
pause,
});
toastError(errMsg);
}
};
const handleBulkPause = async () => {
await executeBulkAction(true);
};
const handleBulkResume = async () => {
await executeBulkAction(false);
};
const handleCancelSelection = () => {
setSelectedRows([]);
};
return {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
};
};
+1 -2
View File
@@ -6,7 +6,6 @@ import {
DEFAULT_STATUS_PAGE_THEME_MODE,
} from "@/Types/StatusPage";
import type { Monitor } from "@/Types/Monitor";
import { resolveTimezone } from "@/Utils/TimeUtils";
interface UseStatusPageFormOptions {
data?: StatusPage | null;
@@ -31,7 +30,7 @@ export const useStatusPageForm = ({
const defaults: StatusPageFormData = {
companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(),
timezone: resolveTimezone(data?.timezone),
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
type: data?.type || ["uptime"],
color: data?.color || "#13715B",
monitors: data?.monitors || [],
+2 -2
View File
@@ -136,8 +136,8 @@ const getGeneralSettingsConfig = (
showIgnoreTls: false,
},
hardware: {
urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"),
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: false,
@@ -1,5 +1,4 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements";
import type { Header } from "@/Components/design-elements/Table";
@@ -13,24 +12,6 @@ import { useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import { Checkbox } from "@/Components/inputs";
interface InfraMonitorsTableProps {
monitors: Monitor[];
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
selectedRows?: string[];
onSelectionChange?: (selected: string[]) => void;
}
export const InfraMonitorsTable = ({
monitors,
@@ -45,32 +26,29 @@ export const InfraMonitorsTable = ({
setPage,
rowsPerPage,
setRowsPerPage,
selectedRows = [],
onSelectionChange,
}: InfraMonitorsTableProps) => {
}: {
monitors: Monitor[];
refetch: Function;
setSelectedMonitor: Function;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
const { post } = usePost<Record<string, never>, Monitor>();
const selectedSet = new Set(selectedRows);
const isAllSelected =
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
const handleSelectAll = (checked: boolean) => {
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
};
const handleSelectRow = (id: string, checked: boolean) => {
if (!onSelectionChange) return;
onSelectionChange(
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
);
};
const isRowSelected = (id: string) => selectedSet.has(id);
const {
post,
// loading: isPatching,
// error: postError,
} = usePost<any, Monitor>();
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
@@ -87,7 +65,7 @@ export const InfraMonitorsTable = ({
setRowsPerPage(value);
};
const handleSort = (e: React.MouseEvent, field: string) => {
const handleSort = (e: any, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
@@ -123,6 +101,13 @@ export const InfraMonitorsTable = ({
navigate(`/infrastructure/configure/${monitor.id}`);
},
},
// {
// id: 5,
// label: "Clone",
// action: () => {
// },
// },
{
id: 6,
label:
@@ -167,35 +152,8 @@ export const InfraMonitorsTable = ({
</Box>
);
const headers: Header<Monitor>[] = [
{
id: "selection",
content: (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectAll")}
indeterminate={isSomeSelected}
checked={isAllSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
),
hideMobileLabel: true,
render: (row) => (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectMonitor", {
name: row.name,
})}
checked={isRowSelected(row.id)}
onChange={(e) => {
handleSelectRow(row.id, e.target.checked);
}}
onClick={(e) => e.stopPropagation()}
/>
),
onClick: (e) => e?.stopPropagation(),
},
{
id: "name",
align: "left",
mobileLabel: t("common.table.headers.name"),
content: (
<Typography
component="div"
@@ -215,20 +173,18 @@ export const InfraMonitorsTable = ({
},
{
id: "status",
mobileLabel: t("common.table.headers.status"),
content: (
<Stack
<Typography
component="div"
display="inline-flex"
alignItems="center"
gap={theme.spacing(4)}
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
</Stack>
</Typography>
),
render: (row) => {
return <StatusLabel status={row.status} />;
@@ -292,11 +248,6 @@ export const InfraMonitorsTable = ({
onRowClick={(row) => {
navigate(`/infrastructure/${row.id}`);
}}
getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
/>
<Pagination
component="div"
@@ -2,13 +2,8 @@ import Stack from "@mui/material/Stack";
import useMediaQuery from "@mui/material/useMediaQuery";
import { MonitorBasePageWithStates } from "@/Components/design-elements";
import { HeaderCreate } from "@/Components/common";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { TextField, Dialog, Button } from "@/Components/inputs";
import { Play, Pause } from "lucide-react";
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
import { TextField, Dialog } from "@/Components/inputs";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
@@ -21,7 +16,6 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
const InfrastructureMonitors = () => {
const { t } = useTranslation();
@@ -43,7 +37,6 @@ const InfrastructureMonitors = () => {
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
@@ -104,15 +97,6 @@ const InfrastructureMonitors = () => {
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
const isLoading = monitorsWithChecksLoading;
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Check if any filters are active
const hasActiveFilters = Boolean(selectedStatus || selectedState || search);
@@ -175,29 +159,6 @@ const InfrastructureMonitors = () => {
}}
/>
</Stack>
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<InfraMonitorsTable
monitors={monitorsWithChecksData?.monitors || []}
refetch={refetch}
@@ -219,8 +180,6 @@ const InfrastructureMonitors = () => {
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
+25 -11
View File
@@ -10,7 +10,6 @@ import type { QueueData } from "@/Types/Queue";
import { Metrics } from "@/Pages/Logs/components/Metrics";
import { Button } from "@/Components/inputs";
import { EmptyState } from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
export const TabQueue = () => {
const theme = useTheme();
@@ -20,7 +19,7 @@ export const TabQueue = () => {
isLoading,
// error,
refetch,
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 1000 });
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 5000 });
const { post, loading: isFlushing } = usePost();
@@ -50,22 +49,32 @@ export const TabQueue = () => {
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
color="text.secondary"
>
{t("pages.logs.jobQueue")}
</Typography>
<Typography color={theme.palette.text.secondary}>
<Typography
sx={{
fontSize: 13,
color: theme.palette.text.secondary,
}}
>
<Trans
i18nKey="pages.logs.jobQueueExplainer"
components={{
highlight: (
<Box
component="span"
bgcolor={theme.palette.rowStatus.running}
color={theme.palette.text.primary}
px={SPACING.SM}
py={SPACING.XXS}
borderRadius={theme.shape.borderRadius}
sx={{
backgroundColor:
theme.palette.mode === "dark"
? "rgba(19, 113, 91, 0.18)"
: "#ECF7F2",
color: theme.palette.text.primary,
px: 1,
py: 0.25,
borderRadius: 0.5,
}}
/>
),
}}
@@ -78,11 +87,16 @@ export const TabQueue = () => {
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
color="text.secondary"
>
{t("pages.logs.failedJobs")}
</Typography>
<Typography color={theme.palette.text.secondary}>
<Typography
sx={{
fontSize: 13,
color: theme.palette.text.secondary,
}}
>
{t("pages.logs.failedJobsExplainer")}
</Typography>
</Stack>
+10 -41
View File
@@ -30,22 +30,6 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
};
const headers: Header<QueueJobWithId>[] = [
{
id: "state",
content: t("pages.logs.table.headers.state"),
render: (row) => {
if (row.lockedAt) {
return t("common.labels.running");
}
if (row.monitorActive === true) {
return t("common.labels.active");
}
if (row.monitorActive === false) {
return t("common.labels.paused");
}
return t("common.labels.idle");
},
},
{
id: "id",
content: t("common.table.headers.monitorId"),
@@ -63,10 +47,7 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
id: "url",
content: t("common.table.headers.url"),
render: (row) => (
<Typography
title={row.monitorUrl ?? ""}
sx={cellSx}
>
<Typography title={row.monitorUrl ?? ""} sx={cellSx}>
{row.monitorUrl}
</Typography>
),
@@ -79,15 +60,9 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
{
id: "interval",
content: t("common.table.headers.interval"),
render: (row) => {
let interval;
if (row.monitorId.toString().includes("geo")) {
interval = row.monitorGeoInterval ?? 0;
} else {
interval = row.repeat ?? 0;
}
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
},
render: (row) => (
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
),
},
{
id: "lastRun",
@@ -95,10 +70,7 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
render: (row) => {
const v = formatTimestamp(row.lastRunAt) ?? "-";
return (
<Typography
title={v}
sx={cellSx}
>
<Typography title={v} sx={cellSx}>
{v}
</Typography>
);
@@ -118,10 +90,7 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
render: (row) => {
const v = formatTimestamp(row.lockedAt) ?? "-";
return (
<Typography
title={v}
sx={cellSx}
>
<Typography title={v} sx={cellSx}>
{v}
</Typography>
);
@@ -129,16 +98,16 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
},
];
const isDark = theme.palette.mode === "dark";
const runningBg = isDark ? "rgba(19, 113, 91, 0.18)" : "#ECF7F2";
return (
<Table
headers={headers}
data={jobsWithId}
getRowSx={(row) => ({
...(row.lockedAt && {
"& td": { backgroundColor: theme.palette.rowStatus.running },
}),
...(row.monitorActive === false && {
"& td": { backgroundColor: theme.palette.rowStatus.paused },
"& td": { backgroundColor: runningBg },
}),
})}
/>
+1 -2
View File
@@ -3,7 +3,6 @@ import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common";
import type { CheckSnapshot } from "@/Types/Check";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface DummyChartProps {
chartType: string;
@@ -11,7 +10,7 @@ interface DummyChartProps {
const generateDummyChecks = (): CheckSnapshot[] => {
const checks: CheckSnapshot[] = [];
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
for (let i = 0; i < 25; i++) {
const isUp = Math.random() > 0.1;
const responseTime = Math.floor(Math.random() * 80) + 20;
checks.push({
+3 -9
View File
@@ -32,7 +32,6 @@ import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom";
import { mutate } from "swr";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
import { ThemePicker } from "./Components/ThemePicker";
@@ -50,9 +49,6 @@ interface TimezoneOption {
name: string;
}
const buildStatusPageKey = (slug: string | null | undefined) =>
slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
const CreateStatusPage = () => {
const theme = useTheme();
const { t } = useTranslation();
@@ -63,7 +59,9 @@ const CreateStatusPage = () => {
// Fetch existing status page data when configuring
const { data: statusPageData, isLoading: isLoadingStatusPage } =
useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url));
useGet<StatusPageResponse>(
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
);
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
const monitors = monitorsResponse ?? [];
@@ -183,10 +181,6 @@ const CreateStatusPage = () => {
}
if (result) {
await mutate(buildStatusPageKey(data.url));
if (!isCreate && url !== data.url) {
await mutate(buildStatusPageKey(url));
}
navigate(`/status/${data.url}`);
}
};
+24 -48
View File
@@ -8,56 +8,35 @@ import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useLocation, useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import { resolveStatusPageTheme } from "@/Types/StatusPage";
import {
PUBLIC_STATUS_PAGE_PREFIX,
type StatusPage,
type StatusPageResponse,
type StatusPageTheme,
} from "@/Types/StatusPage";
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import {
BaseStatusPage,
type ThemeConfig,
} from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import { BrowserFrame } from "@/Pages/StatusPage/Status/themes/BrowserFrame";
import { refinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
import { RefinedHeader } from "@/Pages/StatusPage/Status/themes/refined/RefinedHeader";
import { RefinedHero } from "@/Pages/StatusPage/Status/themes/refined/RefinedHero";
import { modernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
import { ModernHeader } from "@/Pages/StatusPage/Status/themes/modern/ModernHeader";
import { ModernHero } from "@/Pages/StatusPage/Status/themes/modern/ModernHero";
import { boldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
import { BoldHeader } from "@/Pages/StatusPage/Status/themes/bold/BoldHeader";
import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero";
import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader";
import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero";
import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "./themes/StatusPageThemeProvider";
import { RefinedStatusPage } from "./themes/refined/RefinedStatusPage";
import { ModernStatusPage } from "./themes/modern/ModernStatusPage";
import { BoldStatusPage } from "./themes/bold/BoldStatusPage";
import { EditorialStatusPage } from "./themes/editorial/EditorialStatusPage";
import { BrowserFrame } from "./themes/BrowserFrame";
const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
refined: {
createStyles: refinedStyles,
HeaderSlot: RefinedHeader,
HeroSlot: RefinedHero,
},
modern: {
createStyles: modernStyles,
HeaderSlot: ModernHeader,
HeroSlot: ModernHero,
overallStatusOptions: { iconSize: 20 },
},
bold: {
createStyles: boldStyles,
HeaderSlot: BoldHeader,
HeroSlot: BoldHero,
overallStatusOptions: { iconSize: 18 },
},
editorial: {
createStyles: editorialStyles,
HeaderSlot: EditorialHeader,
HeroSlot: EditorialHero,
overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" },
},
type ThemedRendererProps = {
statusPage: StatusPage;
monitors: (Monitor & { checks?: Monitor["recentChecks"] })[];
};
const THEMED_RENDERERS: Record<
StatusPageTheme,
React.ComponentType<ThemedRendererProps>
> = {
refined: RefinedStatusPage,
modern: ModernStatusPage,
bold: BoldStatusPage,
editorial: EditorialStatusPage,
};
const StatusPageView = () => {
@@ -111,12 +90,11 @@ const StatusPageView = () => {
);
}
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const ThemedRenderer = THEMED_RENDERERS[resolveStatusPageTheme(statusPage.theme)];
const themedRenderer = (
<BaseStatusPage
<ThemedRenderer
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
);
@@ -126,7 +104,6 @@ const StatusPageView = () => {
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
paintBody
>
{themedRenderer}
@@ -150,7 +127,6 @@ const StatusPageView = () => {
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
transparent
>
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
@@ -13,7 +13,6 @@ import {
type StatusPageTheme,
type StatusPageThemeMode,
} from "@/Types/StatusPage";
import { resolveTimezone } from "@/Utils/TimeUtils";
import { themeTokens, type StatusPageThemeTokens } from "./tokens";
type ResolvedMode = "light" | "dark";
@@ -22,7 +21,6 @@ interface StatusPageThemeContextValue {
theme: StatusPageTheme;
mode: ResolvedMode;
tokens: StatusPageThemeTokens;
timezone: string;
}
const StatusPageThemeContext = createContext<StatusPageThemeContextValue | null>(null);
@@ -35,7 +33,6 @@ const resolveSystemMode = (): ResolvedMode => {
interface Props {
theme?: StatusPageTheme;
themeMode?: StatusPageThemeMode;
timezone?: string;
/**
* When true, paint the document body with the theme background so it
* covers beyond the provider's wrapper (useful on the public route
@@ -57,14 +54,12 @@ interface Props {
export const StatusPageThemeProvider = ({
theme,
themeMode,
timezone,
paintBody = false,
transparent = false,
children,
}: Props) => {
const resolvedTheme = resolveStatusPageTheme(theme);
const resolvedThemeMode = resolveStatusPageThemeMode(themeMode);
const resolvedTimezone = resolveTimezone(timezone);
const [systemMode, setSystemMode] = useState<ResolvedMode>(resolveSystemMode);
@@ -112,13 +107,8 @@ export const StatusPageThemeProvider = ({
const tokens = themeTokens[resolvedTheme][resolvedMode];
const value = useMemo(
() => ({
theme: resolvedTheme,
mode: resolvedMode,
tokens,
timezone: resolvedTimezone,
}),
[resolvedTheme, resolvedMode, tokens, resolvedTimezone]
() => ({ theme: resolvedTheme, mode: resolvedMode, tokens }),
[resolvedTheme, resolvedMode, tokens]
);
return (
@@ -146,7 +136,6 @@ export const useStatusPageTheme = (): StatusPageThemeContextValue => {
theme: DEFAULT_STATUS_PAGE_THEME,
mode: resolveSystemMode(),
tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()],
timezone: resolveTimezone(),
};
}
return ctx;
@@ -1,20 +0,0 @@
import Box from "@mui/material/Box";
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
export const BoldHeader = ({ statusPage, logoSrc, styles }: SlotProps<BoldStyles>) => (
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoConic}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
);
@@ -1,32 +0,0 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
export const BoldHero = ({ overall, monitorCount, styles }: SlotProps<BoldStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box
component="h1"
sx={styles.heroTitle}
>
<Box
component="span"
sx={styles.heroCheck(overall.tone)}
>
{overall.icon}
</Box>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</Box>
);
};
@@ -0,0 +1,207 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
monitorBadgeTone,
monoFirstChar,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { boldStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
export const BoldStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens, mode } = useStatusPageTheme();
const styles = useMemo(() => boldStyles(tokens, mode === "dark"), [tokens, mode]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t, { iconSize: 18 });
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoConic}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
</Box>
<Box sx={styles.hero}>
<Box
component="h1"
sx={styles.heroTitle}
>
<Box
component="span"
sx={styles.heroCheck(overall.tone)}
>
{overall.icon}
</Box>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
)}
<Stack
component="ul"
sx={styles.monitorList}
>
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
return (
<Box
component="li"
key={monitor.id}
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
sxApi={{
containerSx: styles.infra,
emptySx: styles.infraEmpty,
gaugeSx: styles.gauge,
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
sx={styles.footer}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -2,7 +2,6 @@ import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type BoldBarKind = "up" | "down" | "empty";
@@ -298,7 +297,7 @@ export const boldStyles = (
heatmap: {
padding: "0 28px 22px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
height: 48,
},
@@ -314,7 +313,7 @@ export const boldStyles = (
histogram: {
padding: "0 28px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
alignItems: "flex-end",
height: 48,
@@ -1,36 +0,0 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
export const EditorialHeader = ({
statusPage,
logoSrc,
styles,
}: SlotProps<EditorialStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.brandWrap}>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
)}
<Box
component="h2"
sx={styles.brandEyebrow}
>
{statusPage.companyName}
</Box>
<Box
component="h1"
sx={styles.brandTitle}
>
{t("pages.statusPages.editorial.reportTitle")}
</Box>
</Box>
);
};
@@ -1,46 +0,0 @@
import { useMemo } from "react";
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
export const EditorialHero = ({
overall,
monitorCount,
styles,
}: SlotProps<EditorialStyles>) => {
const { t } = useTranslation();
const todayLabel = useMemo(
() =>
new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
}),
[]
);
return (
<>
<Box
component="p"
sx={styles.statusLine}
>
<Box
component="span"
sx={styles.statusDot(overall.tone)}
/>
{overall.message}
</Box>
<Box
component="p"
sx={styles.dateline}
>
{todayLabel} ·{" "}
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</>
);
};
@@ -0,0 +1,224 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
monitorBadgeTone,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { editorialStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
export const EditorialStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens } = useStatusPageTheme();
const styles = useMemo(() => editorialStyles(tokens), [tokens]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const todayLabel = useMemo(
() =>
new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
}),
[]
);
const overall = resolveOverallStatus(monitors, t, {
allUpKey: "pages.statusPages.editorial.allUp",
});
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brandWrap}>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
)}
<Box
component="h2"
sx={styles.brandEyebrow}
>
{statusPage.companyName}
</Box>
<Box
component="h1"
sx={styles.brandTitle}
>
{t("pages.statusPages.editorial.reportTitle")}
</Box>
</Box>
</Box>
<Box
component="p"
sx={styles.statusLine}
>
<Box
component="span"
sx={styles.statusDot(overall.tone)}
/>
{overall.message}
</Box>
<Box
component="p"
sx={styles.dateline}
>
{todayLabel} ·{" "}
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
)}
<Stack
component="ul"
sx={styles.monitorList}
>
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
return (
<Box
component="li"
key={monitor.id}
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
sxApi={{
containerSx: styles.infra,
emptySx: styles.infraEmpty,
gaugeSx: styles.gauge,
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
sx={styles.footer}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -6,7 +6,6 @@ import {
MONO_STACK,
SERIF_STACK,
} from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type EditorialBarKind = "up" | "down" | "empty";
@@ -51,10 +50,7 @@ export interface EditorialStyles {
footer: SxProps<Theme>;
}
export const editorialStyles = (
tokens: StatusPageThemeTokens,
_isDark: boolean
): EditorialStyles => {
export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles => {
const heatCellBg: Record<EditorialHeatCell, string> = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
@@ -249,7 +245,7 @@ export const editorialStyles = (
heatmap: {
mt: "16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "2px",
height: 40,
},
@@ -261,7 +257,7 @@ export const editorialStyles = (
histogram: {
mt: "16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "2px",
alignItems: "flex-end",
height: 40,
@@ -1,24 +0,0 @@
import Box from "@mui/material/Box";
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
export const ModernHeader = ({
statusPage,
logoSrc,
styles,
}: SlotProps<ModernStyles>) => (
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoGrad}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
);
@@ -1,34 +0,0 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
export const ModernHero = ({
overall,
monitorCount,
styles,
}: SlotProps<ModernStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box sx={styles.pulse(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
);
};
@@ -2,125 +2,80 @@ import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { SxProps, Theme } from "@mui/material/styles";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
ThemedHeatmap,
type HeatCellKind,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
import {
ThemedHistogram,
type BarKind,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
import {
ThemedInfrastructure,
type GaugeFillLevel,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure";
import {
type OverallStatus,
type OverallTone,
monitorBadgeTone,
monoFirstChar,
resolveOverallStatus,
statusBadgeKey,
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { modernStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
export interface BaseStyles {
page: SxProps<Theme>;
top: SxProps<Theme>;
chartSwitchWrap: SxProps<Theme>;
chartSwitch: SxProps<Theme>;
chartSwitchButton: (active: boolean) => SxProps<Theme>;
monitorList: SxProps<Theme>;
card: SxProps<Theme>;
cardRow: SxProps<Theme>;
cardLeft: SxProps<Theme>;
monitorName: SxProps<Theme>;
monitorMeta: SxProps<Theme>;
pill: SxProps<Theme>;
pillHardware: SxProps<Theme>;
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
gauge: SxProps<Theme>;
gaugeLabel: SxProps<Theme>;
gaugeValue: SxProps<Theme>;
gaugeBar: SxProps<Theme>;
gaugeFill: (level: GaugeFillLevel, widthPct: number) => SxProps<Theme>;
gaugeSub: SxProps<Theme>;
footer: SxProps<Theme>;
}
export interface SlotProps<S extends BaseStyles = BaseStyles> {
statusPage: StatusPage;
logoSrc: string | null;
overall: OverallStatus;
monitorCount: number;
styles: S;
}
export interface ThemeConfig<S extends BaseStyles = BaseStyles> {
createStyles: (tokens: StatusPageThemeTokens, isDark: boolean) => S;
HeaderSlot: React.ComponentType<SlotProps<S>>;
HeroSlot: React.ComponentType<SlotProps<S>>;
overallStatusOptions?: { iconSize?: number; allUpKey?: string };
}
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
config: ThemeConfig<any>;
}
export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
export const ModernStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens, mode } = useStatusPageTheme();
const styles = useMemo(
() => config.createStyles(tokens, mode === "dark"),
[config, tokens, mode]
);
const styles = useMemo(() => modernStyles(tokens, mode === "dark"), [tokens, mode]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
const overall = resolveOverallStatus(monitors, t, { iconSize: 20 });
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
const { HeaderSlot, HeroSlot } = config;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<HeaderSlot
statusPage={statusPage}
logoSrc={logoSrc}
overall={overall}
monitorCount={monitors.length}
styles={styles}
/>
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoGrad}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
</Box>
<HeroSlot
statusPage={statusPage}
logoSrc={logoSrc}
overall={overall}
monitorCount={monitors.length}
styles={styles}
/>
<Box sx={styles.hero}>
<Box sx={styles.pulse(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
@@ -3,7 +3,6 @@ import { keyframes } from "@mui/system";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type ModernBarKind = "up" | "down" | "empty";
@@ -321,7 +320,7 @@ export const modernStyles = (
heatmap: {
padding: "0 24px 20px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
height: 46,
},
@@ -336,7 +335,7 @@ export const modernStyles = (
histogram: {
padding: "0 24px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
alignItems: "flex-end",
height: 46,
@@ -1,29 +0,0 @@
import Box from "@mui/material/Box";
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
export const RefinedHeader = ({
statusPage,
logoSrc,
styles,
}: SlotProps<RefinedStyles>) => (
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoMono}>{monoFirstChar(statusPage.companyName)}</Box>
)}
<Box
component="span"
sx={styles.company}
>
{statusPage.companyName}
</Box>
</Box>
);
@@ -1,34 +0,0 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
export const RefinedHero = ({
overall,
monitorCount,
styles,
}: SlotProps<RefinedStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box sx={styles.statusDot(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
);
};
@@ -0,0 +1,210 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
monitorBadgeTone,
monoFirstChar,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { refinedStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens } = useStatusPageTheme();
const styles = useMemo(() => refinedStyles(tokens), [tokens]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t);
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoMono}>{monoFirstChar(statusPage.companyName)}</Box>
)}
<Box
component="span"
sx={styles.company}
>
{statusPage.companyName}
</Box>
</Box>
</Box>
<Box sx={styles.hero}>
<Box sx={styles.statusDot(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
)}
<Stack
component="ul"
sx={styles.monitorList}
>
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
return (
<Box
component="li"
key={monitor.id}
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
sxApi={{
containerSx: styles.infra,
emptySx: styles.infraEmpty,
gaugeSx: styles.gauge,
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
sx={styles.footer}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -6,7 +6,6 @@ import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type RefinedBarKind = "up" | "down" | "empty";
export type RefinedGaugeFill = "ok" | "warm" | "hot";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export interface RefinedStyles {
page: SxProps<Theme>;
@@ -63,10 +62,7 @@ const pillBase = {
fontWeight: 600,
};
export const refinedStyles = (
tokens: StatusPageThemeTokens,
_isDark: boolean
): RefinedStyles => {
export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => {
const heatCellBg: Record<RefinedHeatCell, string> = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
@@ -263,7 +259,7 @@ export const refinedStyles = (
heatmap: {
padding: "0 20px 16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
height: 42,
},
@@ -278,7 +274,7 @@ export const refinedStyles = (
histogram: {
padding: "0 20px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
alignItems: "flex-end",
height: 42,
@@ -4,12 +4,12 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import type { RootState } from "@/store";
import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
const CELLS = MAX_RECENT_CHECKS;
const CELLS = 25;
export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty";
@@ -29,7 +29,7 @@ const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const source = checks.slice(-CELLS);
const padded: (CheckSnapshot | null)[] = [
@@ -67,7 +67,11 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
uiTimezone
)}
</Typography>
</Stack>
);
@@ -5,11 +5,12 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import type { RootState } from "@/store";
import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
const CELLS = MAX_RECENT_CHECKS;
const CELLS = 25;
const MIN_HEIGHT_PCT = 6;
export type BarKind = "up" | "down" | "empty";
@@ -33,7 +34,7 @@ export const ThemedHistogram = ({
statsGap = 1,
}: Props) => {
const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const { padded, max, avg, peak } = useMemo(() => {
const source = checks.slice(-CELLS);
@@ -82,7 +83,7 @@ export const ThemedHistogram = ({
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
timezone
uiTimezone
)}
</Typography>
</Stack>
@@ -20,24 +20,6 @@ import { useSelector } from "react-redux";
import type { Monitor } from "@/Types/Monitor";
import type { ActionMenuItem } from "@/Components/actions-menu";
import type { RootState } from "@/Types/state";
import { Checkbox } from "@/Components/inputs";
interface MonitorTableProps {
monitors: Monitor[];
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
selectedRows?: string[];
onSelectionChange?: (selected: string[]) => void;
}
export const MonitorTable = ({
monitors,
@@ -52,34 +34,31 @@ export const MonitorTable = ({
setPage,
rowsPerPage,
setRowsPerPage,
selectedRows = [],
onSelectionChange,
}: MonitorTableProps) => {
}: {
monitors: Monitor[];
refetch: Function;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram");
const { post } = usePost<Record<string, never>, Monitor>();
const {
post,
// loading: isPosting,
// error: postError,
} = usePost<any, Monitor>();
const selectedSet = new Set(selectedRows);
const isAllSelected =
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
const handleSelectAll = (checked: boolean) => {
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
};
const handleSelectRow = (id: string, checked: boolean) => {
if (!onSelectionChange) return;
onSelectionChange(
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
);
};
const isRowSelected = (id: string) => selectedSet.has(id);
const handleSort = (e: React.MouseEvent, field: string) => {
const handleSort = (e: any, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
@@ -131,6 +110,13 @@ export const MonitorTable = ({
navigate(`/uptime/configure/${monitor.id}`);
},
},
// {
// id: 5,
// label: "Clone",
// action: () => {
// },
// },
{
id: 6,
label:
@@ -174,35 +160,8 @@ export const MonitorTable = ({
</Box>
);
const headers: Header<Monitor>[] = [
{
id: "selection",
content: (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectAll")}
indeterminate={isSomeSelected}
checked={isAllSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
),
hideMobileLabel: true,
render: (row) => (
<Checkbox
aria-label={t("pages.common.monitors.actions.selectMonitor", {
name: row.name,
})}
checked={isRowSelected(row.id)}
onChange={(e) => {
handleSelectRow(row.id, e.target.checked);
}}
onClick={(e) => e.stopPropagation()}
/>
),
onClick: (e) => e?.stopPropagation(),
},
{
id: "name",
align: "left",
mobileLabel: t("common.table.headers.name"),
content: (
<Stack
gap={theme.spacing(4)}
@@ -222,7 +181,6 @@ export const MonitorTable = ({
},
{
id: "status",
mobileLabel: t("common.table.headers.status"),
content: (
<Stack
gap={theme.spacing(4)}
@@ -254,7 +212,6 @@ export const MonitorTable = ({
},
{
id: "type",
mobileLabel: t("common.table.headers.type"),
content: (
<Stack
gap={theme.spacing(4)}
@@ -294,11 +251,6 @@ export const MonitorTable = ({
onRowClick={(row) => {
navigate(`/uptime/${row.id}`);
}}
getRowSx={(row) => ({
backgroundColor: isRowSelected(row.id)
? theme.palette.action.selected
: "inherit",
})}
/>
<Pagination
component="div"
+2 -43
View File
@@ -1,14 +1,9 @@
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
import { MonitorBasePageWithStates } from "@/Components/design-elements";
import { TextField, Dialog, Button } from "@/Components/inputs";
import { TextField, Dialog } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
import { HeaderCreate } from "@/Components/common";
import { Play, Pause } from "lucide-react";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
@@ -21,7 +16,6 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { RootState } from "@/Types/state";
import { useTheme } from "@mui/material";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
const UptimeMonitorsPage = () => {
const { t } = useTranslation();
@@ -43,7 +37,6 @@ const UptimeMonitorsPage = () => {
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
@@ -109,15 +102,6 @@ const UptimeMonitorsPage = () => {
count,
} = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
@@ -193,29 +177,6 @@ const UptimeMonitorsPage = () => {
}}
/>
</Stack>
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<MonitorTable
monitors={monitorsWithChecks || []}
refetch={refetch}
@@ -237,8 +198,6 @@ const UptimeMonitorsPage = () => {
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
-2
View File
@@ -3,8 +3,6 @@ import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
export type { GeoContinent } from "@/Types/GeoCheck";
export const MAX_RECENT_CHECKS = 50;
export const MonitorTypes = [
"http",
"ping",
-3
View File
@@ -23,8 +23,6 @@ export interface QueueJobSummary {
monitorUrl: string | null;
monitorType: MonitorType | null;
monitorInterval: number | null;
monitorGeoInterval: number | null;
monitorActive: boolean | null;
active: boolean;
lockedAt: number | null;
runCount: number;
@@ -34,7 +32,6 @@ export interface QueueJobSummary {
lastFinishedAt: number | null;
lastRunTook: number | null;
lastFailedAt: number | null;
repeat: number | null;
}
export interface QueueData {
-13
View File
@@ -1,5 +1,3 @@
import { alpha } from "@mui/material/styles";
const typographyBase = 13;
export const typographyLevels = {
@@ -19,9 +17,6 @@ export const colors = {
gray850: "#1c1c21",
brandGreen: "#13715B",
brandGreenLight: "#4DAF94",
green200: "#ECF7F2",
yellow200: "#FFF4E5",
orange500: "#FFA500",
};
export const lightPalette = {
@@ -34,10 +29,6 @@ export const lightPalette = {
sidebar: {
accent: colors.brandGreen,
},
rowStatus: {
running: colors.green200,
paused: colors.yellow200,
},
};
export const darkPalette = {
@@ -54,8 +45,4 @@ export const darkPalette = {
sidebar: {
accent: colors.brandGreenLight,
},
rowStatus: {
running: alpha(colors.brandGreen, 0.18),
paused: alpha(colors.orange500, 0.18),
},
};
-2
View File
@@ -15,11 +15,9 @@ declare module "@mui/material/styles" {
}
interface Palette {
sidebar: { accent: string };
rowStatus: { running: string; paused: string };
}
interface PaletteOptions {
sidebar?: { accent: string };
rowStatus?: { running: string; paused: string };
}
}
-1
View File
@@ -1,5 +1,4 @@
export const SPACING = {
XXS: 0.25,
XS: 0.5,
SM: 1,
MD: 1.5,
+2 -20
View File
@@ -15,30 +15,12 @@ export const MS_PER_HOUR = 60 * MS_PER_MINUTE;
export const MS_PER_DAY = 24 * MS_PER_HOUR;
export const MS_PER_WEEK = MS_PER_DAY * 7;
const resolveSystemTimezone = (): string => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch {
return "UTC";
}
};
export const SYSTEM_TIMEZONE = resolveSystemTimezone();
export const resolveTimezone = (preferred?: string | null): string =>
preferred?.trim() || SYSTEM_TIMEZONE;
export const formatDateWithTz = (
timestamp: string,
format: string,
timezone?: string
) => {
export const formatDateWithTz = (timestamp: string, format: string, timezone: string) => {
if (!timestamp) {
return "Unknown time";
}
const tz = resolveTimezone(timezone);
try {
return dayjs(timestamp).tz(tz).format(format);
return dayjs(timestamp).tz(timezone).format(format);
} catch {
return dayjs(timestamp).utc().format(format);
}
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "دعوة عضو",
"openSite": "فتح الموقع",
"pause": "إيقاف مؤقت",
"resume": "استئناف",
"selectAll": "تحديد جميع الشاشات",
"selectMonitor": "تحديد {{name}}",
"bulkSelected_one": "{{count}} شاشة محددة",
"bulkSelected_other": "{{count}} شاشات محددة"
"resume": "استئناف"
},
"statBoxes": {
"activeFor": "نشط منذ",
@@ -453,16 +449,6 @@
"optionHardware": "البنية التحتية",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "تم إيقاف {{count}} شاشة مؤقتاً",
"paused_other": "تم إيقاف {{count}} شاشات مؤقتاً",
"resumed_one": "تم استئناف {{count}} شاشة",
"resumed_other": "تم استئناف {{count}} شاشات",
"alreadyPaused_one": "الشاشة المحددة متوقفة مؤقتاً بالفعل",
"alreadyPaused_other": "جميع الشاشات المحددة متوقفة مؤقتاً بالفعل",
"alreadyRunning_one": "الشاشة المحددة قيد التشغيل بالفعل",
"alreadyRunning_other": "جميع الشاشات المحددة قيد التشغيل بالفعل"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "رابط WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "نقطة نهاية Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "الإعدادات العامة",
@@ -564,7 +546,7 @@
"pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.",
"grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.",
"websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.",
"hardware": "راقب استخدام وحدة المعالجة المركزية والذاكرة والقرص ودرجة الحرارة لبنيتك التحتية. يجب أن يتضمن عنوان URL لنقطة نهاية Capture المسار الكامل ‎/api/v1/metrics‎ (مثال: ‎http://192.168.1.10:5001/api/v1/metrics‎). بالنسبة لمفتاح التفويض، الصق سلسلة الرمز فقط — لا تُضمّن كلمة 'Bearer'."
"hardware": "مراقبة استخدام CPU والذاكرة والقرص ودرجة الحرارة للبنية التحتية الخاصة بك."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Pozvat člena",
"openSite": "Otevřít stránku",
"pause": "Pozastavit",
"resume": "Obnovit",
"selectAll": "Vybrat všechny monitory",
"selectMonitor": "Vybrat {{name}}",
"bulkSelected_one": "Vybrán {{count}} monitor",
"bulkSelected_other": "Vybráno {{count}} monitorů"
"resume": "Obnovit"
},
"statBoxes": {
"activeFor": "Aktivní po dobu",
@@ -453,16 +449,6 @@
"optionHardware": "Infrastruktura",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitor pozastaven",
"paused_other": "{{count}} monitorů pozastaveno",
"resumed_one": "{{count}} monitor obnoven",
"resumed_other": "{{count}} monitorů obnoveno",
"alreadyPaused_one": "Vybraný monitor je již pozastaven",
"alreadyPaused_other": "Všechny vybrané monitory jsou již pozastaveny",
"alreadyRunning_one": "Vybraný monitor již běží",
"alreadyRunning_other": "Všechny vybrané monitory již běží"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Endpoint Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Obecná nastavení",
@@ -564,7 +546,7 @@
"pagespeed": "Sledujte výkon načítání stránky, metriky Core Web Vitals a hodnocení optimalizace vašeho webu.",
"grpc": "Zadejte název hostitele a port gRPC serveru, volitelně zadejte název služby pro kontrolu stavu a přidejte zobrazovaný název.",
"websocket": "Zadejte WebSocket URL ke sledování (např. wss://example.com/socket) a přidejte zobrazovaný název.",
"hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury. URL endpointu Capture musí obsahovat celou cestu /api/v1/metrics (např. http://192.168.1.10:5001/api/v1/metrics). Do autorizačního klíče vložte pouze řetězec tokenu — nezahrnujte 'Bearer'."
"hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Mitglied einladen",
"openSite": "Seite öffnen",
"pause": "Pausieren",
"resume": "Fortsetzen",
"selectAll": "Alle Monitore auswählen",
"selectMonitor": "{{name}} auswählen",
"bulkSelected_one": "{{count}} Monitor ausgewählt",
"bulkSelected_other": "{{count}} Monitore ausgewählt"
"resume": "Fortsetzen"
},
"statBoxes": {
"activeFor": "Aktiv seit",
@@ -453,16 +449,6 @@
"optionHardware": "Infrastruktur",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} Monitor pausiert",
"paused_other": "{{count}} Monitore pausiert",
"resumed_one": "{{count}} Monitor fortgesetzt",
"resumed_other": "{{count}} Monitore fortgesetzt",
"alreadyPaused_one": "Der ausgewählte Monitor ist bereits pausiert",
"alreadyPaused_other": "Alle ausgewählten Monitore sind bereits pausiert",
"alreadyRunning_one": "Der ausgewählte Monitor wird bereits ausgeführt",
"alreadyRunning_other": "Alle ausgewählten Monitore werden bereits ausgeführt"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket-URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture-Endpunkt",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Allgemeine Einstellungen",
@@ -564,7 +546,7 @@
"pagespeed": "Verfolgen Sie die Ladezeit, Core Web Vitals und Optimierungswerte Ihrer Website.",
"grpc": "Geben Sie den Hostnamen und Port des gRPC-Servers ein, optional einen Dienstnamen für den Health-Check, und fügen Sie einen Anzeigenamen hinzu.",
"websocket": "Geben Sie die zu überwachende WebSocket-URL ein (z. B. wss://example.com/socket) und fügen Sie einen Anzeigenamen hinzu.",
"hardware": "Überwachen Sie CPU-, Speicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur. Die URL des Capture-Endpunkts muss den vollständigen Pfad /api/v1/metrics enthalten (z. B. http://192.168.1.10:5001/api/v1/metrics). Fügen Sie für das Autorisierungsgeheimnis nur den Token-String ein — geben Sie 'Bearer' nicht mit an."
"hardware": "Überwachen Sie CPU-, Arbeitsspeicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur."
}
},
"ignoreTls": {
+3 -24
View File
@@ -70,9 +70,7 @@
},
"labels": {
"active": "Active",
"running": "Running",
"paused": "Paused",
"idle": "Idle",
"paused": "paused",
"na": "N/A",
"resolved": "Resolved",
"responseTime": "Response time"
@@ -423,21 +421,7 @@
"inviteMember": "Invite member",
"openSite": "Open site",
"pause": "Pause",
"resume": "Resume",
"bulkSelected_one": "{{count}} monitor selected",
"bulkSelected_other": "{{count}} monitors selected",
"selectAll": "Select all monitors",
"selectMonitor": "Select {{name}}"
},
"bulkPause": {
"alreadyPaused_one": "The selected monitor is already paused",
"alreadyPaused_other": "All selected monitors are already paused",
"alreadyRunning_one": "The selected monitor is already running",
"alreadyRunning_other": "All selected monitors are already running",
"paused_one": "{{count}} monitor paused",
"paused_other": "{{count}} monitors paused",
"resumed_one": "{{count}} monitor resumed",
"resumed_other": "{{count}} monitors resumed"
"resume": "Resume"
},
"statBoxes": {
"activeFor": "Active for",
@@ -550,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture endpoint",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "General settings",
@@ -566,7 +546,7 @@
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
"websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.",
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'."
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure."
}
},
"ignoreTls": {
@@ -903,7 +883,6 @@
},
"table": {
"headers": {
"state": "State",
"timestamp": "Timestamp",
"level": "Level",
"service": "Service",
+2 -20
View File
@@ -428,11 +428,7 @@
"inviteMember": "Invitar miembro",
"openSite": "Abrir sitio",
"pause": "Pausar",
"resume": "Reanudar",
"selectAll": "Seleccionar todos los monitores",
"selectMonitor": "Seleccionar {{name}}",
"bulkSelected_one": "{{count}} monitor seleccionado",
"bulkSelected_other": "{{count}} monitores seleccionados"
"resume": "Reanudar"
},
"statBoxes": {
"activeFor": "Activo desde hace",
@@ -460,16 +456,6 @@
"optionHardware": "Infraestructura",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitor pausado",
"paused_other": "{{count}} monitores pausados",
"resumed_one": "{{count}} monitor reanudado",
"resumed_other": "{{count}} monitores reanudados",
"alreadyPaused_one": "El monitor seleccionado ya está pausado",
"alreadyPaused_other": "Todos los monitores seleccionados ya están pausados",
"alreadyRunning_one": "El monitor seleccionado ya se está ejecutando",
"alreadyRunning_other": "Todos los monitores seleccionados ya se están ejecutando"
}
}
},
@@ -555,10 +541,6 @@
"wsUrl": {
"label": "URL de WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Endpoint de Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Configuración general",
@@ -571,7 +553,7 @@
"pagespeed": "Rastree el rendimiento de carga de página, Core Web Vitals y puntuaciones de optimización de su sitio web.",
"grpc": "Ingrese el nombre de host y puerto del servidor gRPC, opcionalmente especifique un nombre de servicio para la verificación de salud, y agregue un nombre para mostrar.",
"websocket": "Ingrese la URL de WebSocket a monitorear (p. ej., wss://example.com/socket) y agregue un nombre para mostrar.",
"hardware": "Supervisa el uso de CPU, memoria, disco y temperatura de tu infraestructura. La URL del endpoint de Capture debe incluir la ruta completa /api/v1/metrics (p. ej., http://192.168.1.10:5001/api/v1/metrics). Para el secreto de autorización, pega solo la cadena del token — no incluyas 'Bearer'."
"hardware": "Monitoree el uso de CPU, memoria, disco y temperatura de su infraestructura."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Kutsu jäsen",
"openSite": "Avaa sivusto",
"pause": "Keskeytä",
"resume": "Jatka",
"selectAll": "Valitse kaikki näytöt",
"selectMonitor": "Valitse {{name}}",
"bulkSelected_one": "{{count}} näyttö valittu",
"bulkSelected_other": "{{count}} näyttöä valittu"
"resume": "Jatka"
},
"statBoxes": {
"activeFor": "Aktiivinen",
@@ -453,16 +449,6 @@
"optionHardware": "Infrastruktuuri",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} näyttö keskeytetty",
"paused_other": "{{count}} näyttöä keskeytetty",
"resumed_one": "{{count}} näyttö jatkettu",
"resumed_other": "{{count}} näyttöä jatkettu",
"alreadyPaused_one": "Valittu näyttö on jo keskeytetty",
"alreadyPaused_other": "Kaikki valitut näytöt on jo keskeytetty",
"alreadyRunning_one": "Valittu näyttö on jo käynnissä",
"alreadyRunning_other": "Kaikki valitut näytöt ovat jo käynnissä"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture-päätepiste",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Yleiset asetukset",
@@ -564,7 +546,7 @@
"pagespeed": "Seuraa sivun latausnopeutta, Core Web Vitals -arvoja ja optimointipisteitä verkkosivustollasi.",
"grpc": "Syötä gRPC-palvelimen isäntänimi ja portti, määritä halutessasi palvelun nimi terveystarkistusta varten ja lisää näyttönimi.",
"websocket": "Syötä monitoroitava WebSocket URL (esim. wss://example.com/socket) ja lisää näyttönimi.",
"hardware": "Seuraa infrastruktuurisi suorittimen, muistin, levyn käyttöä ja lämpötilaa. Capture-päätepisteen URL-osoitteen on sisällettävä koko polku /api/v1/metrics (esim. http://192.168.1.10:5001/api/v1/metrics). Liitä valtuutusavaimeen vain tokenin merkkijono — älä sisällytä sanaa 'Bearer'."
"hardware": "Monitoroi infrastruktuurisi CPU:n, muistin ja levyn käyttöä sekä lämpötilaa."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Inviter un membre",
"openSite": "Ouvrir le site",
"pause": "Pause",
"resume": "Reprendre",
"selectAll": "Sélectionner tous les moniteurs",
"selectMonitor": "Sélectionner {{name}}",
"bulkSelected_one": "{{count}} moniteur sélectionné",
"bulkSelected_other": "{{count}} moniteurs sélectionnés"
"resume": "Reprendre"
},
"statBoxes": {
"activeFor": "Actif depuis",
@@ -453,16 +449,6 @@
"optionHardware": "Infrastructure",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} moniteur en pause",
"paused_other": "{{count}} moniteurs en pause",
"resumed_one": "{{count}} moniteur repris",
"resumed_other": "{{count}} moniteurs repris",
"alreadyPaused_one": "Le moniteur sélectionné est déjà en pause",
"alreadyPaused_other": "Tous les moniteurs sélectionnés sont déjà en pause",
"alreadyRunning_one": "Le moniteur sélectionné est déjà en cours d'exécution",
"alreadyRunning_other": "Tous les moniteurs sélectionnés sont déjà en cours d'exécution"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "URL WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Point de terminaison Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Paramètres généraux",
@@ -564,7 +546,7 @@
"pagespeed": "Suivez les performances de chargement, les Core Web Vitals et les scores d'optimisation de votre site web.",
"grpc": "Saisissez le nom d'hôte et le port du serveur gRPC, spécifiez éventuellement un nom de service pour le bilan de santé, et ajoutez un nom d'affichage.",
"websocket": "Saisissez l'URL WebSocket à surveiller (ex. wss://example.com/socket) et ajoutez un nom d'affichage.",
"hardware": "Surveillez l'utilisation du processeur, de la mémoire, du disque et la température de votre infrastructure. L'URL du point de terminaison Capture doit inclure le chemin complet /api/v1/metrics (par ex. http://192.168.1.10:5001/api/v1/metrics). Pour le secret d'autorisation, collez uniquement la chaîne du jeton — n'incluez pas 'Bearer'."
"hardware": "Surveillez l'utilisation du CPU, de la mémoire, du disque et la température de votre infrastructure."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "メンバーを招待",
"openSite": "サイトを開く",
"pause": "一時停止",
"resume": "再開",
"selectAll": "すべてのモニターを選択",
"selectMonitor": "{{name}} を選択",
"bulkSelected_one": "{{count}} 個のモニターが選択されました",
"bulkSelected_other": "{{count}} 個のモニターが選択されました"
"resume": "再開"
},
"statBoxes": {
"activeFor": "稼働期間",
@@ -453,16 +449,6 @@
"optionHardware": "インフラストラクチャ",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} 個のモニターを一時停止しました",
"paused_other": "{{count}} 個のモニターを一時停止しました",
"resumed_one": "{{count}} 個のモニターを再開しました",
"resumed_other": "{{count}} 個のモニターを再開しました",
"alreadyPaused_one": "選択したモニターはすでに一時停止されています",
"alreadyPaused_other": "選択したすべてのモニターはすでに一時停止されています",
"alreadyRunning_one": "選択したモニターはすでに実行中です",
"alreadyRunning_other": "選択したすべてのモニターはすでに実行中です"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture エンドポイント",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "一般設定",
@@ -564,7 +546,7 @@
"pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。",
"grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。",
"websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。",
"hardware": "インフラの CPU、メモリ、ディスク使用率、温度を監視します。Capture エンドポイント URL には完全なパス /api/v1/metrics を含める必要があります (例: http://192.168.1.10:5001/api/v1/metrics)。認証シークレットにはトークン文字列のみを貼り付けてください — 'Bearer' は含めないでください。"
"hardware": "インフラストラクチャのCPU、メモリ、ディスク使用量、温度を監視します。"
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Convidar membro",
"openSite": "Abrir site",
"pause": "Pausar",
"resume": "Retomar",
"selectAll": "Selecionar todos os monitores",
"selectMonitor": "Selecionar {{name}}",
"bulkSelected_one": "{{count}} monitor selecionado",
"bulkSelected_other": "{{count}} monitores selecionados"
"resume": "Retomar"
},
"statBoxes": {
"activeFor": "Ativo há",
@@ -453,16 +449,6 @@
"optionHardware": "Infraestrutura",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitor pausado",
"paused_other": "{{count}} monitores pausados",
"resumed_one": "{{count}} monitor retomado",
"resumed_other": "{{count}} monitores retomados",
"alreadyPaused_one": "O monitor selecionado já está pausado",
"alreadyPaused_other": "Todos os monitores selecionados já estão pausados",
"alreadyRunning_one": "O monitor selecionado já está em execução",
"alreadyRunning_other": "Todos os monitores selecionados já estão em execução"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "URL do WebSocket",
"placeholder": "wss://exemplo.com/socket"
},
"captureUrl": {
"label": "Endpoint do Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Configurações gerais",
@@ -564,7 +546,7 @@
"pagespeed": "Acompanhe o desempenho de carregamento da página, Core Web Vitals e pontuações de otimização do seu site.",
"grpc": "Digite o hostname e a porta do servidor gRPC, opcionalmente especifique um nome de serviço para a verificação de saúde e adicione um nome de exibição.",
"websocket": "Digite a URL do WebSocket para monitorar (ex.: wss://exemplo.com/socket) e adicione um nome de exibição.",
"hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura. A URL do endpoint do Capture deve incluir o caminho completo /api/v1/metrics (por exemplo, http://192.168.1.10:5001/api/v1/metrics). Para a chave de autorização, cole apenas a string do token — não inclua 'Bearer'."
"hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Пригласить участника",
"openSite": "Открыть сайт",
"pause": "Пауза",
"resume": "Продолжить",
"selectAll": "Выбрать все мониторы",
"selectMonitor": "Выбрать {{name}}",
"bulkSelected_one": "Выбран {{count}} монитор",
"bulkSelected_other": "Выбрано {{count}} мониторов"
"resume": "Продолжить"
},
"statBoxes": {
"activeFor": "Активен",
@@ -453,16 +449,6 @@
"optionHardware": "Инфраструктура",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} монитор приостановлен",
"paused_other": "{{count}} мониторов приостановлено",
"resumed_one": "{{count}} монитор возобновлен",
"resumed_other": "{{count}} мониторов возобновлено",
"alreadyPaused_one": "Выбранный монитор уже приостановлен",
"alreadyPaused_other": "Все выбранные мониторы уже приостановлены",
"alreadyRunning_one": "Выбранный монитор уже запущен",
"alreadyRunning_other": "Все выбранные мониторы уже запущены"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Эндпоинт Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Основные настройки",
@@ -564,7 +546,7 @@
"pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.",
"grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.",
"websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.",
"hardware": "Отслеживайте использование ЦП, памяти, диска и температуру вашей инфраструктуры. URL эндпоинта Capture должен включать полный путь /api/v1/metrics (например, http://192.168.1.10:5001/api/v1/metrics). В секрете авторизации укажите только строку токена — не включайте 'Bearer'."
"hardware": "Мониторинг CPU, памяти, дисков и температуры Вашей инфраструктуры."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "เชิญสมาชิก",
"openSite": "เปิดเว็บไซต์",
"pause": "หยุดชั่วคราว",
"resume": "ดำเนินการต่อ",
"selectAll": "เลือกจอภาพทั้งหมด",
"selectMonitor": "เลือก {{name}}",
"bulkSelected_one": "เลือกแล้ว {{count}} จอภาพ",
"bulkSelected_other": "เลือกแล้ว {{count}} จอภาพ"
"resume": "ดำเนินการต่อ"
},
"statBoxes": {
"activeFor": "ทำงานมาแล้ว",
@@ -453,16 +449,6 @@
"optionHardware": "โครงสร้างพื้นฐาน",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "หยุดจอภาพชั่วคราว {{count}} จอ",
"paused_other": "หยุดจอภาพชั่วคราว {{count}} จอ",
"resumed_one": "กลับมาทำงานต่อ {{count}} จอ",
"resumed_other": "กลับมาทำงานต่อ {{count}} จอ",
"alreadyPaused_one": "จอภาพที่เลือกถูกหยุดชั่วคราวแล้ว",
"alreadyPaused_other": "จอภาพที่เลือกทั้งหมดถูกหยุดชั่วคราวแล้ว",
"alreadyRunning_one": "จอภาพที่เลือกกำลังทำงานอยู่แล้ว",
"alreadyRunning_other": "จอภาพที่เลือกทั้งหมดกำลังทำงานอยู่แล้ว"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "URL ของ WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "ปลายทาง Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "ตั้งค่าทั่วไป",
@@ -564,7 +546,7 @@
"pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์",
"grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง",
"websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง",
"hardware": "ตรวจสอบการใช้ CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐานของคุณ URL ของปลายทาง Capture ต้องรวมเส้นทางแบบเต็ม /api/v1/metrics (ตัวอย่าง: http://192.168.1.10:5001/api/v1/metrics) สำหรับรหัสลับสำหรับยืนยันตัวตน ให้วางเฉพาะสตริงโทเค็น — อย่ารวมคำว่า 'Bearer'"
"hardware": "ตรวจสอบการใช้งาน CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐาน"
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Üye davet et",
"openSite": "Siteyi aç",
"pause": "Duraklat",
"resume": "Devam et",
"selectAll": "Tüm monitörleri seç",
"selectMonitor": "{{name}}'i seç",
"bulkSelected_one": "{{count}} monitör seçildi",
"bulkSelected_other": "{{count}} monitör seçildi"
"resume": "Devam et"
},
"statBoxes": {
"activeFor": "Aktif süresi",
@@ -453,16 +449,6 @@
"optionHardware": "Altyapı",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitör duraklatıldı",
"paused_other": "{{count}} monitör duraklatıldı",
"resumed_one": "{{count}} monitör sürdürüldü",
"resumed_other": "{{count}} monitör sürdürüldü",
"alreadyPaused_one": "Seçili monitör zaten duraklatıldı",
"alreadyPaused_other": "Seçili tüm monitörler zaten duraklatıldı",
"alreadyRunning_one": "Seçili monitör zaten çalışıyor",
"alreadyRunning_other": "Seçili tüm monitörler zaten çalışıyor"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture uç noktası",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Genel ayarlar",
@@ -564,7 +546,7 @@
"pagespeed": "Web sitenizin sayfa yükleme performansını, Core Web Vitals metriklerini ve optimizasyon puanlarını takip edin.",
"grpc": "gRPC sunucusunun ana bilgisayar adını ve portunu girin, isteğe bağlı olarak sağlık kontrolü için bir servis adı belirtin ve bir görünen ad ekleyin.",
"websocket": "İzlenecek WebSocket URL'sini girin (örn. wss://example.com/socket) ve bir görünen ad ekleyin.",
"hardware": "Altyapınızın CPU, bellek, disk kullanımını ve sıcaklığını izleyin. Capture uç nokta URL'si tam yolu /api/v1/metrics içermelidir (ör. http://192.168.1.10:5001/api/v1/metrics). Yetkilendirme anahtarına yalnızca token dizesini yapıştırın — 'Bearer' eklemeyin."
"hardware": "Altyapınız için CPU, bellek, disk kullanımı ve sıcaklığı izleyin."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Запросити учасника",
"openSite": "Відкрити сайт",
"pause": "Призупинити",
"resume": "Відновити",
"selectAll": "Вибрати всі монітори",
"selectMonitor": "Вибрати {{name}}",
"bulkSelected_one": "Вибрано {{count}} монітор",
"bulkSelected_other": "Вибрано {{count}} моніторів"
"resume": "Відновити"
},
"statBoxes": {
"activeFor": "Активний протягом",
@@ -453,16 +449,6 @@
"optionHardware": "Інфраструктура",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} монітор призупинено",
"paused_other": "{{count}} моніторів призупинено",
"resumed_one": "{{count}} монітор відновлено",
"resumed_other": "{{count}} моніторів відновлено",
"alreadyPaused_one": "Вибраний монітор уже призупинено",
"alreadyPaused_other": "Всі вибрані монітори уже призупинено",
"alreadyRunning_one": "Вибраний монітор уже запущено",
"alreadyRunning_other": "Всі вибрані монітори уже запущено"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Точка доступу Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Загальні налаштування",
@@ -564,7 +546,7 @@
"pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.",
"grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.",
"websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.",
"hardware": "Відстежуйте використання ЦП, пам'яті, диска та температуру вашої інфраструктури. URL точки доступу Capture має містити повний шлях /api/v1/metrics (наприклад, http://192.168.1.10:5001/api/v1/metrics). У секрет авторизації вставте лише рядок токена — не додавайте 'Bearer'."
"hardware": "Моніторинг CPU, пам'яті, використання диска та температури Вашої інфраструктури."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Mời thành viên",
"openSite": "Mở trang web",
"pause": "Tạm dừng",
"resume": "Tiếp tục",
"selectAll": "Chọn tất cả màn hình",
"selectMonitor": "Chọn {{name}}",
"bulkSelected_one": "Đã chọn {{count}} màn hình",
"bulkSelected_other": "Đã chọn {{count}} màn hình"
"resume": "Tiếp tục"
},
"statBoxes": {
"activeFor": "Hoạt động từ",
@@ -453,16 +449,6 @@
"optionHardware": "Hạ tầng",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "Đã tạm dừng {{count}} màn hình",
"paused_other": "Đã tạm dừng {{count}} màn hình",
"resumed_one": "Đã tiếp tục {{count}} màn hình",
"resumed_other": "Đã tiếp tục {{count}} màn hình",
"alreadyPaused_one": "Màn hình đã chọn đã được tạm dừng",
"alreadyPaused_other": "Tất cả các màn hình đã chọn đã được tạm dừng",
"alreadyRunning_one": "Màn hình đã chọn đã đang chạy",
"alreadyRunning_other": "Tất cả các màn hình đã chọn đã đang chạy"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "URL WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Điểm cuối Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Cài đặt chung",
@@ -564,7 +546,7 @@
"pagespeed": "Theo dõi hiệu suất tải trang, Core Web Vitals và điểm tối ưu hóa cho trang web của bạn.",
"grpc": "Nhập tên máy chủ và cổng của máy chủ gRPC, tùy chọn chỉ định tên dịch vụ cho kiểm tra sức khỏe và thêm tên hiển thị.",
"websocket": "Nhập URL WebSocket cần giám sát (vd: wss://example.com/socket) và thêm tên hiển thị.",
"hardware": "Giám sát mức sử dụng CPU, bộ nhớ, ổ đĩa và nhiệt độ của hạ tầng. URL điểm cuối Capture phải bao gồm đường dẫn đầy đủ /api/v1/metrics (ví dụ: http://192.168.1.10:5001/api/v1/metrics). Đối với khóa xác thực, chỉ dán chuỗi token — không bao gồm 'Bearer'."
"hardware": "Giám sát CPU, bộ nhớ, dung lượng ổ đĩa và nhiệt độ cho hạ tầng của bạn."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "邀请成员",
"openSite": "打开站点",
"pause": "暂停",
"resume": "恢复",
"selectAll": "选择所有监视器",
"selectMonitor": "选择 {{name}}",
"bulkSelected_one": "已选择 {{count}} 个监视器",
"bulkSelected_other": "已选择 {{count}} 个监视器"
"resume": "恢复"
},
"statBoxes": {
"activeFor": "已活跃",
@@ -453,16 +449,6 @@
"optionHardware": "基础设施",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "已暂停 {{count}} 个监视器",
"paused_other": "已暂停 {{count}} 个监视器",
"resumed_one": "已恢复 {{count}} 个监视器",
"resumed_other": "已恢复 {{count}} 个监视器",
"alreadyPaused_one": "所选监视器已暂停",
"alreadyPaused_other": "所有选定的监视器均已暂停",
"alreadyRunning_one": "所选监视器已经在运行",
"alreadyRunning_other": "所有选定的监视器均已在运行"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture 端点",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "常规设置",
@@ -564,7 +546,7 @@
"pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。",
"grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。",
"websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。",
"hardware": "监控基础设施的 CPU、内存、磁盘使用情况和温度。Capture 端点 URL 必须包含完整路径 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授权密钥只需粘贴令牌字符串——请勿包含 'Bearer'。"
"hardware": "监控基础设施的 CPU、内存、磁盘使用率和温度。"
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "邀請成員",
"openSite": "開啟網站",
"pause": "暫停",
"resume": "恢復",
"selectAll": "選擇所有監視器",
"selectMonitor": "選擇 {{name}}",
"bulkSelected_one": "已選擇 {{count}} 個監視器",
"bulkSelected_other": "已選擇 {{count}} 個監視器"
"resume": "恢復"
},
"statBoxes": {
"activeFor": "已啟用",
@@ -453,16 +449,6 @@
"optionHardware": "基礎架構",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "已暫停 {{count}} 個監視器",
"paused_other": "已暫停 {{count}} 個監視器",
"resumed_one": "已恢復 {{count}} 個監視器",
"resumed_other": "已恢復 {{count}} 個監視器",
"alreadyPaused_one": "所選監視器已暫停",
"alreadyPaused_other": "所有選定的監視器均已暫停",
"alreadyRunning_one": "所選監視器已經在運行",
"alreadyRunning_other": "所有選定的監視器均已在運行"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture 端點",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "一般設定",
@@ -564,7 +546,7 @@
"pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。",
"grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。",
"websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。",
"hardware": "監控基礎架構的 CPU、記憶體、磁碟使用情況和溫度。Capture 端點 URL 必須包含完整路徑 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授權密鑰僅貼上權杖字串——請勿包含 'Bearer'。"
"hardware": "監控您基礎架構的 CPU、記憶體、磁碟使用量和溫度。"
}
},
"ignoreTls": {
+2
View File
@@ -18,6 +18,8 @@ services:
image: mongo:6.0
restart: always
command: ["mongod", "--quiet", "--bind_ip_all"]
ports:
- "27017:27017"
volumes:
- ./mongo/data:/data/db
healthcheck:
-404
View File
@@ -1,404 +0,0 @@
# Coding conventions
Style and structural rules the codebase follows. Frontend rules are extracted from review feedback on PR series #3591–#3596; backend rules are extracted from the provider/repository/service layering used across `server/src/service/infrastructure/network/*` and the patterns enforced in code review. These are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent.
> If you're touching a file in `client/src` the **frontend** rules apply.
> If you're touching a file in `server/src` the **backend** rules apply.
> Rule 0 applies everywhere.
---
## 0. Look at existing implementations and follow the established pattern
Before writing new code, find one or two files that already do something similar — a peer provider for a new monitor type, a peer page for a new admin screen, a peer repository method for a new query — and follow the same shape. Match imports, helper-function placement, naming, error handling, mock-construction style, and assertion idioms. If you're tempted to do it differently, the burden is on you to justify the divergence.
**Why:** consistency is more valuable than locally-optimal cleverness. A reader who has internalized the pattern of one peer file should be able to read the new one with no extra mental load. New shapes also make refactors more expensive — when someone wants to change the convention later, every variant has to be visited and reasoned about separately.
**How to apply:** before writing a new component, page, provider, repository, or test file:
1. Grep for the closest existing peers (e.g. `WebSocketProvider.ts` if you're writing a new monitor provider).
2. Read at least two of them end-to-end.
3. Mirror their structure: same constants at the top (`SERVICE_NAME`, `TIMEOUT_MS`), same import order, same DI shape, same try/catch envelope, same test-file location and helper imports.
4. If your new code needs a shape no peer has, name it explicitly in the PR description and link to the peer you diverged from. Don't sneak novelty in.
**Concrete check before opening a PR:** can a reader open a peer file side-by-side with yours and trace each section in the same order? If not, restructure.
---
# Part 1 — Frontend (`client/src`)
## 1. Prefer MUI native (system) props over `sx`
MUI components accept layout, spacing, color, border, typography and sizing values as top-level props. Use those. Reach for `sx` only when no native prop exists for the value you need (e.g. `textDecoration`, pseudo-selectors, complex selectors, conditional CSS).
**Why:** native props are easier to read in JSX, easier to grep, easier to refactor, and don't force the reader to mentally parse a nested object literal for things that are conceptually one attribute. A `sx` block also can't be split-line-edited cleanly when most of its keys are simple primitives.
**How to apply:** any time you write `sx={{ … }}`, ask whether each key has a native equivalent. If yes, hoist it.
### Canonical mappings
| `sx` key | Native prop |
|---|---|
| `color` | `color` |
| `backgroundColor` | `bgcolor` |
| `borderRadius` | `borderRadius` |
| `border`, `borderTop`, … | `border`, `borderTop`, … |
| `width`, `height`, `minHeight`, `maxWidth` | same names as native |
| `p`, `pt`, `pb`, `px`, `py` | same names as native |
| `m`, `mt`, `mb`, `mx`, `my` | same names as native |
| `display` | `display` |
| `textAlign` | `textAlign` |
| `fontWeight`, `fontSize`, `lineHeight` | same names as native |
| `gap` (on Stack) | `gap` |
### Examples
```tsx
// BAD — everything inside sx
<Box
sx={{
width: "100%",
p: theme.spacing(LAYOUT.MD),
borderRadius: 1,
border: `1px solid ${alpha(tone, 0.45)}`,
backgroundColor: alpha(tone, 0.08),
textAlign: "left",
}}
/>
// GOOD — every key that has a native prop is hoisted
<Box
width="100%"
p={theme.spacing(LAYOUT.MD)}
borderRadius={theme.shape.borderRadius}
border={`1px solid ${alpha(tone, 0.45)}`}
bgcolor={alpha(tone, 0.08)}
textAlign="left"
/>
```
```tsx
// BAD
<Typography sx={{ color: theme.palette.text.primary, lineHeight: 1.55, fontSize: 13 }}>
// GOOD — hoist what you can; drop fontSize:13 because it's the body default
<Typography
color={theme.palette.text.primary}
lineHeight={1.55}
>
```
### When `sx` is still correct
Keep `sx` for things that don't have a native prop:
```tsx
<Link
component={RouterLink}
to="/settings"
color={theme.palette.primary.main}
fontWeight={600}
sx={{ textDecoration: "underline" }} // OK — no native prop for textDecoration
/>
```
Pseudo-selectors (`"&:hover"`), `transition`, `cursor`, `outline`, `overflow` (when not on Box/Stack), media queries, and conditional spread objects all stay in `sx`.
---
## 2. Use the full theme path for colors, never the shorthand string
Prop forms like `color="text.secondary"`, `color="primary.main"`, `bgcolor="background.paper"` work, but they are **forbidden in this codebase**.
**Why:** the shorthand is invisible to grep. If a future change needs to find every place that consumes `theme.palette.text.secondary` — to retheme, to swap, to audit — the shorthand strings are missed. That is a maintenance trap. It's worth a few extra characters at every callsite to keep the codebase searchable.
**How to apply:** always destructure or reference the theme value explicitly.
```tsx
// BAD
<Typography color="text.secondary">…</Typography>
<Typography color="primary.main">…</Typography>
// GOOD
const theme = useTheme();
<Typography color={theme.palette.text.secondary}>…</Typography>
<Typography color={theme.palette.primary.main}>…</Typography>
```
This applies to every theme-aware string prop (`color`, `bgcolor`, `borderColor`, etc.), and inside `sx` if you really need it there.
---
## 3. No hardcoded numeric or string literals — use tokens
The theme exposes tokens for every kind of magic value. Use them. Reserve raw numbers for values that are genuinely one-off (and even then, push back on yourself first).
**Why:** tokens are the single source of truth. A literal `2` in a `mt` prop is invisible — it could mean `LAYOUT.XXS`, a rounding correction, or a thinko. The reader can't tell. With a token, intent is in the name.
**How to apply:** before writing a literal, check whether one of these tokens covers it. If it nearly does but not exactly, extend the token rather than hardcoding (Alex did exactly this — he added `LAYOUT.XXS = 2` rather than letting `mt: "2px"` stand).
### Token sources
- **Spacing / layout** — `client/src/Utils/Theme/constants.ts`
- `LAYOUT.XXS = 2` · `LAYOUT.XS = 4` · `LAYOUT.SM = 6` · `LAYOUT.MD = 8` · `LAYOUT.LG = 10` · `LAYOUT.XL = 12` · `LAYOUT.XXL = 16`
- `SPACING` for `theme.spacing(...)` multipliers
- **Typography sizes** — `client/src/Utils/Theme/Palette.ts` → `typographyLevels`
- `xs / s / m / l / xl / xxl` (rem values)
- **Hover / interaction** — `HOVER.DARKEN` etc.
- **Shape** — `theme.shape.borderRadius` (don't write `borderRadius: 1` or `borderRadius: 4`)
- **Colors** — `theme.palette.*` (rule 2 above)
### Examples
```tsx
// BAD
<Box sx={{ mt: "2px" }} />
<Stack spacing={theme.spacing(2)} />
<Box sx={{ borderRadius: 1 }} />
<Typography fontSize={18} />
<Typography fontSize={13} /> // 13 is the body default — drop the prop entirely
// GOOD
<Box mt={LAYOUT.XXS} />
<Stack spacing={theme.spacing(LAYOUT.XXS)} />
<Box borderRadius={theme.shape.borderRadius} />
<Typography fontSize={typographyLevels.xl} />
<Typography /> // inherits 13px from theme
```
### Token naming gotcha
`typographyLevels.xl` was historically used for both 18px and 23px callsites. Don't reuse a token for two different purposes — split it (`xl` = 18, `xxl` = 23) and update every callsite. If you find an ambiguous token while editing, fix it the same way.
---
## 4. Use `useTheme()` inside components, don't import the theme module
The `theme` object exported from `@/Utils/Theme/Theme` is the *factory output*, not a live, mode-aware instance. Importing it directly couples a component to the wrong reference and breaks dark-mode reactivity. It also breaks the build at import time in some module-graph configurations (this happened in PR #3596 and required a follow-up commit).
**Why:** themes can change at runtime (mode toggle), and the hook is the only correct way to read the active theme. Imports also create circular-graph risk because `Theme.ts` itself depends on `Palette.ts`.
**How to apply:** inside any component or component-helper that needs theme values:
```tsx
// BAD
import { theme } from "@/Utils/Theme/Theme";
const SectionHeading = ({ children }) => (
<Typography color={theme.palette.text.secondary}>{children}</Typography>
);
// GOOD
import { useTheme } from "@mui/material/styles";
const SectionHeading = ({ children }) => {
const theme = useTheme();
return <Typography color={theme.palette.text.secondary}>{children}</Typography>;
};
```
Module-level utility files that don't render React (pure helpers) should accept `theme` as a parameter rather than importing it.
---
## 5. Discriminated unions: pair the runtime list with the type
When you have a closed set of string literals that's used both as a type and as runtime data (severities, statuses, kinds), declare the runtime tuple first and derive the type from it. Don't declare them as two parallel literals that can drift.
**Why:** if the list and the type aren't tied to each other, adding a new value to one and forgetting the other compiles fine and breaks at runtime. Deriving the type from the tuple makes them inseparable.
```tsx
// BAD
type Severity = "info" | "warning" | "error";
// GOOD
export const Severities = ["info", "warning", "error"] as const;
export type Severity = (typeof Severities)[number];
```
Export both when callers need to iterate (e.g. for a Storybook stories matrix or a select dropdown).
---
# Part 2 — Backend (`server/src`)
## 6. Layering: Controller → Service → Repository → Mongoose
The backend enforces a strict three-layer separation between HTTP handling, business logic, and data access. Every new feature follows this path, no shortcuts.
```
Request → Controller → Service → Repository → MongoDB (Mongoose)
```
**Why:** this is what makes each layer independently testable. Mongoose calls in services force services to mock the database in tests; raw DB queries in controllers couple HTTP shape to schema shape and turn every refactor into a multi-file rewrite.
**How to apply:** when adding a new feature, the pattern is — **add a repository method** for any new DB query, **call it from a service**, **expose it via a controller**. If a service constructs a `MonitorModel.findOne({...})`, you've broken the rule; the query belongs in the repository.
- **Controllers** (`server/src/controllers/`) handle HTTP concerns only: parse params, call the service, return via the `responseHandler` middleware. No business logic.
- **Services** (`server/src/service/business/`) contain all business logic. They throw `AppError` on validation/auth failures; they do not write directly to MongoDB.
- **Repositories** (`server/src/repositories/`) are the sole layer that talks to MongoDB. They expose clean query methods (`findByMonitorId`, `createCheck`, `updateStatusWindowAndChecks`) and return entities — never Mongoose documents — via a `toEntity` private helper.
---
## 7. Provider conventions for `service/infrastructure/network/*`
Every monitor-type provider implements `IStatusProvider<TPayload>` and follows the same skeleton.
**Why:** the registry in `services.ts` builds providers identically (`new XxxProvider(deps)`) and `NetworkService` invokes them identically (`provider.handle(monitor)`). If your provider's shape diverges, the registry/dispatch becomes asymmetric and `NetworkService` grows special cases.
**How to apply:** copy the structure of a recent peer (`WebSocketProvider`, `GrpcProvider`, `PortProvider`). The skeleton:
```ts
const SERVICE_NAME = "XxxProvider";
const TIMEOUT_MS = 10000;
type ClientCtor = typeof SomeNodeStdlibClass;
export class XxxProvider implements IStatusProvider<XxxStatusPayload> {
readonly type = "xxx";
constructor(private Client: ClientCtor) {}
supports(type: MonitorType): boolean {
return type === "xxx";
}
async handle(monitor: Monitor): Promise<MonitorStatusResponse<XxxStatusPayload>> {
try {
// 1. validate inputs → throw AppError(400) on bad input
// 2. set up the client (use this.Client, never imported singleton)
// 3. perform the call inside timeRequest(...), with a setTimeout race
// in the Promise body to enforce TIMEOUT_MS
// 4. return MonitorStatusResponse with payload
} catch (err: unknown) {
if (err instanceof AppError) throw err;
const originalMessage = err instanceof Error ? err.message : String(err);
throw new AppError({
message: originalMessage || "Error performing xxx check",
status: 500,
service: SERVICE_NAME,
method: "handle",
details: { /* monitor-shaped context */ },
});
}
}
}
```
### Hard rules
- **DI for all external dependencies.** `typeof WebSocket`, `typeof net` — pass the constructor in via the provider's constructor. Never `import { Foo } from "some/library"` and call it inside `handle()`. This is the single thing that makes the provider unit-testable without `jest.unstable_mockModule`.
- **`SERVICE_NAME` and `TIMEOUT_MS`** as module-level constants near the top, not magic strings/numbers in the body.
- **Use the shared `timeRequest()` helper** from `service/infrastructure/network/utils.ts` to measure latency. Don't roll your own `process.hrtime()`.
- **Enforce a hard timeout.** Every peer provider inlines a `setTimeout` race in the Promise body around the underlying client call (see `WebSocketProvider.ts`); sockets, DNS resolvers, and external clients can hang indefinitely without it. There is no shared `withTimeout` helper today — match the inline pattern.
- **Outer try/catch** that converts unknown errors to `AppError({ service, method, details })` while letting your own `AppError` pass through.
---
## 8. Mongoose schema fields with closed value sets
If a field's TypeScript type is a closed union (status, type, record type), the Mongoose schema must declare the same set as `enum`.
**Why:** the validator and the schema are independent gates. A service-layer Zod check can be bypassed (imports, migrations, direct repo writes). A schema `enum` is the last line of defense and surfaces invalid data as a Mongoose validation error rather than silently accepting `"badtype"` and breaking downstream consumers.
**How to apply:** when you add a string field whose values are constrained by a const tuple in `types/`, reuse the same const for the schema enum:
```ts
// types/monitor.ts (existing)
export const MonitorMatchMethods = ["equal", "include", "regex"] as const;
// db/models/Monitor.ts
{
matchMethod: {
type: String,
enum: MonitorMatchMethods,
},
}
```
The same applies to `MonitorTypes`, `MonitorStatuses`, and any new closed-set field.
---
## 9. Test conventions for `server/test/unit/providers/network/*`
Each provider gets a unit test file at `server/test/unit/providers/network/<providerName>.test.ts`. Follow the structure used by `webSocketProvider.test.ts`, `grpcProvider.test.ts`, `portProvider.test.ts`.
**Why:** consistency lets reviewers diff a new test file against any peer and only see the actual logic differences, not framework/import noise. It also lets the `testStatusProviderContract` helper guarantee every provider satisfies the `IStatusProvider` contract.
**How to apply:**
- **Imports**: `from "@jest/globals"` (not the global `jest`), and `.ts` extensions on every relative import.
- **Contract test**: call `testStatusProviderContract("XxxProvider", { create, supportedType, unsupportedType, makeMonitor })` before the per-test `describe` block.
- **Mock construction**: build the constructor mock as `jest.fn().mockImplementation(() => mock) as unknown as typeof Client` (typed cast, not `as any`).
- **`makeMonitor` factory**: takes `Partial<Monitor>` overrides, spreads onto a baseline, returns `as Monitor`.
- **Inline `setup()` per test** rather than `beforeEach` shared state. Each test should construct its own mock + provider, so a single test can be read in isolation:
```ts
const setup = () => {
const mock = createMockClient();
const Ctor = jest.fn().mockImplementation(() => mock) as unknown as typeof Client;
const provider = new XxxProvider(Ctor);
return { mock, provider };
};
it("does X", async () => {
const { mock, provider } = setup();
// ...
});
```
- **Assertion style**: individual `expect(result.field).toBe(...)` lines or `expect(result).toEqual(expect.objectContaining({...}))`. Both are used; pick what reads cleanest for the case.
- **Test naming**: lowercase imperative ("returns success when connection opens", "uses the configured timeout", "throws AppError when host is missing").
---
## 10. Validation: centralize, don't inline
Whenever the same string-set or shape constraint appears in two or more validators, lift it into `types/` as a const tuple and `import` it into both validators. Don't inline `z.enum(["A", "AAAA", ...])` in three places.
**Why:** drift. Three inline copies will eventually disagree — someone adds a new value to one and forgets the other two. The validator silently accepts it on `POST /monitors` and rejects it on `PATCH`, leading to bug reports the author can't reproduce.
**How to apply:** keep validation source-of-truth in `server/src/types/*.ts` as paired const tuples + derived types (rule 5). The validators in `server/src/validation/*.ts` and the Mongoose schemas in `server/src/db/models/*.ts` import from `types/` and reuse the same tuple.
---
## 11. OpenAPI: response schemas live in the validator file
The OpenAPI `monitorObject` (and friends) are generated from a `monitorResponseSchema` Zod object exported from the validator file, not hand-built in `server/openapi/routes/`. When you add a field to the data model, add it to the response schema in the same edit.
**Why:** the spec is auto-generated. If the response schema doesn't list a field, SDK consumers don't see it, even though the server returns it. That's a silent contract gap.
**How to apply:** when you add an entity field, update three files in the same PR:
1. `types/<entity>.ts` — add the field to the TS interface.
2. `db/models/<Entity>.ts` — add the Mongoose schema entry (with `enum` if the value set is closed; rule 8).
3. `validation/<entity>Validation.ts` — add the field to the response schema (and create/edit body schemas if it's user-settable).
The repository's `toEntity` helper also needs to surface the new field; that's part of the change, not a separate one.
---
# Pre-PR checklist
Before opening a PR, grep your diff for the patterns below. Each item describes the **anti-pattern** to find and fix; if your diff has none, that line passes. Frontend section applies to `client/src`; backend to `server/src`.
## Universal
- [ ] I can name the peer file I used as a template, and my file's structure mirrors it (rule 0).
- [ ] `npm run build` and `npm run format-check` are clean in both `client/` and `server/`.
## Frontend (`client/src`)
- [ ] No `sx={{ … }}` block containing keys with a native-prop equivalent (rule 1).
- [ ] No `color="text.X"`, `bgcolor="background.X"`, or other short-string palette paths (rule 2).
- [ ] No raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3).
- [ ] No `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4).
- [ ] No `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5).
## Backend (`server/src`)
- [ ] No `MonitorModel.findOne` (or similar Mongoose call) outside `repositories/` (rule 6).
- [ ] No provider that imports its underlying client at module top instead of receiving it via constructor DI (rule 7).
- [ ] No provider missing `SERVICE_NAME` / `TIMEOUT_MS` / `AppError` outer catch / `timeRequest()` / inline timeout (rule 7).
- [ ] No closed-set string field on a Mongoose schema without an `enum` (rule 8).
- [ ] No provider test that uses `beforeEach` instead of inline `setup()` (rule 9).
- [ ] No duplicated `z.enum([...])` literal that should reference a `types/*` const tuple (rule 10).
- [ ] No new entity field missing from `monitorResponseSchema` (or peer response schema) for OpenAPI (rule 11).
A clean PR on these axes is a PR that ships without a normalize follow-up.
+211
View File
@@ -0,0 +1,211 @@
# Frontend conventions
Style and structural rules the client codebase follows. These are extracted from review feedback on the PR series #3591–#3596 and are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent with the existing theme.
> If you're touching a file in `client/src`, every rule below applies. The "why" matters as much as the "what" — read the reasoning so you can judge edge cases.
---
## 1. Prefer MUI native (system) props over `sx`
MUI components accept layout, spacing, color, border, typography and sizing values as top-level props. Use those. Reach for `sx` only when no native prop exists for the value you need (e.g. `textDecoration`, pseudo-selectors, complex selectors, conditional CSS).
**Why:** native props are easier to read in JSX, easier to grep, easier to refactor, and don't force the reader to mentally parse a nested object literal for things that are conceptually one attribute. A `sx` block also can't be split-line-edited cleanly when most of its keys are simple primitives.
**How to apply:** any time you write `sx={{ … }}`, ask whether each key has a native equivalent. If yes, hoist it.
### Canonical mappings
| `sx` key | Native prop |
|---|---|
| `color` | `color` |
| `backgroundColor` | `bgcolor` |
| `borderRadius` | `borderRadius` |
| `border`, `borderTop`, … | `border`, `borderTop`, … |
| `width`, `height`, `minHeight`, `maxWidth` | same names as native |
| `p`, `pt`, `pb`, `px`, `py` | same names as native |
| `m`, `mt`, `mb`, `mx`, `my` | same names as native |
| `display` | `display` |
| `textAlign` | `textAlign` |
| `fontWeight`, `fontSize`, `lineHeight` | same names as native |
| `gap` (on Stack) | `gap` |
### Examples
```tsx
// BAD — everything inside sx
<Box
sx={{
width: "100%",
p: theme.spacing(LAYOUT.MD),
borderRadius: 1,
border: `1px solid ${alpha(tone, 0.45)}`,
backgroundColor: alpha(tone, 0.08),
textAlign: "left",
}}
/>
// GOOD — every key that has a native prop is hoisted
<Box
width={"100%"}
p={theme.spacing(LAYOUT.MD)}
borderRadius={theme.shape.borderRadius}
border={`1px solid ${alpha(tone, 0.45)}`}
bgcolor={alpha(tone, 0.08)}
textAlign={"left"}
/>
```
```tsx
// BAD
<Typography sx={{ color: theme.palette.text.primary, lineHeight: 1.55, fontSize: 13 }}>
// GOOD — hoist what you can; drop fontSize:13 because it's the body default
<Typography
color={theme.palette.text.primary}
lineHeight={1.55}
>
```
### When `sx` is still correct
Keep `sx` for things that don't have a native prop:
```tsx
<Link
component={RouterLink}
to="/settings"
color={theme.palette.primary.main}
fontWeight={600}
sx={{ textDecoration: "underline" }} // OK — no native prop for textDecoration
/>
```
Pseudo-selectors (`"&:hover"`), `transition`, `cursor`, `outline`, `overflow` (when not on Box/Stack), media queries, and conditional spread objects all stay in `sx`.
---
## 2. Use the full theme path for colors, never the shorthand string
Prop forms like `color="text.secondary"`, `color="primary.main"`, `bgcolor="background.paper"` work, but they are **forbidden in this codebase**.
**Why:** the shorthand is invisible to grep. If a future change needs to find every place that consumes `theme.palette.text.secondary` — to retheme, to swap, to audit — the shorthand strings are missed. That is a maintenance trap. It's worth a few extra characters at every callsite to keep the codebase searchable.
**How to apply:** always destructure or reference the theme value explicitly.
```tsx
// BAD
<Typography color="text.secondary">…</Typography>
<Typography color="primary.main">…</Typography>
// GOOD
const theme = useTheme();
<Typography color={theme.palette.text.secondary}>…</Typography>
<Typography color={theme.palette.primary.main}>…</Typography>
```
This applies to every theme-aware string prop (`color`, `bgcolor`, `borderColor`, etc.), and inside `sx` if you really need it there.
---
## 3. No hardcoded numeric or string literals — use tokens
The theme exposes tokens for every kind of magic value. Use them. Reserve raw numbers for values that are genuinely one-off (and even then, push back on yourself first).
**Why:** tokens are the single source of truth. A literal `2` in a `mt` prop is invisible — it could mean `LAYOUT.XXS`, a rounding correction, or a thinko. The reader can't tell. With a token, intent is in the name.
**How to apply:** before writing a literal, check whether one of these tokens covers it. If it nearly does but not exactly, extend the token rather than hardcoding (Alex did exactly this — he added `LAYOUT.XXS = 2` rather than letting `mt: "2px"` stand).
### Token sources
- **Spacing / layout** — `client/src/Utils/Theme/constants.ts`
- `LAYOUT.XXS = 2` · `LAYOUT.XS = 4` · `LAYOUT.SM = 6` · `LAYOUT.MD = 8` · `LAYOUT.LG = 10` · `LAYOUT.XL = 12` · `LAYOUT.XXL = 16`
- `SPACING` for `theme.spacing(...)` multipliers
- **Typography sizes** — `client/src/Utils/Theme/Palette.ts` → `typographyLevels`
- `xs / s / m / l / xl / xxl` (rem values)
- **Hover / interaction** — `HOVER.DARKEN` etc.
- **Shape** — `theme.shape.borderRadius` (don't write `borderRadius: 1` or `borderRadius: 4`)
- **Colors** — `theme.palette.*` (rule 2 above)
### Examples
```tsx
// BAD
<Box sx={{ mt: "2px" }} />
<Stack spacing={theme.spacing(2)} />
<Box sx={{ borderRadius: 1 }} />
<Typography fontSize={18} />
<Typography fontSize={13} /> // 13 is the body default — drop the prop entirely
// GOOD
<Box mt={LAYOUT.XXS} />
<Stack spacing={theme.spacing(LAYOUT.XXS)} />
<Box borderRadius={theme.shape.borderRadius} />
<Typography fontSize={typographyLevels.xl} />
<Typography /> // inherits 13px from theme
```
### Token naming gotcha
`typographyLevels.xl` was historically used for both 18px and 23px callsites. Don't reuse a token for two different purposes — split it (`xl` = 18, `xxl` = 23) and update every callsite. If you find an ambiguous token while editing, fix it the same way.
---
## 4. Use `useTheme()` inside components, don't import the theme module
The `theme` object exported from `@/Utils/Theme/Theme` is the *factory output*, not a live, mode-aware instance. Importing it directly couples a component to the wrong reference and breaks dark-mode reactivity. It also breaks the build at import time in some module-graph configurations (this happened in PR #3596 and required a follow-up commit).
**Why:** themes can change at runtime (mode toggle), and the hook is the only correct way to read the active theme. Imports also create circular-graph risk because `Theme.ts` itself depends on `Palette.ts`.
**How to apply:** inside any component or component-helper that needs theme values:
```tsx
// BAD
import { theme } from "@/Utils/Theme/Theme";
const SectionHeading = ({ children }) => (
<Typography color={theme.palette.text.secondary}>{children}</Typography>
);
// GOOD
import { useTheme } from "@mui/material/styles";
const SectionHeading = ({ children }) => {
const theme = useTheme();
return <Typography color={theme.palette.text.secondary}>{children}</Typography>;
};
```
Module-level utility files that don't render React (pure helpers) should accept `theme` as a parameter rather than importing it.
---
## 5. Discriminated unions: pair the runtime list with the type
When you have a closed set of string literals that's used both as a type and as runtime data (severities, statuses, kinds), declare the runtime tuple first and derive the type from it. Don't declare them as two parallel literals that can drift.
**Why:** if the list and the type aren't tied to each other, adding a new value to one and forgetting the other compiles fine and breaks at runtime. Deriving the type from the tuple makes them inseparable.
```tsx
// BAD
type Severity = "info" | "warning" | "error";
// GOOD
export const Severities = ["info", "warning", "error"] as const;
export type Severity = (typeof Severities)[number];
```
Export both when callers need to iterate (e.g. for a Storybook stories matrix or a select dropdown).
---
## Pre-PR checklist
Before you open a PR, scan your diff for these:
- [ ] Any `sx={{ … }}` block that contains keys with a native-prop equivalent (rule 1)
- [ ] Any `color="text.X"`, `bgcolor="background.X"`, or any short-string palette path (rule 2)
- [ ] Any raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3)
- [ ] Any `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4)
- [ ] Any `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5)
- [ ] Run `npm run build` and `npm run format-check` in both `client/` and `server/` (per global instructions)
A clean PR on these axes is a PR that ships without a normalize follow-up.
+4554 -10661
View File
File diff suppressed because it is too large. Load diff
-53
View File
@@ -1,53 +0,0 @@
import { z } from "zod";
export const successEnvelope = <T extends z.ZodTypeAny>(data: T) =>
z.object({
success: z.literal(true),
msg: z.string(),
data,
});
export const successEnvelopeNoData = z.object({
success: z.literal(true),
msg: z.string(),
});
export const errorEnvelope = z.object({
success: z.literal(false),
msg: z.string(),
data: z.unknown().optional(),
});
export const bearer = [{ bearerAuth: [] }];
export const json = <T extends z.ZodTypeAny>(schema: T, example?: unknown) => ({
"application/json": example === undefined ? { schema } : { schema, example },
});
export const standardErrors = {
"401": { description: "Unauthorized", content: json(errorEnvelope) },
"403": { description: "Forbidden", content: json(errorEnvelope) },
"500": { description: "Internal server error", content: json(errorEnvelope) },
};
export const okJson = <T extends z.ZodTypeAny>(data: T, description = "OK", example?: unknown) => ({
description,
content: json(successEnvelope(data), example === undefined ? undefined : { success: true, msg: "OK", data: example }),
});
export const okJsonNoData = (description = "OK") => ({
description,
content: json(successEnvelopeNoData, { success: true, msg: "OK" }),
});
export const okUnknown = okJson(z.unknown());
export const multipart = (fields: Record<string, z.ZodTypeAny>, fileField?: string) => {
const shape: Record<string, z.ZodTypeAny> = { ...fields };
if (fileField) {
shape[fileField] = z.string().openapi({ type: "string", format: "binary" });
}
return {
"multipart/form-data": { schema: z.object(shape) },
};
};
-56
View File
@@ -1,56 +0,0 @@
import "./registry.js";
import "./routes/auth.js";
import "./routes/check.js";
import "./routes/diagnostic.js";
import "./routes/geoCheck.js";
import "./routes/incident.js";
import "./routes/invite.js";
import "./routes/log.js";
import "./routes/maintenanceWindow.js";
import "./routes/monitor.js";
import "./routes/notification.js";
import "./routes/queue.js";
import "./routes/settings.js";
import "./routes/statusPage.js";
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
import type { JsonObject } from "swagger-ui-express";
import { registry } from "./registry.js";
let cached: JsonObject | null = null;
export function getOpenApiSpec(): JsonObject {
if (cached) return cached;
const generator = new OpenApiGeneratorV3(registry.definitions);
const document = generator.generateDocument({
openapi: "3.0.0",
info: {
title: "Checkmate API",
version: "1.0.0",
description: "Generated from Zod validation schemas. Source of truth: server/src/validation/*.ts",
},
servers: [{ url: "/api/v1", description: "Current server" }],
tags: [
{ name: "auth", description: "Sign up, log in, password reset, and the authenticated user's profile." },
{ name: "monitors", description: "Configure and inspect monitors (HTTP, port, ping, hardware, docker, game, gRPC) for the caller's team." },
{ name: "checks", description: "Individual monitoring check results, including history, summaries, and bulk deletes." },
{ name: "geo-checks", description: "Geographic check results for monitors with multi-region probing enabled." },
{ name: "incidents", description: "Downtime incidents derived from monitor state changes, including filtering and CSV export." },
{
name: "notifications",
description: "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts.",
},
{ name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." },
{ name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." },
{ name: "settings", description: "Global application settings (admin/superadmin)." },
{ name: "invite", description: "Team invitations and accepting them." },
{ name: "queue", description: "Background job queue introspection and admin actions (admin/superadmin)." },
{ name: "diagnostic", description: "System diagnostics for the running server (admin/superadmin)." },
{ name: "logs", description: "Application logs for the running server (admin/superadmin)." },
],
});
cached = document as unknown as JsonObject;
return cached;
}
-12
View File
@@ -1,12 +0,0 @@
import { OpenAPIRegistry, extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
extendZodWithOpenApi(z);
export const registry = new OpenAPIRegistry();
registry.registerComponent("securitySchemes", "bearerAuth", {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
});
-207
View File
@@ -1,207 +0,0 @@
import { z } from "zod";
import { registry } from "../registry.js";
import { bearer, json, multipart, okJson, okJsonNoData, standardErrors } from "../helpers.js";
import {
loginValidation,
registrationBodyValidation,
recoveryValidation,
recoveryTokenBodyValidation,
newPasswordValidation,
} from "@/validation/authValidation.js";
import {
getUserByIdParamValidation,
editUserByIdParamValidation,
editUserByIdBodyValidation,
editUserPasswordByIdBodyValidation,
createUserBodyValidation,
editUserBodyValidation,
userResponseSchema,
} from "@/validation/userValidation.js";
const tags = ["auth"];
const userExample = {
_id: "65f1c2a4d8b9e0123456789a",
firstName: "Ada",
lastName: "Lovelace",
email: "ada@example.com",
role: ["admin"],
teamId: "65f1c2a4d8b9e01234567890",
createdAt: "2026-04-01T10:00:00.000Z",
updatedAt: "2026-04-15T14:30:00.000Z",
};
const userObject = userResponseSchema.openapi("User", { example: userExample });
const authPayload = z
.object({
user: userObject,
token: z.string().optional(),
})
.openapi("AuthPayload", {
example: {
user: userExample,
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
},
});
registry.registerPath({
method: "post",
path: "/auth/register",
tags,
summary: "Register a new user (first user becomes superadmin)",
request: { body: { content: multipart(registrationBodyValidation.shape, "profileImage") } },
responses: { "200": okJson(authPayload), ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/auth/login",
tags,
summary: "Log in",
request: {
body: {
content: json(loginValidation, { email: "ada@example.com", password: "S3cure!Passw0rd" }),
},
},
responses: {
"200": okJson(authPayload, "OK", {
user: {
_id: "65f1c2a4d8b9e0123456789a",
firstName: "Ada",
lastName: "Lovelace",
email: "ada@example.com",
role: ["admin"],
teamId: "65f1c2a4d8b9e01234567890",
createdAt: "2026-04-01T10:00:00.000Z",
updatedAt: "2026-04-15T14:30:00.000Z",
},
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
}),
"401": { description: "Invalid credentials", content: json(z.object({ success: z.literal(false), msg: z.string() })) },
"500": standardErrors["500"],
},
});
registry.registerPath({
method: "post",
path: "/auth/recovery/request",
tags,
summary: "Request a password recovery email",
request: { body: { content: json(recoveryValidation, { email: "ada@example.com" }) } },
responses: { "200": okJsonNoData(), "500": standardErrors["500"] },
});
registry.registerPath({
method: "post",
path: "/auth/recovery/validate",
tags,
summary: "Validate a password recovery token",
request: { body: { content: json(recoveryTokenBodyValidation) } },
responses: { "200": okJsonNoData(), "500": standardErrors["500"] },
});
registry.registerPath({
method: "post",
path: "/auth/recovery/reset",
tags,
summary: "Reset password using a recovery token",
request: { body: { content: json(newPasswordValidation) } },
responses: { "200": okJsonNoData(), "500": standardErrors["500"] },
});
registry.registerPath({
method: "get",
path: "/auth/users/superadmin",
tags,
summary: "Check whether a superadmin user exists",
responses: { "200": okJson(z.object({ superAdminExists: z.boolean() })), "500": standardErrors["500"] },
});
registry.registerPath({
method: "get",
path: "/auth/users",
tags,
summary: "List all users (admin/superadmin)",
security: bearer,
responses: { "200": okJson(z.array(userObject)), ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/auth/users",
tags,
summary: "Create a new user (superadmin)",
security: bearer,
request: { body: { content: multipart(createUserBodyValidation.shape, "profileImage") } },
responses: { "201": okJson(userObject, "User created"), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/auth/users/{userId}",
tags,
summary: "Get a user by id",
security: bearer,
request: { params: getUserByIdParamValidation },
responses: {
"200": okJson(userObject),
"404": { description: "User not found", content: json(z.object({ success: z.literal(false), msg: z.string() })) },
...standardErrors,
},
});
registry.registerPath({
method: "patch",
path: "/auth/users/{userId}",
tags,
summary: "Edit a user (superadmin)",
security: bearer,
request: {
params: editUserByIdParamValidation,
body: { content: json(editUserByIdBodyValidation) },
},
responses: { "200": okJsonNoData(), ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/auth/users/{userId}/password",
tags,
summary: "Change a user's password (superadmin)",
security: bearer,
request: {
params: editUserByIdParamValidation,
body: { content: json(editUserPasswordByIdBodyValidation) },
},
responses: { "200": okJsonNoData(), ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/auth/users/{userId}",
tags,
summary: "Delete a user (admin/superadmin)",
security: bearer,
request: { params: getUserByIdParamValidation },
responses: { "200": okJsonNoData(), ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/auth/user",
tags,
summary: "Edit the currently authenticated user",
security: bearer,
request: { body: { content: multipart(editUserBodyValidation.shape, "profileImage") } },
responses: { "200": okJson(userObject), ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/auth/user",
tags,
summary: "Delete the currently authenticated user",
security: bearer,
responses: { "200": okJsonNoData(), ...standardErrors },
});
-61
View File
@@ -1,61 +0,0 @@
import { z } from "zod";
import { registry } from "../registry.js";
import { bearer, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import {
getChecksParamValidation,
getChecksQueryValidation,
getTeamChecksQueryValidation,
deleteChecksParamValidation,
getChecksSummaryByTeamIdQueryValidation,
} from "@/validation/checkValidation.js";
const tags = ["checks"];
registry.registerPath({
method: "get",
path: "/checks/team/summary",
tags,
summary: "Aggregate check summary for the caller's team",
security: bearer,
request: { query: getChecksSummaryByTeamIdQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/checks/team",
tags,
summary: "List checks across the team",
security: bearer,
request: { query: getTeamChecksQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/checks/team",
tags,
summary: "Delete all checks for the team (admin/superadmin)",
security: bearer,
responses: { "200": okJson(z.object({ deletedCount: z.number() })), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/checks/{monitorId}",
tags,
summary: "List checks for a monitor",
security: bearer,
request: { params: getChecksParamValidation, query: getChecksQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/checks/{monitorId}",
tags,
summary: "Delete checks for a monitor",
security: bearer,
request: { params: deleteChecksParamValidation },
responses: { "200": okJsonNoData(), ...standardErrors },
});
-11
View File
@@ -1,11 +0,0 @@
import { registry } from "../registry.js";
import { bearer, okUnknown, standardErrors } from "../helpers.js";
registry.registerPath({
method: "get",
path: "/diagnostic/system",
tags: ["diagnostic"],
summary: "Get system diagnostics (admin/superadmin)",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
-13
View File
@@ -1,13 +0,0 @@
import { registry } from "../registry.js";
import { bearer, okUnknown, standardErrors } from "../helpers.js";
import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js";
registry.registerPath({
method: "get",
path: "/geo-checks/{monitorId}",
tags: ["geo-checks"],
summary: "Get geo check results for a monitor",
security: bearer,
request: { params: getChecksParamValidation, query: getChecksQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
-104
View File
@@ -1,104 +0,0 @@
import { z } from "zod";
import { registry } from "../registry.js";
import { bearer, okJson, okJsonNoData, standardErrors } from "../helpers.js";
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js";
const tags = ["incidents"];
const incidentIdParam = z.object({ incidentId: z.string() });
const incidentObject = z
.object({
id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789c" }),
monitorId: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }),
teamId: z.string().openapi({ example: "65f1c2a4d8b9e01234567890" }),
startTime: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }),
endTime: z.string().nullable().openapi({ example: "2026-04-15T03:34:00.000Z" }),
status: z.boolean().openapi({ example: true, description: "true = resolved, false = ongoing" }),
message: z.string().nullable().optional().openapi({ example: "HTTP 503 from origin" }),
statusCode: z.number().nullable().optional().openapi({ example: 503 }),
resolutionType: z.enum(["automatic", "manual"]).nullable().openapi({ example: "automatic" }),
resolvedBy: z.string().nullable().optional(),
resolvedByEmail: z.string().nullable().optional(),
comment: z.string().nullable().optional(),
createdAt: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }),
updatedAt: z.string().openapi({ example: "2026-04-15T03:34:00.000Z" }),
})
.passthrough()
.openapi("Incident");
const incidentSummaryItem = z
.object({
id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789c" }),
monitorId: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }),
monitorName: z.string().nullable().openapi({ example: "Marketing site" }),
status: z.boolean().openapi({ example: true }),
startTime: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }),
endTime: z.string().nullable().openapi({ example: "2026-04-15T03:34:00.000Z" }),
resolutionType: z.enum(["automatic", "manual"]).nullable().openapi({ example: "automatic" }),
message: z.string().nullable().openapi({ example: "HTTP 503 from origin" }),
statusCode: z.number().nullable().openapi({ example: 503 }),
createdAt: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }),
})
.openapi("IncidentSummaryItem");
const incidentSummary = z
.object({
total: z.number().openapi({ example: 12 }),
totalActive: z.number().openapi({ example: 1 }),
totalManualResolutions: z.number().openapi({ example: 2 }),
totalAutomaticResolutions: z.number().openapi({ example: 9 }),
avgResolutionTimeHours: z.number().openapi({ example: 0.5 }),
topMonitor: z
.object({
monitorId: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }),
monitorName: z.string().nullable().openapi({ example: "Marketing site" }),
incidentCount: z.number().openapi({ example: 4 }),
})
.nullable(),
latestIncidents: z.array(incidentSummaryItem),
})
.openapi("IncidentSummary");
registry.registerPath({
method: "get",
path: "/incidents/team",
tags,
summary: "List incidents for the caller's team",
security: bearer,
request: { query: getIncidentsByTeamQueryValidation },
responses: { "200": okJson(z.array(incidentObject)), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/incidents/team/summary",
tags,
summary: "Incident summary for the caller's team",
security: bearer,
request: { query: getIncidentSummaryQueryValidation },
responses: { "200": okJson(incidentSummary), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/incidents/{incidentId}",
tags,
summary: "Get an incident by id",
security: bearer,
request: { params: incidentIdParam },
responses: {
"200": okJson(z.object({ incident: incidentObject, monitor: z.unknown(), user: z.unknown().nullable() })),
...standardErrors,
},
});
registry.registerPath({
method: "put",
path: "/incidents/{incidentId}/resolve",
tags,
summary: "Manually resolve an incident (admin/superadmin)",
security: bearer,
request: { params: incidentIdParam },
responses: { "200": okJsonNoData(), ...standardErrors },
});
-35
View File
@@ -1,35 +0,0 @@
import { registry } from "../registry.js";
import { bearer, json, okJson, okJsonNoData, standardErrors } from "../helpers.js";
import { z } from "zod";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js";
const tags = ["invite"];
registry.registerPath({
method: "post",
path: "/invite/send",
tags,
summary: "Send an invite email (admin/superadmin)",
security: bearer,
request: { body: { content: json(inviteBodyValidation) } },
responses: { "200": okJsonNoData(), ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/invite/verify",
tags,
summary: "Verify an invite token",
request: { body: { content: json(inviteVerificationBodyValidation) } },
responses: { "200": okJson(z.object({ email: z.string(), role: z.array(z.string()).optional() }).passthrough()), "500": standardErrors["500"] },
});
registry.registerPath({
method: "post",
path: "/invite",
tags,
summary: "Create an invite token (admin/superadmin)",
security: bearer,
request: { body: { content: json(inviteBodyValidation) } },
responses: { "200": okJson(z.object({ token: z.string() }).passthrough()), ...standardErrors },
});
-11
View File
@@ -1,11 +0,0 @@
import { registry } from "../registry.js";
import { bearer, okUnknown, standardErrors } from "../helpers.js";
registry.registerPath({
method: "get",
path: "/logs",
tags: ["logs"],
summary: "Get application logs (admin/superadmin)",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
@@ -1,73 +0,0 @@
import { registry } from "../registry.js";
import { bearer, json, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import {
createMaintenanceWindowBodyValidation,
getMaintenanceWindowByIdParamValidation,
getMaintenanceWindowsByTeamIdQueryValidation,
getMaintenanceWindowsByMonitorIdParamValidation,
deleteMaintenanceWindowByIdParamValidation,
editMaintenanceWindowByIdParamValidation,
editMaintenanceByIdWindowBodyValidation,
} from "@/validation/maintenanceWindowValidation.js";
const tags = ["maintenance-window"];
registry.registerPath({
method: "post",
path: "/maintenance-window",
tags,
summary: "Create one or more maintenance windows",
security: bearer,
request: { body: { content: json(createMaintenanceWindowBodyValidation) } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/maintenance-window/team",
tags,
summary: "List maintenance windows for the caller's team",
security: bearer,
request: { query: getMaintenanceWindowsByTeamIdQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/maintenance-window/monitor/{monitorId}",
tags,
summary: "List maintenance windows for a monitor",
security: bearer,
request: { params: getMaintenanceWindowsByMonitorIdParamValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/maintenance-window/{id}",
tags,
summary: "Get a maintenance window by id",
security: bearer,
request: { params: getMaintenanceWindowByIdParamValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/maintenance-window/{id}",
tags,
summary: "Edit a maintenance window",
security: bearer,
request: { params: editMaintenanceWindowByIdParamValidation, body: { content: json(editMaintenanceByIdWindowBodyValidation) } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/maintenance-window/{id}",
tags,
summary: "Delete a maintenance window",
security: bearer,
request: { params: deleteMaintenanceWindowByIdParamValidation },
responses: { "200": okJsonNoData(), ...standardErrors },
});
-238
View File
@@ -1,238 +0,0 @@
import { z } from "zod";
import { registry } from "../registry.js";
import { bearer, json, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import {
getMonitorByIdParamValidation,
getMonitorByIdQueryValidation,
getMonitorsByTeamIdQueryValidation,
getMonitorsWithChecksQueryValidation,
getCertificateParamValidation,
createMonitorBodyValidation,
editMonitorBodyValidation,
pauseMonitorParamValidation,
getUptimeDetailsByIdParamValidation,
getUptimeDetailsByIdQueryValidation,
importMonitorsBodyValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
monitorResponseSchema,
} from "@/validation/monitorValidation.js";
import { updateNotificationsValidation } from "@/validation/notificationValidation.js";
const tags = ["monitors"];
const monitorObject = monitorResponseSchema.openapi("Monitor", {
example: {
_id: "65f1c2a4d8b9e0123456789a",
name: "Marketing site",
description: "Production marketing site monitored from the EU region",
type: "http",
url: "https://www.example.com",
port: 443,
isActive: true,
interval: 60000,
status: "up",
statusWindowSize: 5,
statusWindowThreshold: 3,
ignoreTlsErrors: false,
useAdvancedMatching: false,
notifications: ["65f1c2a4d8b9e0123456789b"],
cpuAlertThreshold: 90,
memoryAlertThreshold: 90,
diskAlertThreshold: 90,
tempAlertThreshold: 80,
selectedDisks: [],
geoCheckEnabled: false,
geoCheckLocations: [],
geoCheckInterval: 300000,
teamId: "65f1c2a4d8b9e01234567890",
userId: "65f1c2a4d8b9e01234567891",
createdAt: "2026-04-01T10:00:00.000Z",
updatedAt: "2026-04-15T14:30:00.000Z",
},
});
registry.registerPath({
method: "get",
path: "/monitors/team",
tags,
summary: "List monitors for the caller's team",
security: bearer,
request: { query: getMonitorsByTeamIdQueryValidation },
responses: { "200": okJson(z.array(monitorObject)), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/team/with-checks",
tags,
summary: "List team monitors with their most recent checks (paginated)",
security: bearer,
request: { query: getMonitorsWithChecksQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/team/groups",
tags,
summary: "List monitor groups for the caller's team",
security: bearer,
responses: { "200": okJson(z.array(z.string())), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/uptime/details/{monitorId}",
tags,
summary: "Get uptime details for a monitor",
security: bearer,
request: { params: getUptimeDetailsByIdParamValidation, query: getUptimeDetailsByIdQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/hardware/details/{monitorId}",
tags,
summary: "Get hardware metrics detail for a monitor",
security: bearer,
request: { params: getHardwareDetailsByIdParamValidation, query: getHardwareDetailsByIdQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/pagespeed/details/{monitorId}",
tags,
summary: "Get PageSpeed detail for a monitor",
security: bearer,
request: { params: getHardwareDetailsByIdParamValidation, query: getHardwareDetailsByIdQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/{monitorId}/geo-checks",
tags,
summary: "Get geo check results for a monitor",
security: bearer,
request: { params: getMonitorByIdParamValidation, query: getMonitorByIdQueryValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/monitors/pause/{monitorId}",
tags,
summary: "Toggle pause state for a monitor",
security: bearer,
request: { params: pauseMonitorParamValidation },
responses: { "200": okJson(monitorObject), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/certificate/{monitorId}",
tags,
summary: "Get SSL certificate info for a monitor",
security: bearer,
request: { params: getCertificateParamValidation },
responses: { "200": okJson(z.object({ certificateDate: z.string() })), ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/monitors/notifications",
tags,
summary: "Bulk update notifications across monitors",
security: bearer,
request: { body: { content: json(updateNotificationsValidation) } },
responses: { "200": okJson(z.object({ modifiedCount: z.number() })), ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/monitors",
tags,
summary: "Create a new monitor",
security: bearer,
request: { body: { content: json(createMonitorBodyValidation) } },
responses: { "200": okJson(monitorObject, "Monitor created"), ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/monitors",
tags,
summary: "Delete every monitor (superadmin only)",
security: bearer,
responses: { "200": okJsonNoData(), ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/monitors/demo",
tags,
summary: "Insert preconfigured demo monitors",
security: bearer,
responses: { "200": okJson(z.number()), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/export/json",
tags,
summary: "Export all team monitors as JSON",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/monitors/import/json",
tags,
summary: "Import monitors from JSON",
security: bearer,
request: { body: { content: json(importMonitorsBodyValidation) } },
responses: { "200": okJson(z.object({ imported: z.number() }).passthrough()), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/games",
tags,
summary: "List supported game-server types",
security: bearer,
responses: { "200": okJson(z.array(z.string())), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/monitors/{monitorId}",
tags,
summary: "Get a monitor by id",
security: bearer,
request: { params: getMonitorByIdParamValidation },
responses: { "200": okJson(monitorObject), ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/monitors/{monitorId}",
tags,
summary: "Edit a monitor",
security: bearer,
request: { params: getMonitorByIdParamValidation, body: { content: json(editMonitorBodyValidation) } },
responses: { "200": okJson(monitorObject), ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/monitors/{monitorId}",
tags,
summary: "Delete a monitor",
security: bearer,
request: { params: getMonitorByIdParamValidation },
responses: { "200": okJson(monitorObject), ...standardErrors },
});
-155
View File
@@ -1,155 +0,0 @@
import { z } from "zod";
import { registry } from "../registry.js";
import { bearer, json, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import {
createNotificationBodyValidation,
deleteNotificationParamValidation,
getNotificationByIdParamValidation,
editNotificationParamValidation,
testAllNotificationsBodyValidation,
} from "@/validation/notificationValidation.js";
const tags = ["notifications"];
// OpenAPI metadata for each notification variant. Keyed by the discriminator
// value in createNotificationBodyValidation; build fails loudly if a variant
// in the validator has no entry here, which doubles as drift detection.
const notificationVariantMeta: Record<string, { component: string; example: Record<string, unknown> }> = {
email: {
component: "EmailNotification",
example: { notificationName: "Ops on-call email", type: "email", address: "alerts@example.com" },
},
webhook: {
component: "WebhookNotification",
example: { notificationName: "Custom webhook", type: "webhook", address: "https://example.com/hooks/checkmate" },
},
slack: {
component: "SlackNotification",
example: { notificationName: "#alerts", type: "slack", address: "https://hooks.slack.com/services/T000/B000/XXXX" },
},
discord: {
component: "DiscordNotification",
example: { notificationName: "#status", type: "discord", address: "https://discord.com/api/webhooks/123/abc" },
},
pager_duty: {
component: "PagerDutyNotification",
example: { notificationName: "PagerDuty primary", type: "pager_duty", address: "R01XXXXXXXXXXXXXXXXXXXXXXX" },
},
matrix: {
component: "MatrixNotification",
example: {
notificationName: "Matrix room",
type: "matrix",
homeserverUrl: "https://matrix.example.com",
roomId: "!abc123:example.com",
accessToken: "syt_xxx",
},
},
teams: {
component: "TeamsNotification",
example: { notificationName: "Teams ops channel", type: "teams", address: "https://outlook.office.com/webhook/..." },
},
telegram: {
component: "TelegramNotification",
example: { notificationName: "Telegram bot", type: "telegram", address: "-1001234567890", accessToken: "123456:ABC-DEF" },
},
pushover: {
component: "PushoverNotification",
example: { notificationName: "Pushover personal", type: "pushover", address: "u1234567890abcdef", accessToken: "a1234567890abcdef" },
},
twilio: {
component: "TwilioNotification",
example: {
notificationName: "Twilio SMS",
type: "twilio",
accountSid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
accessToken: "your-auth-token",
phone: "+15551234567",
twilioPhoneNumber: "+15557654321",
},
},
};
const decoratedVariants = createNotificationBodyValidation.options.map((variant) => {
const typeField = variant.shape.type as z.ZodLiteral<string>;
const type = typeField.value;
const meta = notificationVariantMeta[type];
if (!meta) {
throw new Error(`Missing OpenAPI metadata for notification variant "${type}". Add an entry in notificationVariantMeta.`);
}
return variant.openapi(meta.component, { example: meta.example });
});
const notificationBody = z
.discriminatedUnion("type", decoratedVariants as typeof createNotificationBodyValidation.options)
.openapi("NotificationChannelBody");
registry.registerPath({
method: "post",
path: "/notifications",
tags,
summary: "Create a notification channel",
description: "Create a notification channel of any supported type. The `type` field discriminates the body shape.",
security: bearer,
request: { body: { content: json(notificationBody) } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/notifications/test/all",
tags,
summary: "Send a test alert through every notification channel for the team",
security: bearer,
request: { body: { content: json(testAllNotificationsBodyValidation) } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/notifications/test",
tags,
summary: "Send a test alert through a single notification channel",
security: bearer,
request: { body: { content: json(notificationBody) } },
responses: { "200": okJson(z.object({ success: z.boolean() }).passthrough()), ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/notifications/team",
tags,
summary: "List notification channels for the caller's team",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/notifications/{id}",
tags,
summary: "Get a notification channel by id",
security: bearer,
request: { params: getNotificationByIdParamValidation },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "delete",
path: "/notifications/{id}",
tags,
summary: "Delete a notification channel",
security: bearer,
request: { params: deleteNotificationParamValidation },
responses: { "200": okJsonNoData(), ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/notifications/{id}",
tags,
summary: "Edit a notification channel",
security: bearer,
request: { params: editNotificationParamValidation, body: { content: json(notificationBody) } },
responses: { "200": okUnknown, ...standardErrors },
});
-40
View File
@@ -1,40 +0,0 @@
import { registry } from "../registry.js";
import { bearer, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
const tags = ["queue"];
registry.registerPath({
method: "get",
path: "/queue/jobs",
tags,
summary: "List queued monitor jobs (admin/superadmin)",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/queue/metrics",
tags,
summary: "Get queue runtime metrics (admin/superadmin)",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/queue/all-metrics",
tags,
summary: "Get queue metrics across all monitors (admin/superadmin)",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/queue/flush",
tags,
summary: "Flush the monitor job queue (admin/superadmin)",
security: bearer,
responses: { "200": okJsonNoData(), ...standardErrors },
});
-35
View File
@@ -1,35 +0,0 @@
import { registry } from "../registry.js";
import { bearer, json, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js";
import { sendTestEmailBodyValidation } from "@/validation/notificationValidation.js";
const tags = ["settings"];
registry.registerPath({
method: "get",
path: "/settings",
tags,
summary: "Get application settings",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "patch",
path: "/settings",
tags,
summary: "Update application settings (admin/superadmin)",
security: bearer,
request: { body: { content: json(updateAppSettingsBodyValidation) } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/settings/test-email",
tags,
summary: "Send a test email using current SMTP settings (admin/superadmin)",
security: bearer,
request: { body: { content: json(sendTestEmailBodyValidation) } },
responses: { "200": okJsonNoData(), ...standardErrors },
});
-52
View File
@@ -1,52 +0,0 @@
import { registry } from "../registry.js";
import { bearer, multipart, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation } from "@/validation/statusPageValidation.js";
const tags = ["status-page"];
registry.registerPath({
method: "get",
path: "/status-page/team",
tags,
summary: "List status pages for the caller's team",
security: bearer,
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "post",
path: "/status-page",
tags,
summary: "Create a status page (logo upload optional)",
security: bearer,
request: { body: { content: multipart(createStatusPageBodyValidation.shape, "logo") } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "put",
path: "/status-page/{id}",
tags,
summary: "Update a status page (logo upload optional)",
security: bearer,
request: { body: { content: multipart(createStatusPageBodyValidation.shape, "logo") } },
responses: { "200": okUnknown, ...standardErrors },
});
registry.registerPath({
method: "get",
path: "/status-page/{url}",
tags,
summary: "Get a public status page by its URL slug",
request: { params: getStatusPageParamValidation, query: getStatusPageQueryValidation },
responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] },
});
registry.registerPath({
method: "delete",
path: "/status-page/{id}",
tags,
summary: "Delete a status page",
security: bearer,
responses: { "200": okJsonNoData(), ...standardErrors },
});
+8 -33
View File
@@ -12,7 +12,7 @@
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@pulsecron/pulse": "1.6.8",
"axios": "^1.15.2",
"axios": "^1.7.2",
"bcryptjs": "3.0.2",
"cacheable-lookup": "7.0.0",
"compression": "1.8.1",
@@ -34,7 +34,7 @@
"mailersend": "^2.2.0",
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
"multer": "^1.4.5-lts.2",
"multer": "^1.4.5-lts.1",
"nodemailer": "8.0.5",
"ping": "0.4.4",
"sharp": "0.33.5",
@@ -46,7 +46,6 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@asteasolutions/zod-to-openapi": "8.5.0",
"@eslint/js": "^9.17.0",
"@types/compression": "1.8.1",
"@types/cookie-parser": "1.4.10",
@@ -127,19 +126,6 @@
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"license": "MIT"
},
"node_modules/@asteasolutions/zod-to-openapi": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz",
"integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"openapi3-ts": "^4.1.2"
},
"peerDependencies": {
"zod": "^4.0.0"
}
},
"node_modules/@aws-crypto/sha256-browser": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
@@ -5455,9 +5441,9 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz",
"integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
@@ -11970,16 +11956,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi3-ts": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz",
"integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yaml": "^2.8.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -12353,9 +12329,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
@@ -15562,7 +15538,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+3 -6
View File
@@ -11,9 +11,7 @@
"test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --no-coverage test/integration",
"dev": "nodemon --exec tsx src/index.js",
"start": "node --watch ./dist/index.js",
"clean": "rm -rf dist",
"build": "npm run clean && npm run build:openapi && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && cp -r src/templates dist/templates",
"build:openapi": "tsc --noEmit && tsx scripts/buildOpenApi.ts",
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
"lint": "eslint .",
"lint-fix": "eslint --fix .",
"format": "prettier --write .",
@@ -32,7 +30,7 @@
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@pulsecron/pulse": "1.6.8",
"axios": "^1.15.2",
"axios": "^1.7.2",
"bcryptjs": "3.0.2",
"cacheable-lookup": "7.0.0",
"compression": "1.8.1",
@@ -54,7 +52,7 @@
"mailersend": "^2.2.0",
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
"multer": "^1.4.5-lts.2",
"multer": "^1.4.5-lts.1",
"nodemailer": "8.0.5",
"ping": "0.4.4",
"sharp": "0.33.5",
@@ -66,7 +64,6 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@asteasolutions/zod-to-openapi": "8.5.0",
"@eslint/js": "^9.17.0",
"@types/compression": "1.8.1",
"@types/cookie-parser": "1.4.10",
-10
View File
@@ -1,10 +0,0 @@
import { writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getOpenApiSpec } from "../openapi/index.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outPath = path.resolve(__dirname, "..", "openapi.json");
writeFileSync(outPath, JSON.stringify(getOpenApiSpec(), null, 2) + "\n");
console.log(`OpenAPI spec written to ${outPath}`);
+1 -29
View File
@@ -14,12 +14,12 @@ import {
getUptimeDetailsByIdParamValidation,
getUptimeDetailsByIdQueryValidation,
importMonitorsBodyValidation,
bulkPauseMonitorBodyValidation,
} from "@/validation/monitorValidation.js";
import sslChecker from "ssl-checker";
import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorService, INotificationsService } from "@/service/index.js";
import { GeoContinent } from "@/types/geoCheck.js";
const SERVICE_NAME = "monitorController";
@@ -36,7 +36,6 @@ export interface IMonitorController {
deleteAllMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
editMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
pauseMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
bulkPauseMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
addDemoMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMonitorsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMonitorsWithChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
@@ -308,33 +307,6 @@ class MonitorController implements IMonitorController {
}
};
bulkPauseMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = bulkPauseMonitorBodyValidation.parse(req.body);
const { monitorIds, pause } = validatedBody;
const teamId = requireTeamId(req.user?.teamId);
const { monitors, failedCount } = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause });
const action = pause ? "paused" : "resumed";
const monitorStr = monitors.length === 1 ? "monitor" : "monitors";
let msg = `${monitors.length} ${monitorStr} ${action} successfully`;
if (failedCount > 0) {
msg = `${monitors.length} ${monitorStr} ${action} in database, but ${failedCount} failed to sync with the job queue. Please check logs.`;
}
return res.status(200).json({
success: true,
msg,
data: monitors,
});
} catch (error) {
next(error);
}
};
addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const id = requireUserId(req.user?.id);
@@ -39,7 +39,6 @@ export interface IMonitorsRepository {
statusPatch?: Partial<Monitor>
): Promise<Monitor>;
togglePauseById(monitorId: string, teamId: string): Promise<Monitor>;
bulkTogglePause(monitorIds: string[], teamId: string, pause: boolean): Promise<Monitor[]>;
// delete
deleteById(monitorId: string, teamId: string): Promise<Monitor>;
deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }>;
@@ -234,34 +234,6 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return this.toEntity(monitor);
};
bulkTogglePause = async (monitorIds: string[], teamId: string, pause: boolean): Promise<Monitor[]> => {
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const filter = {
_id: { $in: objectIds },
teamId: new mongoose.Types.ObjectId(teamId),
isActive: pause, // Only pause if active (true), only resume if inactive (false)
};
const nextStatus = pause ? "paused" : "initializing";
const eligible = await MonitorModel.find(filter);
if (eligible.length === 0) return [];
const now = new Date();
await MonitorModel.updateMany(
{ _id: { $in: eligible.map((doc) => doc._id) } },
{ $set: { isActive: !pause, status: nextStatus, updatedAt: now } },
{ timestamps: false }
);
eligible.forEach((doc) => {
doc.isActive = !pause;
doc.status = nextStatus;
doc.updatedAt = now;
});
return this.mapDocuments(eligible);
};
deleteById = async (monitorId: string, teamId: string) => {
const deletedMonitor = await MonitorModel.findOneAndDelete({ _id: monitorId, teamId });
-1
View File
@@ -31,7 +31,6 @@ class MonitorRoutes {
// General monitor routes
this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor);
this.router.post("/bulk/pause", isAllowed(["admin", "superadmin"]), this.monitorController.bulkPauseMonitors);
// Util routes
this.router.get("/certificate/:monitorId", (req, res, next) => {
@@ -73,7 +73,6 @@ export interface IMonitorService {
// update
editMonitor(args: { teamId: string; monitorId: string; body: Partial<Monitor> }): Promise<Monitor>;
pauseMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise<{ monitors: Monitor[]; failedCount: number }>;
// delete
deleteMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
@@ -463,43 +462,6 @@ export class MonitorService implements IMonitorService {
return monitor;
};
bulkPauseMonitors = async ({
teamId,
monitorIds,
pause,
}: {
teamId: string;
monitorIds: string[];
pause: boolean;
}): Promise<{ monitors: Monitor[]; failedCount: number }> => {
const monitors = await this.monitorsRepository.bulkTogglePause(monitorIds, teamId, pause);
const results = await Promise.allSettled(
monitors.map(async (monitor) => {
if (monitor.isActive) {
await this.jobQueue.resumeJob(monitor);
} else {
await this.jobQueue.pauseJob(monitor);
}
})
);
let failedCount = 0;
results.forEach((result, index) => {
if (result.status === "rejected") {
failedCount++;
this.logger.error({
message: `Failed to sync job queue for monitor ${monitors[index]?.id || "unknown"} during bulk ${pause ? "pause" : "resume"}`,
service: SERVICE_NAME,
method: "bulkPauseMonitors",
stack: result.reason instanceof Error ? result.reason.stack : undefined,
});
}
});
return { monitors, failedCount };
};
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
@@ -28,8 +28,6 @@ type QueueJobSummary = {
monitorUrl: string | null;
monitorType: string | null;
monitorInterval: number | null;
monitorGeoInterval: number | null;
monitorActive: boolean | null;
active: boolean;
lockedAt: number | null;
runCount: number;
@@ -39,7 +37,6 @@ type QueueJobSummary = {
lastFinishedAt: number | null;
lastRunTook: number | null;
lastFailedAt: number | null;
repeat: number | null;
};
export interface ISuperSimpleQueue {
@@ -226,20 +223,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (result === false) {
throw new Error("Failed to pause monitor");
}
this.scheduler.updateJob(monitor.id, { data: monitor });
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) {
const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`);
if (geoRes === false) {
this.logger.error({
message: `Failed to pause geo check job for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "pauseJob",
});
}
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
}
if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`);
this.logger.debug({
message: `Paused monitor ${monitor.id}`,
@@ -253,20 +238,9 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
if (result === false) {
throw new Error("Failed to resume monitor");
}
this.scheduler.updateJob(monitor.id, { data: monitor });
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
if (geoJob) {
const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`);
if (geoRes === false) {
this.logger.error({
message: `Failed to resume geo check job for monitor ${monitor.id}`,
service: SERVICE_NAME,
method: "resumeJob",
});
}
this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor });
}
await this.scheduler.resumeJob(`${monitor.id}-geo`);
this.logger.debug({
message: `Resumed monitor ${monitor.id}`,
service: SERVICE_NAME,
@@ -367,10 +341,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
monitorInterval: job.data?.interval || null,
monitorGeoInterval: job.data?.geoCheckInterval || null,
monitorActive: job.data?.isActive ?? null,
active: job.active,
repeat: job.repeat ?? null,
lockedAt: job.lockedAt ?? null,
runCount: job.runCount ?? 0,
failCount: job.failCount ?? 0,
@@ -20,9 +20,8 @@ import { AppError } from "@/utils/AppError.js";
import { ILogger } from "@/utils/logger.js";
import { IBufferService } from "./bufferService.js";
import type { HardwareStatusMetrics } from "@/types/network.js";
import { MAX_RECENT_CHECKS } from "@/types/index.js";
const SERVICE_NAME = "StatusService";
const MAX_RECENT_CHECKS = 25;
const HARDWARE_ALERT_COUNTER_START = 5;
const HARDWARE_METRIC_KEYS = ["cpu", "memory", "disk", "temp"] as const;
type HardwareMetricKey = (typeof HARDWARE_METRIC_KEYS)[number];
-1
View File
@@ -14,7 +14,6 @@ export type MonitorStatus = (typeof MonitorStatuses)[number];
export const MonitorMatchMethods = ["equal", "include", "regex"] as const;
export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | "";
export const MAX_RECENT_CHECKS = 50;
export interface Monitor {
id: string;
+1 -49
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { GeoContinents } from "@/types/geoCheck.js";
import { MonitorMatchMethods, MonitorStatuses, MonitorTypes } from "@/types/monitor.js";
import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js";
export const getMonitorByIdParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -113,14 +113,6 @@ export const pauseMonitorParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
});
export const bulkPauseMonitorBodyValidation = z.object({
monitorIds: z
.array(z.string().min(1, "Monitor ID must not be empty"))
.min(1, "At least one monitor ID is required")
.max(100, "Cannot bulk update more than 100 monitors at once"),
pause: z.boolean(),
});
export const getUptimeDetailsByIdParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
});
@@ -185,43 +177,3 @@ export const getHardwareDetailsByIdParamValidation = z.object({
export const getHardwareDetailsByIdQueryValidation = z.object({
dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(),
});
// Canonical monitor shape returned by /monitors endpoints. Keep aligned with
// what the controllers actually serialize.
export const monitorResponseSchema = z
.object({
_id: z.string(),
name: z.string(),
description: z.string().optional(),
type: z.enum(MonitorTypes),
url: z.string(),
port: z.number().optional(),
isActive: z.boolean(),
interval: z.number(),
status: z.enum(MonitorStatuses),
statusWindowSize: z.number(),
statusWindowThreshold: z.number(),
ignoreTlsErrors: z.boolean(),
useAdvancedMatching: z.boolean(),
jsonPath: z.string().optional(),
expectedValue: z.string().optional(),
matchMethod: z.enum(MonitorMatchMethods).optional(),
notifications: z.array(z.string()),
secret: z.string().optional(),
cpuAlertThreshold: z.number(),
memoryAlertThreshold: z.number(),
diskAlertThreshold: z.number(),
tempAlertThreshold: z.number(),
selectedDisks: z.array(z.string()),
gameId: z.string().optional(),
grpcServiceName: z.string().optional(),
group: z.string().nullable().optional(),
geoCheckEnabled: z.boolean(),
geoCheckLocations: z.array(z.enum(GeoContinents)),
geoCheckInterval: z.number(),
teamId: z.string(),
userId: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
.passthrough();
-16
View File
@@ -50,19 +50,3 @@ export const editUserBodyValidation = z.object({
email: lowercaseEmailValidation.optional(),
profilePicture: z.string().optional(),
});
// Canonical user shape returned by auth/user endpoints. Keep aligned with what
// the controllers actually serialize (password is intentionally omitted).
export const userResponseSchema = z
.object({
_id: z.string(),
firstName: z.string(),
lastName: z.string(),
email: z.string(),
role: z.array(z.string()),
teamId: z.string().optional(),
profileImage: z.string().nullable().optional(),
createdAt: z.string(),
updatedAt: z.string(),
})
.passthrough();
@@ -23,7 +23,6 @@ const createMonitorsRepositoryMock = () =>
updateById: jest.fn(),
updateNotifications: jest.fn(),
togglePauseById: jest.fn(),
bulkTogglePause: jest.fn(),
deleteById: jest.fn(),
deleteByTeamId: jest.fn(),
}) as unknown as IMonitorsRepository;
@@ -840,141 +839,6 @@ describe("MonitorService", () => {
});
});
describe("bulkPauseMonitors", () => {
it("resumes jobs for monitors that are active after bulk toggle", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const monitors = [makeMonitor({ id: "m1", isActive: true }), makeMonitor({ id: "m2", isActive: true })];
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors);
const { service, jobQueue } = createService({ monitorsRepository });
const result = await service.bulkPauseMonitors({
teamId: TEAM_ID,
monitorIds: ["m1", "m2"],
pause: false,
});
expect(result.monitors).toHaveLength(2);
expect(result.failedCount).toBe(0);
expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, false);
expect(jobQueue.resumeJob).toHaveBeenCalledTimes(2);
expect(jobQueue.pauseJob).not.toHaveBeenCalled();
});
it("pauses jobs for monitors that are inactive after bulk toggle", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const monitors = [makeMonitor({ id: "m1", isActive: false }), makeMonitor({ id: "m2", isActive: false })];
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors);
const { service, jobQueue } = createService({ monitorsRepository });
const result = await service.bulkPauseMonitors({
teamId: TEAM_ID,
monitorIds: ["m1", "m2"],
pause: true,
});
expect(result.monitors).toHaveLength(2);
expect(result.failedCount).toBe(0);
expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, true);
expect(jobQueue.pauseJob).toHaveBeenCalledTimes(2);
expect(jobQueue.resumeJob).not.toHaveBeenCalled();
});
it("handles mixed active/inactive monitors correctly", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const activeMonitor = makeMonitor({ id: "m1", isActive: true });
const pausedMonitor = makeMonitor({ id: "m2", isActive: false });
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([activeMonitor, pausedMonitor]);
const { service, jobQueue } = createService({ monitorsRepository });
const result = await service.bulkPauseMonitors({
teamId: TEAM_ID,
monitorIds: ["m1", "m2"],
pause: true,
});
expect(result.monitors).toHaveLength(2);
expect(result.failedCount).toBe(0);
expect(jobQueue.resumeJob).toHaveBeenCalledTimes(1);
expect(jobQueue.resumeJob).toHaveBeenCalledWith(activeMonitor);
expect(jobQueue.pauseJob).toHaveBeenCalledTimes(1);
expect(jobQueue.pauseJob).toHaveBeenCalledWith(pausedMonitor);
});
it("returns empty array when no monitors were affected", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([]);
const { service, jobQueue } = createService({ monitorsRepository });
const result = await service.bulkPauseMonitors({
teamId: TEAM_ID,
monitorIds: ["m1", "m2"],
pause: true,
});
expect(result.monitors).toHaveLength(0);
expect(result.failedCount).toBe(0);
expect(jobQueue.pauseJob).not.toHaveBeenCalled();
expect(jobQueue.resumeJob).not.toHaveBeenCalled();
});
it("works with a single monitor", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const monitor = makeMonitor({ id: "m1", isActive: false });
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([monitor]);
const { service, jobQueue } = createService({ monitorsRepository });
const result = await service.bulkPauseMonitors({
teamId: TEAM_ID,
monitorIds: ["m1"],
pause: true,
});
expect(result.monitors).toHaveLength(1);
expect(result.failedCount).toBe(0);
expect(jobQueue.pauseJob).toHaveBeenCalledWith(monitor);
});
it("returns failedCount and logs errors when multiple job operations reject", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
const monitors = [
makeMonitor({ id: "m1", isActive: false }),
makeMonitor({ id: "m2", isActive: false }),
makeMonitor({ id: "m3", isActive: false }),
];
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors);
const jobQueue = createJobQueueMock();
(jobQueue.pauseJob as jest.Mock)
.mockRejectedValueOnce(new Error("Queue failure 1"))
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("Queue failure 2"));
const logger = createMockLogger();
const { service } = createService({ monitorsRepository, jobQueue, logger });
const result = await service.bulkPauseMonitors({
teamId: TEAM_ID,
monitorIds: ["m1", "m2", "m3"],
pause: true,
});
expect(result.monitors).toHaveLength(3);
expect(result.failedCount).toBe(2);
expect(logger.error).toHaveBeenCalledTimes(2);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Failed to sync job queue for monitor m1"),
method: "bulkPauseMonitors",
})
);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Failed to sync job queue for monitor m3"),
method: "bulkPauseMonitors",
})
);
});
});
describe("deleteMonitor", () => {
it("deletes monitor and all associated data", async () => {
const monitorsRepository = createMonitorsRepositoryMock();
@@ -1,7 +1,6 @@
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { StatusService } from "../../../src/service/infrastructure/statusService.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import { MAX_RECENT_CHECKS } from "../../../src/types/index.ts";
import type { Monitor, MonitorStatus, MonitorStatusResponse, Check, HardwareStatusPayload } from "../../../src/types/index.ts";
import type { IMonitorsRepository, IMonitorStatsRepository, IChecksRepository } from "../../../src/repositories/index.ts";
import type { IBufferService } from "../../../src/service/infrastructure/bufferService.ts";
@@ -281,13 +280,13 @@ describe("StatusService", () => {
false,
expect.any(Object),
5,
MAX_RECENT_CHECKS,
25,
expect.objectContaining({ status: expect.any(String) })
);
});
it("pushes check snapshot to recentChecks and trims to MAX_RECENT_CHECKS", async () => {
const existingChecks = Array.from({ length: MAX_RECENT_CHECKS }, (_, i) => ({ id: `old-${i}` }));
it("pushes check snapshot to recentChecks and trims to 25", async () => {
const existingChecks = Array.from({ length: 25 }, (_, i) => ({ id: `old-${i}` }));
const monitor = makeMonitor({ recentChecks: existingChecks as any, statusWindow: [], statusWindowSize: 5 });
const { service, monitorsRepository } = createService();
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
@@ -295,8 +294,8 @@ describe("StatusService", () => {
await service.updateMonitorStatus(makeStatusResponse(), makeCheck({ id: "new-check" }));
expect(monitor.recentChecks).toHaveLength(MAX_RECENT_CHECKS);
expect(monitor.recentChecks[MAX_RECENT_CHECKS - 1]).toEqual(expect.objectContaining({ id: "new-check" }));
expect(monitor.recentChecks).toHaveLength(25);
expect(monitor.recentChecks[24]).toEqual(expect.objectContaining({ id: "new-check" }));
});
it("marks status as down when failure threshold is met", async () => {
@@ -291,71 +291,34 @@ describe("SuperSimpleQueue", () => {
// ── pauseJob ─────────────────────────────────────────────────────────────
describe("pauseJob", () => {
it("pauses monitor and pauses geo job when geo exists", async () => {
it("pauses monitor and removes geo job if it exists", async () => {
const { queue, scheduler, logger } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.pauseJob(makeMonitor());
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1");
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo");
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo");
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" }));
});
it("refreshes scheduler data with current monitor on pause", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
const monitor = makeMonitor({ isActive: false, status: "paused" });
await queue.pauseJob(monitor);
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", { data: monitor });
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", { data: monitor });
});
it("does not pause geo job when it does not exist", async () => {
it("does not remove geo job if it does not exist", async () => {
const { queue, scheduler } = createQueue();
await queue.pauseJob(makeMonitor());
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1");
expect(scheduler.pauseJob).not.toHaveBeenCalledWith("mon-1-geo");
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
});
it("does not refresh geo data when geo job does not exist", async () => {
const { queue, scheduler } = createQueue();
await queue.pauseJob(makeMonitor());
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ data: expect.anything() }));
expect(scheduler.updateJob).not.toHaveBeenCalledWith("mon-1-geo", expect.anything());
});
it("throws when scheduler.pauseJob returns false for the main job", async () => {
it("throws when scheduler.pauseJob returns false", async () => {
const { queue, scheduler } = createQueue();
(scheduler.pauseJob as jest.Mock).mockResolvedValue(false);
await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor");
});
it("does not refresh data when main pauseJob throws", async () => {
const { queue, scheduler } = createQueue();
(scheduler.pauseJob as jest.Mock).mockResolvedValue(false);
await expect(queue.pauseJob(makeMonitor())).rejects.toThrow();
expect(scheduler.updateJob).not.toHaveBeenCalled();
});
it("logs error but does not throw when geo pauseJob returns false", async () => {
const { queue, scheduler, logger } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
(scheduler.pauseJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo");
await expect(queue.pauseJob(makeMonitor())).resolves.toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to pause geo check job for monitor mon-1" }));
});
});
// ── resumeJob ────────────────────────────────────────────────────────────
describe("resumeJob", () => {
it("resumes monitor and geo job when geo exists", async () => {
it("resumes monitor and geo job", async () => {
const { queue, scheduler, logger } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.resumeJob(makeMonitor());
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1");
@@ -363,64 +326,11 @@ describe("SuperSimpleQueue", () => {
expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" }));
});
it("refreshes scheduler data with current monitor on resume", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
const monitor = makeMonitor({ isActive: true, status: "initializing" });
await queue.resumeJob(monitor);
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", { data: monitor });
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", { data: monitor });
});
it("does not resume geo job when it does not exist", async () => {
const { queue, scheduler } = createQueue();
await queue.resumeJob(makeMonitor());
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1");
expect(scheduler.resumeJob).not.toHaveBeenCalledWith("mon-1-geo");
});
it("does not refresh geo data when geo job does not exist", async () => {
const { queue, scheduler } = createQueue();
await queue.resumeJob(makeMonitor());
expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ data: expect.anything() }));
expect(scheduler.updateJob).not.toHaveBeenCalledWith("mon-1-geo", expect.anything());
});
it("throws when scheduler.resumeJob returns false for the main job", async () => {
it("throws when scheduler.resumeJob returns false", async () => {
const { queue, scheduler } = createQueue();
(scheduler.resumeJob as jest.Mock).mockResolvedValue(false);
await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor");
});
it("does not refresh data when main resumeJob throws", async () => {
const { queue, scheduler } = createQueue();
(scheduler.resumeJob as jest.Mock).mockResolvedValue(false);
await expect(queue.resumeJob(makeMonitor())).rejects.toThrow();
expect(scheduler.updateJob).not.toHaveBeenCalled();
});
it("logs error but does not throw when geo resumeJob returns false", async () => {
const { queue, scheduler, logger } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
(scheduler.resumeJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo");
await expect(queue.resumeJob(makeMonitor())).resolves.toBeUndefined();
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to resume geo check job for monitor mon-1" }));
});
it("preserves geo job through a pause/resume cycle", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" });
await queue.pauseJob(makeMonitor());
await queue.resumeJob(makeMonitor());
expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo");
expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo");
expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo");
});
});
// ── updateJob ────────────────────────────────────────────────────────────
@@ -557,7 +467,6 @@ describe("SuperSimpleQueue", () => {
{
id: "m1",
active: true,
repeat: 60000,
lockedAt: null,
runCount: 5,
failCount: 1,
@@ -565,7 +474,7 @@ describe("SuperSimpleQueue", () => {
lastRunAt: 1000,
lastFinishedAt: 1100,
lastFailedAt: 900,
data: { url: "http://a.com", type: "http", interval: 60000, geoCheckInterval: 300000, isActive: true },
data: { url: "http://a.com", type: "http", interval: 60000 },
},
]);
const jobs = await queue.getJobs();
@@ -576,10 +485,7 @@ describe("SuperSimpleQueue", () => {
monitorUrl: "http://a.com",
monitorType: "http",
monitorInterval: 60000,
monitorGeoInterval: 300000,
monitorActive: true,
active: true,
repeat: 60000,
lockedAt: null,
runCount: 5,
failCount: 1,
@@ -607,30 +513,6 @@ describe("SuperSimpleQueue", () => {
expect(jobs[0].monitorUrl).toBeNull();
expect(jobs[0].monitorType).toBeNull();
expect(jobs[0].monitorInterval).toBeNull();
expect(jobs[0].monitorGeoInterval).toBeNull();
expect(jobs[0].monitorActive).toBeNull();
expect(jobs[0].repeat).toBeNull();
});
it("exposes monitor.isActive as monitorActive in the summary", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{ id: "m1", active: true, data: { url: "a", type: "http", interval: 60000, isActive: true } },
{ id: "m2", active: false, data: { url: "b", type: "http", interval: 60000, isActive: false } },
]);
const jobs = await queue.getJobs();
expect(jobs[0].monitorActive).toBe(true);
expect(jobs[1].monitorActive).toBe(false);
});
it("exposes geo check interval and repeat", async () => {
const { queue, scheduler } = createQueue();
(scheduler.getJobs as jest.Mock).mockResolvedValue([
{ id: "m1", active: true, repeat: 120000, data: { url: "a", type: "http", interval: 60000, geoCheckInterval: 600000 } },
]);
const jobs = await queue.getJobs();
expect(jobs[0].repeat).toBe(120000);
expect(jobs[0].monitorGeoInterval).toBe(600000);
});
});
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"noEmit": false
},
"include": ["src"]
}
+3 -3
View File
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"rootDir": "./src",
"allowJs": true,
"checkJs": false,
"paths": {
@@ -14,9 +15,8 @@
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"resolveJsonModule": true,
"noEmit": true
"resolveJsonModule": true
},
"include": ["src", "openapi", "scripts"],
"include": ["src"],
"exclude": ["tests", "dist", "node_modules"]
}