Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
940fc38056 | ||
|
|
bf369aa546 | ||
|
|
c72287cb21 | ||
|
|
ba2d23fd82 | ||
|
|
e725128b10 | ||
|
|
baf7344e2d | ||
|
|
387cb35494 | ||
|
|
6a8f97ae65 | ||
|
|
3365a8f7da | ||
|
|
2e9b87ba32 | ||
|
|
1fd56610ea | ||
|
|
22230b8a08 | ||
|
|
f648ab1b24 | ||
|
|
281fc99136 | ||
|
|
6f0cc307dc | ||
|
|
cb218a46a9 | ||
|
|
39807c2de1 | ||
|
|
59d97f0d25 | ||
|
|
657a35e948 | ||
|
|
1b0b17b589 | ||
|
|
ff34c872c8 | ||
|
|
1458cf4e4a | ||
|
|
cb285bbb66 | ||
|
|
13b13b6818 | ||
|
|
63c1a60b06 | ||
|
|
d5cd957bbd | ||
|
|
5b25666054 | ||
|
|
34feb6d3fa | ||
|
|
a57da284d7 | ||
|
|
3f9019f34c | ||
|
|
4e284c1468 | ||
|
|
6b9759e455 | ||
|
|
094def117a | ||
|
|
28baeaf218 | ||
|
|
c535ba8c1a | ||
|
|
4c04294f71 | ||
|
|
212c1fec52 | ||
|
|
05798914a8 | ||
|
|
6a4d8a3f6b | ||
|
|
e906b237a0 | ||
|
|
bacbf8404e | ||
|
|
d47cc65fed | ||
|
|
e077fe52e7 | ||
|
|
f39029a37f | ||
|
|
c8c54ce49e | ||
|
|
021eda098d | ||
|
|
8993ecadce | ||
|
|
f92e0fae3a | ||
|
|
a4b73bc4ed | ||
|
|
87b7ff7452 | ||
|
|
61de273a73 | ||
|
|
04332c47d5 | ||
|
|
f263c78a4d | ||
|
|
de67ea9ee6 | ||
|
|
c9c6c42116 | ||
|
|
79d2eb68fa | ||
|
|
bc9441bcd0 | ||
|
|
d868f6931d | ||
|
|
29dbebd228 | ||
|
|
906d306827 | ||
|
|
aaf7d7e47d | ||
|
|
c40f4e9f1c | ||
|
|
35daed605f | ||
|
|
6434afe2c9 | ||
|
|
788ca78234 | ||
|
|
3adadf0d07 | ||
|
|
55b9702fc3 | ||
|
|
7f4282ab1f | ||
|
|
fcf2557a2f | ||
|
|
bdf1e13ce8 | ||
|
|
4de2ba4b25 | ||
|
|
d5bf45e2a9 | ||
|
|
6f988cc688 | ||
|
|
8fbbb1dd4b | ||
|
|
baa4d693ae | ||
|
|
e11553c218 | ||
|
|
e71892d0bc | ||
|
|
100e569aa8 | ||
|
|
5a72a11ac2 | ||
|
|
66418bf591 | ||
|
|
726e4de6ab |
No files matched your search
@@ -159,14 +159,26 @@ When working on anything related to check scheduling, incident lifecycle, or not
|
|||||||
|
|
||||||
## Code Conventions
|
## Code Conventions
|
||||||
|
|
||||||
### Frontend conventions (mandatory for `client/src`)
|
### Coding conventions (mandatory)
|
||||||
Read `docs/frontend-conventions.md` before touching any `.tsx` file. Five rules, all enforced in code review:
|
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`):**
|
||||||
1. Prefer MUI native props over `sx` (e.g. `color={…}`, `bgcolor={…}`, `mt={…}` — not `sx={{ color, bgcolor, mt }}`).
|
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).
|
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.*`.
|
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"`.
|
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]`.
|
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
|
### Internationalization
|
||||||
All user-facing strings must use the translation function:
|
All user-facing strings must use the translation function:
|
||||||
```javascript
|
```javascript
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||
[](https://www.bestpractices.dev/projects/9901)
|
|
||||||
[](https://deepwiki.com/bluewave-labs/checkmate)
|
[](https://deepwiki.com/bluewave-labs/checkmate)
|
||||||
|
|
||||||
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
||||||
@@ -16,7 +15,10 @@
|
|||||||
|
|
||||||
[](https://www.pikapods.com/pods?run=checkmate)
|
[](https://www.pikapods.com/pods?run=checkmate)
|
||||||
|
|
||||||
<img width="1660" alt="image" src="https://github.com/user-attachments/assets/b748f36d-a271-4965-ad0a-18bf153bbee7" />
|
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
|
||||||
This repository contains both the frontend and the backend of Checkmate, an open-source, self-hosted monitoring tool for tracking server hardware, uptime, response times, and incidents in real-time with beautiful visualizations. Checkmate regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.
|
This repository contains both the frontend and the backend of Checkmate, an open-source, self-hosted monitoring tool for tracking server hardware, uptime, response times, and incidents in real-time with beautiful visualizations. Checkmate regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.
|
||||||
|
|
||||||
@@ -24,8 +26,6 @@ Checkmate also has an agent, called [Capture](https://github.com/bluewave-labs/c
|
|||||||
|
|
||||||
Checkmate has been stress-tested with 1000+ active monitors without any particular issues or performance bottlenecks.
|
Checkmate has been stress-tested with 1000+ active monitors without any particular issues or performance bottlenecks.
|
||||||
|
|
||||||
**If you would like to sponsor a feature, [see this link](https://checkmate.so/sponsored-features).**
|
|
||||||
|
|
||||||
## 📚 Table of contents
|
## 📚 Table of contents
|
||||||
|
|
||||||
- [📦 Demo](#demo)
|
- [📦 Demo](#demo)
|
||||||
@@ -38,7 +38,6 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
|
|||||||
- [🏗️ Tech stack](#tech-stack)
|
- [🏗️ Tech stack](#tech-stack)
|
||||||
- [🔗 A few links](#a-few-links)
|
- [🔗 A few links](#a-few-links)
|
||||||
- [🤝 Contributing](#contributing)
|
- [🤝 Contributing](#contributing)
|
||||||
- [💰 Our sponsors](#our-sponsors)
|
|
||||||
|
|
||||||
|
|
||||||
## Demo
|
## Demo
|
||||||
@@ -89,30 +88,17 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
|
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
|
||||||
- Website monitoring
|
- Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server
|
||||||
- Page speed monitoring
|
- Page speed monitoring
|
||||||
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
|
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
|
||||||
- Selective disk monitoring with mountpoint selection
|
- Selective disk monitoring with mountpoint selection
|
||||||
- Docker monitoring
|
|
||||||
- Ping monitoring
|
|
||||||
- SSL monitoring
|
|
||||||
- Port monitoring
|
|
||||||
- Game server monitoring (3.0)
|
|
||||||
- Incidents at a glance
|
- Incidents at a glance
|
||||||
- Status pages
|
- Status pages with 4 beautiful themes
|
||||||
- E-mail, Webhooks, Discord and Slack notifications
|
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
||||||
- Scheduled maintenance
|
- Scheduled maintenance
|
||||||
- JSON query monitoring
|
- JSON query monitoring
|
||||||
- Multi-language support for English, German, Japanese, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, Chinese (Traditional, Taiwan)
|
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
||||||
|
|
||||||
**Short term roadmap:**
|
|
||||||
|
|
||||||
- Plugins that will help Checkmate get any information from a remote service (e.g database, etc)
|
|
||||||
- Better notifications
|
|
||||||
- Network monitoring
|
|
||||||
- ..and a few more features
|
|
||||||
|
|
||||||
If you would like to sponsor an additional feature, [see this page](https://checkmate.so/sponsored-features).
|
|
||||||
|
|
||||||
## Monitor Lifecycle
|
## Monitor Lifecycle
|
||||||
|
|
||||||
|
|||||||
Generated
+43
-28
@@ -18,7 +18,7 @@
|
|||||||
"@mui/x-date-pickers": "7.29.4",
|
"@mui/x-date-pickers": "7.29.4",
|
||||||
"@reduxjs/toolkit": "2.7.0",
|
"@reduxjs/toolkit": "2.7.0",
|
||||||
"@types/maplibre-gl": "^1.13.2",
|
"@types/maplibre-gl": "^1.13.2",
|
||||||
"axios": "^1.7.4",
|
"axios": "^1.15.2",
|
||||||
"dayjs": "1.11.13",
|
"dayjs": "1.11.13",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"human-interval": "2.0.1",
|
"human-interval": "2.0.1",
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite": "6.4.1"
|
"vite": "^6.4.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -2925,14 +2925,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.5",
|
"version": "1.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.15.11",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/babel-plugin-macros": {
|
"node_modules/babel-plugin-macros": {
|
||||||
@@ -3239,9 +3239,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cosmiconfig/node_modules/yaml": {
|
"node_modules/cosmiconfig/node_modules/yaml": {
|
||||||
"version": "1.10.2",
|
"version": "1.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -4191,12 +4191,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.3",
|
"version": "3.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"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==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -5313,7 +5317,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
@@ -5470,9 +5476,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromatch/node_modules/picomatch": {
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5881,7 +5887,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -5912,7 +5920,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||||
|
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -6004,14 +6014,19 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/protocol-buffers-schema": {
|
"node_modules/protocol-buffers-schema": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||||
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
|
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "2.1.0",
|
||||||
"license": "MIT"
|
"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"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
@@ -7365,9 +7380,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
@@ -7648,9 +7663,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.2",
|
"version": "2.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+2
-2
@@ -27,7 +27,7 @@
|
|||||||
"@mui/x-date-pickers": "7.29.4",
|
"@mui/x-date-pickers": "7.29.4",
|
||||||
"@reduxjs/toolkit": "2.7.0",
|
"@reduxjs/toolkit": "2.7.0",
|
||||||
"@types/maplibre-gl": "^1.13.2",
|
"@types/maplibre-gl": "^1.13.2",
|
||||||
"axios": "^1.7.4",
|
"axios": "^1.15.2",
|
||||||
"dayjs": "1.11.13",
|
"dayjs": "1.11.13",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"human-interval": "2.0.1",
|
"human-interval": "2.0.1",
|
||||||
@@ -68,6 +68,6 @@
|
|||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite": "6.4.1"
|
"vite": "^6.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import { getResponseColor } from "@/Utils/DataUtils";
|
|||||||
import { HeatmapResponseTimeTooltip } from "@/Components/common/charts/HeatmapResponseTimeTooltip";
|
import { HeatmapResponseTimeTooltip } from "@/Components/common/charts/HeatmapResponseTimeTooltip";
|
||||||
import type { SxProps } from "@mui/material/styles";
|
import type { SxProps } from "@mui/material/styles";
|
||||||
import type { ResponsiveStyleValue } from "@mui/system";
|
import type { ResponsiveStyleValue } from "@mui/system";
|
||||||
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
interface PlaceholderCheck {
|
interface PlaceholderCheck {
|
||||||
status: "placeholder";
|
status: "placeholder";
|
||||||
responseTime: 0;
|
responseTime: 0;
|
||||||
@@ -32,11 +32,11 @@ export const HeatmapResponseTime = ({
|
|||||||
|
|
||||||
if (!checks || checks.length === 0) return null;
|
if (!checks || checks.length === 0) return null;
|
||||||
|
|
||||||
const latestChecks = checks.slice(-25);
|
const latestChecks = checks.slice(-MAX_RECENT_CHECKS);
|
||||||
|
|
||||||
let data: Array<CheckSnapshot | PlaceholderCheck>;
|
let data: Array<CheckSnapshot | PlaceholderCheck>;
|
||||||
if (latestChecks.length !== 25) {
|
if (latestChecks.length !== MAX_RECENT_CHECKS) {
|
||||||
const placeholders = Array(25 - latestChecks.length).fill({
|
const placeholders = Array(MAX_RECENT_CHECKS - latestChecks.length).fill({
|
||||||
status: "placeholder" as const,
|
status: "placeholder" as const,
|
||||||
});
|
});
|
||||||
data = [...latestChecks, ...placeholders];
|
data = [...latestChecks, ...placeholders];
|
||||||
@@ -44,13 +44,13 @@ export const HeatmapResponseTime = ({
|
|||||||
data = latestChecks;
|
data = latestChecks;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Box sx={{ width: "100%" }}>
|
<Box width={"100%"}>
|
||||||
<Box
|
<Box
|
||||||
|
width={"100%"}
|
||||||
|
display={"grid"}
|
||||||
|
gap={gap}
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(25, 1fr)",
|
|
||||||
gap: gap,
|
|
||||||
alignItems: "stretch",
|
alignItems: "stretch",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -82,50 +82,50 @@ export const HeatmapResponseTime = ({
|
|||||||
check={check}
|
check={check}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
display={"grid"}
|
||||||
sx={{
|
sx={{
|
||||||
display: "grid",
|
|
||||||
gridTemplateRows: "auto auto",
|
gridTemplateRows: "auto auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
display={"flex"}
|
||||||
|
gap={theme.spacing(2)}
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: theme.spacing(2),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
width={"100%"}
|
||||||
sx={{
|
sx={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: "100%",
|
|
||||||
aspectRatio: "10",
|
aspectRatio: "10",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
bgcolor={statusBg}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
bgcolor: statusBg,
|
|
||||||
borderRadius: theme.spacing(0.5),
|
|
||||||
...availabilityCellSx,
|
...availabilityCellSx,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Box
|
<Box
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
borderRadius: theme.spacing(0.5),
|
|
||||||
boxShadow: `inset 0 0 0 2px ${statusBorder}`,
|
boxShadow: `inset 0 0 0 2px ${statusBorder}`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
|
width={"100%"}
|
||||||
|
bgcolor={respBg}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
|
||||||
aspectRatio: "1 / 1",
|
aspectRatio: "1 / 1",
|
||||||
bgcolor: respBg,
|
|
||||||
borderRadius: theme.spacing(0.5),
|
|
||||||
...responseCellSx,
|
...responseCellSx,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { CheckSnapshot } from "@/Types/Check";
|
|||||||
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
|
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
|
|
||||||
interface HistogramResponseTimeProps {
|
interface HistogramResponseTimeProps {
|
||||||
checks: CheckSnapshot[];
|
checks: CheckSnapshot[];
|
||||||
@@ -57,8 +58,11 @@ export const HistogramResponseTime = ({
|
|||||||
if (!Array.isArray(checks) || checks.length === 0) return null;
|
if (!Array.isArray(checks) || checks.length === 0) return null;
|
||||||
|
|
||||||
const data =
|
const data =
|
||||||
checks.length !== 25
|
checks.length !== MAX_RECENT_CHECKS
|
||||||
? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })]
|
? [
|
||||||
|
...checks,
|
||||||
|
...Array(MAX_RECENT_CHECKS - checks.length).fill({ status: "placeholder" }),
|
||||||
|
]
|
||||||
: checks;
|
: checks;
|
||||||
|
|
||||||
const chartHeight = typeof height === "number" ? `${height}px` : height;
|
const chartHeight = typeof height === "number" ? `${height}px` : height;
|
||||||
@@ -86,17 +90,17 @@ export const HistogramResponseTime = ({
|
|||||||
<Stack
|
<Stack
|
||||||
direction={statsPosition === "left" ? "row-reverse" : "row"}
|
direction={statsPosition === "left" ? "row-reverse" : "row"}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
sx={{ width: "100%" }}
|
width={"100%"}
|
||||||
>
|
>
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
<Box
|
<Box
|
||||||
|
width={"100%"}
|
||||||
|
display={"grid"}
|
||||||
|
gap={gridGap}
|
||||||
|
height={chartHeight}
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(25, 1fr)",
|
|
||||||
gap: gridGap,
|
|
||||||
alignItems: "end",
|
alignItems: "end",
|
||||||
height: chartHeight,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.map((check, index) => {
|
{data.map((check, index) => {
|
||||||
@@ -108,23 +112,23 @@ export const HistogramResponseTime = ({
|
|||||||
: theme.palette.error.light;
|
: theme.palette.error.light;
|
||||||
const bar = (
|
const bar = (
|
||||||
<Box
|
<Box
|
||||||
|
width={"100%"}
|
||||||
|
height={"100%"}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
bgcolor={theme.palette.action.hover}
|
||||||
sx={{
|
sx={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
borderRadius: theme.spacing(1),
|
|
||||||
bgcolor: theme.palette.action.hover,
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
width={"100%"}
|
||||||
|
height={isPlaceholder ? 0 : heightPct}
|
||||||
|
bgcolor={barColor}
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: "100%",
|
|
||||||
height: isPlaceholder ? 0 : heightPct,
|
|
||||||
bgcolor: barColor,
|
|
||||||
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
|
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -133,8 +137,8 @@ export const HistogramResponseTime = ({
|
|||||||
|
|
||||||
return isPlaceholder ? (
|
return isPlaceholder ? (
|
||||||
<Box
|
<Box
|
||||||
|
height={"100%"}
|
||||||
key={index}
|
key={index}
|
||||||
sx={{ height: "100%" }}
|
|
||||||
>
|
>
|
||||||
{bar}
|
{bar}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ import useMediaQuery from "@mui/material/useMediaQuery";
|
|||||||
export type Header<T> = {
|
export type Header<T> = {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
content: React.ReactNode;
|
content: React.ReactNode;
|
||||||
|
mobileLabel?: React.ReactNode;
|
||||||
|
hideMobileLabel?: boolean;
|
||||||
|
align?: "left" | "center" | "right" | "justify" | "inherit";
|
||||||
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
|
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
|
||||||
render: (row: T) => React.ReactNode;
|
render: (row: T) => React.ReactNode;
|
||||||
};
|
};
|
||||||
@@ -109,6 +112,24 @@ export function DataTable<
|
|||||||
key={key}
|
key={key}
|
||||||
>
|
>
|
||||||
{headers.map((header) => {
|
{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 (
|
return (
|
||||||
<Grid2
|
<Grid2
|
||||||
container
|
container
|
||||||
@@ -123,7 +144,7 @@ export function DataTable<
|
|||||||
component="div"
|
component="div"
|
||||||
color={theme.palette.text.primary}
|
color={theme.palette.text.primary}
|
||||||
>
|
>
|
||||||
{header.content}
|
{header.mobileLabel ?? header.content}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid2>
|
</Grid2>
|
||||||
<Grid2
|
<Grid2
|
||||||
@@ -193,7 +214,7 @@ export function DataTable<
|
|||||||
{headers.map((header, idx) => {
|
{headers.map((header, idx) => {
|
||||||
return (
|
return (
|
||||||
<TableCell
|
<TableCell
|
||||||
align={idx === 0 ? "left" : "center"}
|
align={header.align ?? (idx === 0 ? "left" : "center")}
|
||||||
key={header.id}
|
key={header.id}
|
||||||
>
|
>
|
||||||
{header.content}
|
{header.content}
|
||||||
@@ -222,7 +243,7 @@ export function DataTable<
|
|||||||
{headers.map((header, index) => {
|
{headers.map((header, index) => {
|
||||||
return (
|
return (
|
||||||
<TableCell
|
<TableCell
|
||||||
align={index === 0 ? "left" : "center"}
|
align={header.align ?? (index === 0 ? "left" : "center")}
|
||||||
key={header.id}
|
key={header.id}
|
||||||
onClick={
|
onClick={
|
||||||
header.onClick ? (e) => header.onClick!(e, row) : undefined
|
header.onClick ? (e) => header.onClick!(e, row) : undefined
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -14,3 +14,4 @@ export * from "./charts/HistogramPageSpeedDetails";
|
|||||||
export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
||||||
export * from "./charts/HistogramInfrastructure";
|
export * from "./charts/HistogramInfrastructure";
|
||||||
export * from "./HeaderMonitorsSummary";
|
export * from "./HeaderMonitorsSummary";
|
||||||
|
export * from "./BulkActionsBar";
|
||||||
@@ -89,7 +89,9 @@ const uiSlice = createSlice({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setTimezone: (state, action: PayloadAction<{ timezone: string }>) => {
|
setTimezone: (state, action: PayloadAction<{ timezone: string }>) => {
|
||||||
state.timezone = action.payload.timezone;
|
if (action.payload.timezone) {
|
||||||
|
state.timezone = action.payload.timezone;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setLanguage: (state, action: PayloadAction<string>) => {
|
setLanguage: (state, action: PayloadAction<string>) => {
|
||||||
state.language = action.payload;
|
state.language = action.payload;
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
|
|||||||
systemEmailHost: data?.systemEmailHost || "",
|
systemEmailHost: data?.systemEmailHost || "",
|
||||||
systemEmailUser: data?.systemEmailUser || "",
|
systemEmailUser: data?.systemEmailUser || "",
|
||||||
systemEmailAddress: data?.systemEmailAddress || "",
|
systemEmailAddress: data?.systemEmailAddress || "",
|
||||||
|
systemEmailDisplayName: data?.systemEmailDisplayName || "",
|
||||||
systemEmailConnectionHost: data?.systemEmailConnectionHost || "localhost",
|
systemEmailConnectionHost: data?.systemEmailConnectionHost || "localhost",
|
||||||
systemEmailTLSServername: data?.systemEmailTLSServername || "",
|
systemEmailTLSServername: data?.systemEmailTLSServername || "",
|
||||||
systemEmailPort: data?.systemEmailPort,
|
systemEmailPort: data?.systemEmailPort,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
DEFAULT_STATUS_PAGE_THEME_MODE,
|
DEFAULT_STATUS_PAGE_THEME_MODE,
|
||||||
} from "@/Types/StatusPage";
|
} from "@/Types/StatusPage";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
|
import { resolveTimezone } from "@/Utils/TimeUtils";
|
||||||
|
|
||||||
interface UseStatusPageFormOptions {
|
interface UseStatusPageFormOptions {
|
||||||
data?: StatusPage | null;
|
data?: StatusPage | null;
|
||||||
@@ -30,7 +31,7 @@ export const useStatusPageForm = ({
|
|||||||
const defaults: StatusPageFormData = {
|
const defaults: StatusPageFormData = {
|
||||||
companyName: data?.companyName || "",
|
companyName: data?.companyName || "",
|
||||||
url: data?.url || generateDefaultUrl(),
|
url: data?.url || generateDefaultUrl(),
|
||||||
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: resolveTimezone(data?.timezone),
|
||||||
type: data?.type || ["uptime"],
|
type: data?.type || ["uptime"],
|
||||||
color: data?.color || "#13715B",
|
color: data?.color || "#13715B",
|
||||||
monitors: data?.monitors || [],
|
monitors: data?.monitors || [],
|
||||||
|
|||||||
@@ -136,8 +136,8 @@ const getGeneralSettingsConfig = (
|
|||||||
showIgnoreTls: false,
|
showIgnoreTls: false,
|
||||||
},
|
},
|
||||||
hardware: {
|
hardware: {
|
||||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"),
|
||||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"),
|
||||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||||
showUrl: true,
|
showUrl: true,
|
||||||
showPort: false,
|
showPort: false,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements";
|
import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements";
|
||||||
import type { Header } from "@/Components/design-elements/Table";
|
import type { Header } from "@/Components/design-elements/Table";
|
||||||
@@ -12,6 +13,24 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { usePost } from "@/Hooks/UseApi";
|
import { usePost } from "@/Hooks/UseApi";
|
||||||
|
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
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 = ({
|
export const InfraMonitorsTable = ({
|
||||||
monitors,
|
monitors,
|
||||||
@@ -26,29 +45,32 @@ export const InfraMonitorsTable = ({
|
|||||||
setPage,
|
setPage,
|
||||||
rowsPerPage,
|
rowsPerPage,
|
||||||
setRowsPerPage,
|
setRowsPerPage,
|
||||||
}: {
|
selectedRows = [],
|
||||||
monitors: Monitor[];
|
onSelectionChange,
|
||||||
refetch: Function;
|
}: InfraMonitorsTableProps) => {
|
||||||
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 { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {
|
const { post } = usePost<Record<string, never>, Monitor>();
|
||||||
post,
|
|
||||||
// loading: isPatching,
|
const selectedSet = new Set(selectedRows);
|
||||||
// error: postError,
|
const isAllSelected =
|
||||||
} = usePost<any, Monitor>();
|
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 handlePageChange = (
|
const handlePageChange = (
|
||||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||||
@@ -65,7 +87,7 @@ export const InfraMonitorsTable = ({
|
|||||||
setRowsPerPage(value);
|
setRowsPerPage(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (e: any, field: string) => {
|
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
@@ -101,13 +123,6 @@ export const InfraMonitorsTable = ({
|
|||||||
navigate(`/infrastructure/configure/${monitor.id}`);
|
navigate(`/infrastructure/configure/${monitor.id}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// id: 5,
|
|
||||||
// label: "Clone",
|
|
||||||
// action: () => {
|
|
||||||
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
label:
|
label:
|
||||||
@@ -152,8 +167,35 @@ export const InfraMonitorsTable = ({
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
const headers: Header<Monitor>[] = [
|
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",
|
id: "name",
|
||||||
|
align: "left",
|
||||||
|
mobileLabel: t("common.table.headers.name"),
|
||||||
content: (
|
content: (
|
||||||
<Typography
|
<Typography
|
||||||
component="div"
|
component="div"
|
||||||
@@ -173,18 +215,20 @@ export const InfraMonitorsTable = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
|
mobileLabel: t("common.table.headers.status"),
|
||||||
content: (
|
content: (
|
||||||
<Typography
|
<Stack
|
||||||
component="div"
|
|
||||||
display="inline-flex"
|
|
||||||
alignItems="center"
|
|
||||||
gap={theme.spacing(4)}
|
gap={theme.spacing(4)}
|
||||||
|
direction={"row"}
|
||||||
|
justifyContent={"center"}
|
||||||
|
alignItems={"center"}
|
||||||
onClick={(e) => handleSort(e, "status")}
|
onClick={(e) => handleSort(e, "status")}
|
||||||
sx={{ cursor: "pointer" }}
|
sx={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
|
<Box width={theme.spacing(8)} />
|
||||||
{t("common.table.headers.status")}
|
{t("common.table.headers.status")}
|
||||||
{renderSortIcon(sortField === "status")}
|
{renderSortIcon(sortField === "status")}
|
||||||
</Typography>
|
</Stack>
|
||||||
),
|
),
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
return <StatusLabel status={row.status} />;
|
return <StatusLabel status={row.status} />;
|
||||||
@@ -248,6 +292,11 @@ export const InfraMonitorsTable = ({
|
|||||||
onRowClick={(row) => {
|
onRowClick={(row) => {
|
||||||
navigate(`/infrastructure/${row.id}`);
|
navigate(`/infrastructure/${row.id}`);
|
||||||
}}
|
}}
|
||||||
|
getRowSx={(row) => ({
|
||||||
|
backgroundColor: isRowSelected(row.id)
|
||||||
|
? theme.palette.action.selected
|
||||||
|
: "inherit",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<Pagination
|
<Pagination
|
||||||
component="div"
|
component="div"
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import Stack from "@mui/material/Stack";
|
|||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import { MonitorBasePageWithStates } from "@/Components/design-elements";
|
import { MonitorBasePageWithStates } from "@/Components/design-elements";
|
||||||
import { HeaderCreate } from "@/Components/common";
|
import { HeaderCreate } from "@/Components/common";
|
||||||
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
|
import {
|
||||||
import { TextField, Dialog } from "@/Components/inputs";
|
ControlsFilter,
|
||||||
|
HeaderMonitorsSummary,
|
||||||
|
BulkActionsBar,
|
||||||
|
} from "@/Components/monitors";
|
||||||
|
import { TextField, Dialog, Button } from "@/Components/inputs";
|
||||||
|
import { Play, Pause } from "lucide-react";
|
||||||
|
|
||||||
import { useGet, useDelete } from "@/Hooks/UseApi";
|
import { useGet, useDelete } from "@/Hooks/UseApi";
|
||||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||||
@@ -16,6 +21,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
|
|||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import { InfraMonitorsTable } from "./Components/MonitorsTable";
|
import { InfraMonitorsTable } from "./Components/MonitorsTable";
|
||||||
import useDebounce from "@/Hooks/useDebounce";
|
import useDebounce from "@/Hooks/useDebounce";
|
||||||
|
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||||
|
|
||||||
const InfrastructureMonitors = () => {
|
const InfrastructureMonitors = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -37,6 +43,7 @@ const InfrastructureMonitors = () => {
|
|||||||
const [sortField, setSortField] = useState<string>("");
|
const [sortField, setSortField] = useState<string>("");
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||||
|
|
||||||
const isDialogOpen = Boolean(selectedMonitor);
|
const isDialogOpen = Boolean(selectedMonitor);
|
||||||
|
|
||||||
const debouncedSearch = useDebounce<string>(search, 300);
|
const debouncedSearch = useDebounce<string>(search, 300);
|
||||||
@@ -97,6 +104,15 @@ const InfrastructureMonitors = () => {
|
|||||||
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
|
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
|
||||||
const isLoading = monitorsWithChecksLoading;
|
const isLoading = monitorsWithChecksLoading;
|
||||||
|
|
||||||
|
// Bulk actions
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
setSelectedRows,
|
||||||
|
handleBulkPause,
|
||||||
|
handleBulkResume,
|
||||||
|
handleCancelSelection,
|
||||||
|
} = useBulkMonitorActions(refetch, page);
|
||||||
|
|
||||||
// Check if any filters are active
|
// Check if any filters are active
|
||||||
const hasActiveFilters = Boolean(selectedStatus || selectedState || search);
|
const hasActiveFilters = Boolean(selectedStatus || selectedState || search);
|
||||||
|
|
||||||
@@ -159,6 +175,29 @@ const InfrastructureMonitors = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</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
|
<InfraMonitorsTable
|
||||||
monitors={monitorsWithChecksData?.monitors || []}
|
monitors={monitorsWithChecksData?.monitors || []}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
@@ -180,6 +219,8 @@ const InfrastructureMonitors = () => {
|
|||||||
);
|
);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}}
|
}}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
onSelectionChange={setSelectedRows}
|
||||||
/>
|
/>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { QueueData } from "@/Types/Queue";
|
|||||||
import { Metrics } from "@/Pages/Logs/components/Metrics";
|
import { Metrics } from "@/Pages/Logs/components/Metrics";
|
||||||
import { Button } from "@/Components/inputs";
|
import { Button } from "@/Components/inputs";
|
||||||
import { EmptyState } from "@/Components/design-elements";
|
import { EmptyState } from "@/Components/design-elements";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
export const TabQueue = () => {
|
export const TabQueue = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -19,7 +20,7 @@ export const TabQueue = () => {
|
|||||||
isLoading,
|
isLoading,
|
||||||
// error,
|
// error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 5000 });
|
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 1000 });
|
||||||
|
|
||||||
const { post, loading: isFlushing } = usePost();
|
const { post, loading: isFlushing } = usePost();
|
||||||
|
|
||||||
@@ -49,32 +50,22 @@ export const TabQueue = () => {
|
|||||||
<Stack gap={theme.spacing(1)}>
|
<Stack gap={theme.spacing(1)}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="eyebrow"
|
variant="eyebrow"
|
||||||
color="text.secondary"
|
color={theme.palette.text.secondary}
|
||||||
>
|
>
|
||||||
{t("pages.logs.jobQueue")}
|
{t("pages.logs.jobQueue")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography color={theme.palette.text.secondary}>
|
||||||
sx={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="pages.logs.jobQueueExplainer"
|
i18nKey="pages.logs.jobQueueExplainer"
|
||||||
components={{
|
components={{
|
||||||
highlight: (
|
highlight: (
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
sx={{
|
bgcolor={theme.palette.rowStatus.running}
|
||||||
backgroundColor:
|
color={theme.palette.text.primary}
|
||||||
theme.palette.mode === "dark"
|
px={SPACING.SM}
|
||||||
? "rgba(19, 113, 91, 0.18)"
|
py={SPACING.XXS}
|
||||||
: "#ECF7F2",
|
borderRadius={theme.shape.borderRadius}
|
||||||
color: theme.palette.text.primary,
|
|
||||||
px: 1,
|
|
||||||
py: 0.25,
|
|
||||||
borderRadius: 0.5,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
@@ -87,16 +78,11 @@ export const TabQueue = () => {
|
|||||||
<Stack gap={theme.spacing(1)}>
|
<Stack gap={theme.spacing(1)}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="eyebrow"
|
variant="eyebrow"
|
||||||
color="text.secondary"
|
color={theme.palette.text.secondary}
|
||||||
>
|
>
|
||||||
{t("pages.logs.failedJobs")}
|
{t("pages.logs.failedJobs")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography color={theme.palette.text.secondary}>
|
||||||
sx={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("pages.logs.failedJobsExplainer")}
|
{t("pages.logs.failedJobsExplainer")}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const headers: Header<QueueJobWithId>[] = [
|
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",
|
id: "id",
|
||||||
content: t("common.table.headers.monitorId"),
|
content: t("common.table.headers.monitorId"),
|
||||||
@@ -47,7 +63,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
|||||||
id: "url",
|
id: "url",
|
||||||
content: t("common.table.headers.url"),
|
content: t("common.table.headers.url"),
|
||||||
render: (row) => (
|
render: (row) => (
|
||||||
<Typography title={row.monitorUrl ?? ""} sx={cellSx}>
|
<Typography
|
||||||
|
title={row.monitorUrl ?? ""}
|
||||||
|
sx={cellSx}
|
||||||
|
>
|
||||||
{row.monitorUrl}
|
{row.monitorUrl}
|
||||||
</Typography>
|
</Typography>
|
||||||
),
|
),
|
||||||
@@ -60,9 +79,15 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
|||||||
{
|
{
|
||||||
id: "interval",
|
id: "interval",
|
||||||
content: t("common.table.headers.interval"),
|
content: t("common.table.headers.interval"),
|
||||||
render: (row) => (
|
render: (row) => {
|
||||||
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
|
let interval;
|
||||||
),
|
if (row.monitorId.toString().includes("geo")) {
|
||||||
|
interval = row.monitorGeoInterval ?? 0;
|
||||||
|
} else {
|
||||||
|
interval = row.repeat ?? 0;
|
||||||
|
}
|
||||||
|
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "lastRun",
|
id: "lastRun",
|
||||||
@@ -70,7 +95,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
|||||||
render: (row) => {
|
render: (row) => {
|
||||||
const v = formatTimestamp(row.lastRunAt) ?? "-";
|
const v = formatTimestamp(row.lastRunAt) ?? "-";
|
||||||
return (
|
return (
|
||||||
<Typography title={v} sx={cellSx}>
|
<Typography
|
||||||
|
title={v}
|
||||||
|
sx={cellSx}
|
||||||
|
>
|
||||||
{v}
|
{v}
|
||||||
</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
@@ -90,7 +118,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
|||||||
render: (row) => {
|
render: (row) => {
|
||||||
const v = formatTimestamp(row.lockedAt) ?? "-";
|
const v = formatTimestamp(row.lockedAt) ?? "-";
|
||||||
return (
|
return (
|
||||||
<Typography title={v} sx={cellSx}>
|
<Typography
|
||||||
|
title={v}
|
||||||
|
sx={cellSx}
|
||||||
|
>
|
||||||
{v}
|
{v}
|
||||||
</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
@@ -98,16 +129,16 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const isDark = theme.palette.mode === "dark";
|
|
||||||
const runningBg = isDark ? "rgba(19, 113, 91, 0.18)" : "#ECF7F2";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
headers={headers}
|
headers={headers}
|
||||||
data={jobsWithId}
|
data={jobsWithId}
|
||||||
getRowSx={(row) => ({
|
getRowSx={(row) => ({
|
||||||
...(row.lockedAt && {
|
...(row.lockedAt && {
|
||||||
"& td": { backgroundColor: runningBg },
|
"& td": { backgroundColor: theme.palette.rowStatus.running },
|
||||||
|
}),
|
||||||
|
...(row.monitorActive === false && {
|
||||||
|
"& td": { backgroundColor: theme.palette.rowStatus.paused },
|
||||||
}),
|
}),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Typography from "@mui/material/Typography";
|
|||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common";
|
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common";
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
import type { CheckSnapshot } from "@/Types/Check";
|
||||||
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
|
|
||||||
interface DummyChartProps {
|
interface DummyChartProps {
|
||||||
chartType: string;
|
chartType: string;
|
||||||
@@ -10,7 +11,7 @@ interface DummyChartProps {
|
|||||||
|
|
||||||
const generateDummyChecks = (): CheckSnapshot[] => {
|
const generateDummyChecks = (): CheckSnapshot[] => {
|
||||||
const checks: CheckSnapshot[] = [];
|
const checks: CheckSnapshot[] = [];
|
||||||
for (let i = 0; i < 25; i++) {
|
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
|
||||||
const isUp = Math.random() > 0.1;
|
const isUp = Math.random() > 0.1;
|
||||||
const responseTime = Math.floor(Math.random() * 80) + 20;
|
const responseTime = Math.floor(Math.random() * 80) + 20;
|
||||||
checks.push({
|
checks.push({
|
||||||
|
|||||||
@@ -128,8 +128,8 @@ export const SettingsPage = () => {
|
|||||||
timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null;
|
timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null;
|
||||||
|
|
||||||
const handleTimezoneChange = (newValue: Timezone | null) => {
|
const handleTimezoneChange = (newValue: Timezone | null) => {
|
||||||
const newId = newValue?.id ?? "";
|
if (!newValue?.id) return;
|
||||||
dispatch(setTimezone({ timezone: newId }));
|
dispatch(setTimezone({ timezone: newValue.id }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModeChange = (e: SelectChangeEvent<ThemeMode>) => {
|
const handleModeChange = (e: SelectChangeEvent<ThemeMode>) => {
|
||||||
@@ -182,6 +182,9 @@ export const SettingsPage = () => {
|
|||||||
systemEmailRequireTLS: formValues.systemEmailRequireTLS,
|
systemEmailRequireTLS: formValues.systemEmailRequireTLS,
|
||||||
systemEmailRejectUnauthorized: formValues.systemEmailRejectUnauthorized,
|
systemEmailRejectUnauthorized: formValues.systemEmailRejectUnauthorized,
|
||||||
...(formValues.systemEmailUser && { systemEmailUser: formValues.systemEmailUser }),
|
...(formValues.systemEmailUser && { systemEmailUser: formValues.systemEmailUser }),
|
||||||
|
...(formValues.systemEmailDisplayName && {
|
||||||
|
systemEmailDisplayName: formValues.systemEmailDisplayName,
|
||||||
|
}),
|
||||||
...(formValues.systemEmailTLSServername && {
|
...(formValues.systemEmailTLSServername && {
|
||||||
systemEmailTLSServername: formValues.systemEmailTLSServername,
|
systemEmailTLSServername: formValues.systemEmailTLSServername,
|
||||||
}),
|
}),
|
||||||
@@ -577,44 +580,65 @@ export const SettingsPage = () => {
|
|||||||
href="https://nodemailer.com/smtp/"
|
href="https://nodemailer.com/smtp/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
/>
|
/>
|
||||||
<Box
|
{(() => {
|
||||||
component="pre"
|
const address = form.watch("systemEmailAddress") || "";
|
||||||
sx={{
|
const displayName = form.watch("systemEmailDisplayName")?.trim();
|
||||||
fontFamily: theme.typography.fontFamilyMonospace,
|
return (
|
||||||
p: 2,
|
<>
|
||||||
borderRadius: 1,
|
<Box
|
||||||
overflow: "auto",
|
component="pre"
|
||||||
backgroundColor: theme.palette.action.hover,
|
p={2}
|
||||||
}}
|
borderRadius={theme.shape.borderRadius}
|
||||||
>
|
bgcolor={theme.palette.action.hover}
|
||||||
<code>
|
sx={{
|
||||||
{JSON.stringify(
|
fontFamily: theme.typography.fontFamilyMonospace,
|
||||||
{
|
overflow: "auto",
|
||||||
host: form.watch("systemEmailHost") || "",
|
}}
|
||||||
port: form.watch("systemEmailPort") || "",
|
>
|
||||||
secure: form.watch("systemEmailSecure") ?? false,
|
<code>
|
||||||
auth: {
|
{JSON.stringify(
|
||||||
user:
|
{
|
||||||
form.watch("systemEmailUser") ||
|
host: form.watch("systemEmailHost") || "",
|
||||||
form.watch("systemEmailAddress") ||
|
port: form.watch("systemEmailPort") || "",
|
||||||
"",
|
secure: form.watch("systemEmailSecure") ?? false,
|
||||||
pass: "<your_password>",
|
auth: {
|
||||||
},
|
user: form.watch("systemEmailUser") || address,
|
||||||
name: form.watch("systemEmailConnectionHost") || "localhost",
|
pass: "<your_password>",
|
||||||
pool: form.watch("systemEmailPool") ?? false,
|
},
|
||||||
tls: {
|
name: form.watch("systemEmailConnectionHost") || "localhost",
|
||||||
rejectUnauthorized:
|
pool: form.watch("systemEmailPool") ?? false,
|
||||||
form.watch("systemEmailRejectUnauthorized") ?? true,
|
tls: {
|
||||||
ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
|
rejectUnauthorized:
|
||||||
requireTLS: form.watch("systemEmailRequireTLS") ?? false,
|
form.watch("systemEmailRejectUnauthorized") ?? true,
|
||||||
servername: form.watch("systemEmailTLSServername") || "",
|
ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
|
||||||
},
|
requireTLS: form.watch("systemEmailRequireTLS") ?? false,
|
||||||
},
|
servername: form.watch("systemEmailTLSServername") || "",
|
||||||
null,
|
},
|
||||||
2
|
},
|
||||||
)}
|
null,
|
||||||
</code>
|
2
|
||||||
</Box>
|
)}
|
||||||
|
</code>
|
||||||
|
</Box>
|
||||||
|
{address && (
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
p={2}
|
||||||
|
borderRadius={theme.shape.borderRadius}
|
||||||
|
bgcolor={theme.palette.action.hover}
|
||||||
|
sx={{
|
||||||
|
fontFamily: theme.typography.fontFamilyMonospace,
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code>
|
||||||
|
{`From: ${displayName ? `"${displayName}" <${address}>` : address}`}
|
||||||
|
</code>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Stack>
|
</Stack>
|
||||||
}
|
}
|
||||||
rightContent={
|
rightContent={
|
||||||
@@ -680,6 +704,24 @@ export const SettingsPage = () => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Email Display Name (Optional) */}
|
||||||
|
<Controller
|
||||||
|
name="systemEmailDisplayName"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
fieldLabel={t("pages.settings.form.email.option.displayName.label")}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.settings.form.email.option.displayName.placeholder"
|
||||||
|
)}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Email User (Optional) */}
|
{/* Email User (Optional) */}
|
||||||
<Controller
|
<Controller
|
||||||
name="systemEmailUser"
|
name="systemEmailUser"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"
|
|||||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||||
import timezones from "@/Utils/timezones.json";
|
import timezones from "@/Utils/timezones.json";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { mutate } from "swr";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
|
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
|
||||||
import { ThemePicker } from "./Components/ThemePicker";
|
import { ThemePicker } from "./Components/ThemePicker";
|
||||||
@@ -49,6 +50,9 @@ interface TimezoneOption {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildStatusPageKey = (slug: string | null | undefined) =>
|
||||||
|
slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
|
||||||
|
|
||||||
const CreateStatusPage = () => {
|
const CreateStatusPage = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -59,9 +63,7 @@ const CreateStatusPage = () => {
|
|||||||
|
|
||||||
// Fetch existing status page data when configuring
|
// Fetch existing status page data when configuring
|
||||||
const { data: statusPageData, isLoading: isLoadingStatusPage } =
|
const { data: statusPageData, isLoading: isLoadingStatusPage } =
|
||||||
useGet<StatusPageResponse>(
|
useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url));
|
||||||
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
|
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
|
||||||
const monitors = monitorsResponse ?? [];
|
const monitors = monitorsResponse ?? [];
|
||||||
@@ -181,6 +183,10 @@ const CreateStatusPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
|
await mutate(buildStatusPageKey(data.url));
|
||||||
|
if (!isCreate && url !== data.url) {
|
||||||
|
await mutate(buildStatusPageKey(url));
|
||||||
|
}
|
||||||
navigate(`/status/${data.url}`);
|
navigate(`/status/${data.url}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,35 +8,56 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||||
import { useLocation, useParams } from "react-router-dom";
|
import { useLocation, useParams } from "react-router-dom";
|
||||||
import { useGet } from "@/Hooks/UseApi";
|
import { useGet } from "@/Hooks/UseApi";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
|
||||||
import { resolveStatusPageTheme } from "@/Types/StatusPage";
|
import { resolveStatusPageTheme } from "@/Types/StatusPage";
|
||||||
import {
|
import {
|
||||||
PUBLIC_STATUS_PAGE_PREFIX,
|
PUBLIC_STATUS_PAGE_PREFIX,
|
||||||
type StatusPage,
|
|
||||||
type StatusPageResponse,
|
type StatusPageResponse,
|
||||||
type StatusPageTheme,
|
type StatusPageTheme,
|
||||||
} from "@/Types/StatusPage";
|
} from "@/Types/StatusPage";
|
||||||
import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls";
|
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
|
||||||
import { StatusPageThemeProvider } from "./themes/StatusPageThemeProvider";
|
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||||
import { RefinedStatusPage } from "./themes/refined/RefinedStatusPage";
|
import {
|
||||||
import { ModernStatusPage } from "./themes/modern/ModernStatusPage";
|
BaseStatusPage,
|
||||||
import { BoldStatusPage } from "./themes/bold/BoldStatusPage";
|
type ThemeConfig,
|
||||||
import { EditorialStatusPage } from "./themes/editorial/EditorialStatusPage";
|
} from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||||
import { BrowserFrame } from "./themes/BrowserFrame";
|
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";
|
||||||
|
|
||||||
type ThemedRendererProps = {
|
const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
|
||||||
statusPage: StatusPage;
|
refined: {
|
||||||
monitors: (Monitor & { checks?: Monitor["recentChecks"] })[];
|
createStyles: refinedStyles,
|
||||||
};
|
HeaderSlot: RefinedHeader,
|
||||||
|
HeroSlot: RefinedHero,
|
||||||
const THEMED_RENDERERS: Record<
|
},
|
||||||
StatusPageTheme,
|
modern: {
|
||||||
React.ComponentType<ThemedRendererProps>
|
createStyles: modernStyles,
|
||||||
> = {
|
HeaderSlot: ModernHeader,
|
||||||
refined: RefinedStatusPage,
|
HeroSlot: ModernHero,
|
||||||
modern: ModernStatusPage,
|
overallStatusOptions: { iconSize: 20 },
|
||||||
bold: BoldStatusPage,
|
},
|
||||||
editorial: EditorialStatusPage,
|
bold: {
|
||||||
|
createStyles: boldStyles,
|
||||||
|
HeaderSlot: BoldHeader,
|
||||||
|
HeroSlot: BoldHero,
|
||||||
|
overallStatusOptions: { iconSize: 18 },
|
||||||
|
},
|
||||||
|
editorial: {
|
||||||
|
createStyles: editorialStyles,
|
||||||
|
HeaderSlot: EditorialHeader,
|
||||||
|
HeroSlot: EditorialHero,
|
||||||
|
overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatusPageView = () => {
|
const StatusPageView = () => {
|
||||||
@@ -90,11 +111,12 @@ const StatusPageView = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemedRenderer = THEMED_RENDERERS[resolveStatusPageTheme(statusPage.theme)];
|
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
|
||||||
const themedRenderer = (
|
const themedRenderer = (
|
||||||
<ThemedRenderer
|
<BaseStatusPage
|
||||||
statusPage={statusPage}
|
statusPage={statusPage}
|
||||||
monitors={monitors}
|
monitors={monitors}
|
||||||
|
config={themeConfig}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -104,6 +126,7 @@ const StatusPageView = () => {
|
|||||||
<StatusPageThemeProvider
|
<StatusPageThemeProvider
|
||||||
theme={statusPage.theme}
|
theme={statusPage.theme}
|
||||||
themeMode={statusPage.themeMode}
|
themeMode={statusPage.themeMode}
|
||||||
|
timezone={statusPage.timezone}
|
||||||
paintBody
|
paintBody
|
||||||
>
|
>
|
||||||
{themedRenderer}
|
{themedRenderer}
|
||||||
@@ -127,6 +150,7 @@ const StatusPageView = () => {
|
|||||||
<StatusPageThemeProvider
|
<StatusPageThemeProvider
|
||||||
theme={statusPage.theme}
|
theme={statusPage.theme}
|
||||||
themeMode={statusPage.themeMode}
|
themeMode={statusPage.themeMode}
|
||||||
|
timezone={statusPage.timezone}
|
||||||
transparent
|
transparent
|
||||||
>
|
>
|
||||||
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
|
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type StatusPageTheme,
|
type StatusPageTheme,
|
||||||
type StatusPageThemeMode,
|
type StatusPageThemeMode,
|
||||||
} from "@/Types/StatusPage";
|
} from "@/Types/StatusPage";
|
||||||
|
import { resolveTimezone } from "@/Utils/TimeUtils";
|
||||||
import { themeTokens, type StatusPageThemeTokens } from "./tokens";
|
import { themeTokens, type StatusPageThemeTokens } from "./tokens";
|
||||||
|
|
||||||
type ResolvedMode = "light" | "dark";
|
type ResolvedMode = "light" | "dark";
|
||||||
@@ -21,6 +22,7 @@ interface StatusPageThemeContextValue {
|
|||||||
theme: StatusPageTheme;
|
theme: StatusPageTheme;
|
||||||
mode: ResolvedMode;
|
mode: ResolvedMode;
|
||||||
tokens: StatusPageThemeTokens;
|
tokens: StatusPageThemeTokens;
|
||||||
|
timezone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusPageThemeContext = createContext<StatusPageThemeContextValue | null>(null);
|
const StatusPageThemeContext = createContext<StatusPageThemeContextValue | null>(null);
|
||||||
@@ -33,6 +35,7 @@ const resolveSystemMode = (): ResolvedMode => {
|
|||||||
interface Props {
|
interface Props {
|
||||||
theme?: StatusPageTheme;
|
theme?: StatusPageTheme;
|
||||||
themeMode?: StatusPageThemeMode;
|
themeMode?: StatusPageThemeMode;
|
||||||
|
timezone?: string;
|
||||||
/**
|
/**
|
||||||
* When true, paint the document body with the theme background so it
|
* When true, paint the document body with the theme background so it
|
||||||
* covers beyond the provider's wrapper (useful on the public route
|
* covers beyond the provider's wrapper (useful on the public route
|
||||||
@@ -54,12 +57,14 @@ interface Props {
|
|||||||
export const StatusPageThemeProvider = ({
|
export const StatusPageThemeProvider = ({
|
||||||
theme,
|
theme,
|
||||||
themeMode,
|
themeMode,
|
||||||
|
timezone,
|
||||||
paintBody = false,
|
paintBody = false,
|
||||||
transparent = false,
|
transparent = false,
|
||||||
children,
|
children,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const resolvedTheme = resolveStatusPageTheme(theme);
|
const resolvedTheme = resolveStatusPageTheme(theme);
|
||||||
const resolvedThemeMode = resolveStatusPageThemeMode(themeMode);
|
const resolvedThemeMode = resolveStatusPageThemeMode(themeMode);
|
||||||
|
const resolvedTimezone = resolveTimezone(timezone);
|
||||||
|
|
||||||
const [systemMode, setSystemMode] = useState<ResolvedMode>(resolveSystemMode);
|
const [systemMode, setSystemMode] = useState<ResolvedMode>(resolveSystemMode);
|
||||||
|
|
||||||
@@ -107,8 +112,13 @@ export const StatusPageThemeProvider = ({
|
|||||||
const tokens = themeTokens[resolvedTheme][resolvedMode];
|
const tokens = themeTokens[resolvedTheme][resolvedMode];
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ theme: resolvedTheme, mode: resolvedMode, tokens }),
|
() => ({
|
||||||
[resolvedTheme, resolvedMode, tokens]
|
theme: resolvedTheme,
|
||||||
|
mode: resolvedMode,
|
||||||
|
tokens,
|
||||||
|
timezone: resolvedTimezone,
|
||||||
|
}),
|
||||||
|
[resolvedTheme, resolvedMode, tokens, resolvedTimezone]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -136,6 +146,7 @@ export const useStatusPageTheme = (): StatusPageThemeContextValue => {
|
|||||||
theme: DEFAULT_STATUS_PAGE_THEME,
|
theme: DEFAULT_STATUS_PAGE_THEME,
|
||||||
mode: resolveSystemMode(),
|
mode: resolveSystemMode(),
|
||||||
tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()],
|
tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()],
|
||||||
|
timezone: resolveTimezone(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
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,6 +2,7 @@ import type { SxProps, Theme } from "@mui/material/styles";
|
|||||||
import type { StatusPageThemeTokens } from "../tokens";
|
import type { StatusPageThemeTokens } from "../tokens";
|
||||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||||
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
|
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 BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||||
export type BoldBarKind = "up" | "down" | "empty";
|
export type BoldBarKind = "up" | "down" | "empty";
|
||||||
@@ -297,7 +298,7 @@ export const boldStyles = (
|
|||||||
heatmap: {
|
heatmap: {
|
||||||
padding: "0 28px 22px",
|
padding: "0 28px 22px",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(25, 1fr)",
|
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||||
gap: "3px",
|
gap: "3px",
|
||||||
height: 48,
|
height: 48,
|
||||||
},
|
},
|
||||||
@@ -313,7 +314,7 @@ export const boldStyles = (
|
|||||||
histogram: {
|
histogram: {
|
||||||
padding: "0 28px",
|
padding: "0 28px",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(25, 1fr)",
|
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||||
gap: "3px",
|
gap: "3px",
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
height: 48,
|
height: 48,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
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,6 +6,7 @@ import {
|
|||||||
MONO_STACK,
|
MONO_STACK,
|
||||||
SERIF_STACK,
|
SERIF_STACK,
|
||||||
} from "../shared/fontStacks";
|
} from "../shared/fontStacks";
|
||||||
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
|
|
||||||
export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||||
export type EditorialBarKind = "up" | "down" | "empty";
|
export type EditorialBarKind = "up" | "down" | "empty";
|
||||||
@@ -50,7 +51,10 @@ export interface EditorialStyles {
|
|||||||
footer: SxProps<Theme>;
|
footer: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles => {
|
export const editorialStyles = (
|
||||||
|
tokens: StatusPageThemeTokens,
|
||||||
|
_isDark: boolean
|
||||||
|
): EditorialStyles => {
|
||||||
const heatCellBg: Record<EditorialHeatCell, string> = {
|
const heatCellBg: Record<EditorialHeatCell, string> = {
|
||||||
fast: tokens.up,
|
fast: tokens.up,
|
||||||
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
|
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
|
||||||
@@ -245,7 +249,7 @@ export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles
|
|||||||
heatmap: {
|
heatmap: {
|
||||||
mt: "16px",
|
mt: "16px",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(25, 1fr)",
|
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||||
gap: "2px",
|
gap: "2px",
|
||||||
height: 40,
|
height: 40,
|
||||||
},
|
},
|
||||||
@@ -257,7 +261,7 @@ export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles
|
|||||||
histogram: {
|
histogram: {
|
||||||
mt: "16px",
|
mt: "16px",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(25, 1fr)",
|
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||||
gap: "2px",
|
gap: "2px",
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { keyframes } from "@mui/system";
|
|||||||
import type { StatusPageThemeTokens } from "../tokens";
|
import type { StatusPageThemeTokens } from "../tokens";
|
||||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||||
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
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 ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||||
export type ModernBarKind = "up" | "down" | "empty";
|
export type ModernBarKind = "up" | "down" | "empty";
|
||||||
@@ -320,7 +321,7 @@ export const modernStyles = (
|
|||||||
heatmap: {
|
heatmap: {
|
||||||
padding: "0 24px 20px",
|
padding: "0 24px 20px",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(25, 1fr)",
|
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||||
gap: "3px",
|
gap: "3px",
|
||||||
height: 46,
|
height: 46,
|
||||||
},
|
},
|
||||||
@@ -335,7 +336,7 @@ export const modernStyles = (
|
|||||||
histogram: {
|
histogram: {
|
||||||
padding: "0 24px",
|
padding: "0 24px",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(25, 1fr)",
|
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||||
gap: "3px",
|
gap: "3px",
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
height: 46,
|
height: 46,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
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,6 +6,7 @@ import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
|||||||
export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||||
export type RefinedBarKind = "up" | "down" | "empty";
|
export type RefinedBarKind = "up" | "down" | "empty";
|
||||||
export type RefinedGaugeFill = "ok" | "warm" | "hot";
|
export type RefinedGaugeFill = "ok" | "warm" | "hot";
|
||||||
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
|
|
||||||
export interface RefinedStyles {
|
export interface RefinedStyles {
|
||||||
page: SxProps<Theme>;
|
page: SxProps<Theme>;
|
||||||
@@ -62,7 +63,10 @@ const pillBase = {
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => {
|
export const refinedStyles = (
|
||||||
|
tokens: StatusPageThemeTokens,
|
||||||
|
_isDark: boolean
|
||||||
|
): RefinedStyles => {
|
||||||
const heatCellBg: Record<RefinedHeatCell, string> = {
|
const heatCellBg: Record<RefinedHeatCell, string> = {
|
||||||
fast: tokens.up,
|
fast: tokens.up,
|
||||||
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
|
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
|
||||||
@@ -259,7 +263,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => {
|
|||||||
heatmap: {
|
heatmap: {
|
||||||
padding: "0 20px 16px",
|
padding: "0 20px 16px",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(25, 1fr)",
|
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||||
gap: "3px",
|
gap: "3px",
|
||||||
height: 42,
|
height: 42,
|
||||||
},
|
},
|
||||||
@@ -274,7 +278,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => {
|
|||||||
histogram: {
|
histogram: {
|
||||||
padding: "0 20px",
|
padding: "0 20px",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(25, 1fr)",
|
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||||
gap: "3px",
|
gap: "3px",
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
height: 42,
|
height: 42,
|
||||||
|
|||||||
+88
-43
@@ -2,80 +2,125 @@ import { useMemo, useState } from "react";
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import type { StatusPage } from "@/Types/StatusPage";
|
import type { StatusPage } from "@/Types/StatusPage";
|
||||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||||
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
|
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
|
||||||
import { ThemedHistogram } from "../shared/ThemedHistogram";
|
|
||||||
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
|
|
||||||
import {
|
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,
|
monitorBadgeTone,
|
||||||
monoFirstChar,
|
|
||||||
resolveOverallStatus,
|
resolveOverallStatus,
|
||||||
statusBadgeKey,
|
statusBadgeKey,
|
||||||
} from "../shared/overallStatus";
|
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
|
||||||
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||||
import { modernStyles } from "./styles";
|
|
||||||
|
|
||||||
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
|
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 {
|
interface Props {
|
||||||
statusPage: StatusPage;
|
statusPage: StatusPage;
|
||||||
monitors: StatusPageMonitor[];
|
monitors: StatusPageMonitor[];
|
||||||
|
config: ThemeConfig<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ModernStatusPage = ({ statusPage, monitors }: Props) => {
|
export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { tokens, mode } = useStatusPageTheme();
|
const { tokens, mode } = useStatusPageTheme();
|
||||||
const styles = useMemo(() => modernStyles(tokens, mode === "dark"), [tokens, mode]);
|
const styles = useMemo(
|
||||||
|
() => config.createStyles(tokens, mode === "dark"),
|
||||||
|
[config, tokens, mode]
|
||||||
|
);
|
||||||
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
|
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
|
||||||
|
|
||||||
const overall = resolveOverallStatus(monitors, t, { iconSize: 20 });
|
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
|
||||||
const logoSrc = statusPage.logo?.data
|
const logoSrc = statusPage.logo?.data
|
||||||
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
|
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const { HeaderSlot, HeroSlot } = config;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={styles.page}>
|
<Box sx={styles.page}>
|
||||||
<Box
|
<Box
|
||||||
component="header"
|
component="header"
|
||||||
sx={styles.top}
|
sx={styles.top}
|
||||||
>
|
>
|
||||||
<Box sx={styles.brand}>
|
<HeaderSlot
|
||||||
{logoSrc ? (
|
statusPage={statusPage}
|
||||||
<Box
|
logoSrc={logoSrc}
|
||||||
component="img"
|
overall={overall}
|
||||||
src={logoSrc}
|
monitorCount={monitors.length}
|
||||||
alt={statusPage.companyName}
|
styles={styles}
|
||||||
sx={styles.logoImg}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box sx={styles.logoGrad}>{monoFirstChar(statusPage.companyName)}</Box>
|
|
||||||
)}
|
|
||||||
{statusPage.companyName}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={styles.hero}>
|
<HeroSlot
|
||||||
<Box sx={styles.pulse(overall.tone)} />
|
statusPage={statusPage}
|
||||||
<Box sx={styles.statusCopy}>
|
logoSrc={logoSrc}
|
||||||
<Box
|
overall={overall}
|
||||||
component="h1"
|
monitorCount={monitors.length}
|
||||||
sx={styles.heroTitle}
|
styles={styles}
|
||||||
>
|
/>
|
||||||
{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 && (
|
{statusPage.showCharts && (
|
||||||
<Box sx={styles.chartSwitchWrap}>
|
<Box sx={styles.chartSwitchWrap}>
|
||||||
@@ -4,12 +4,12 @@ import Stack from "@mui/material/Stack";
|
|||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import type { RootState } from "@/store";
|
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
import type { CheckSnapshot } from "@/Types/Check";
|
||||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
|
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||||
|
|
||||||
const CELLS = 25;
|
const CELLS = MAX_RECENT_CHECKS;
|
||||||
|
|
||||||
export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty";
|
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) => {
|
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
const { timezone } = useStatusPageTheme();
|
||||||
|
|
||||||
const source = checks.slice(-CELLS);
|
const source = checks.slice(-CELLS);
|
||||||
const padded: (CheckSnapshot | null)[] = [
|
const padded: (CheckSnapshot | null)[] = [
|
||||||
@@ -67,11 +67,7 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
|
|||||||
variant="caption"
|
variant="caption"
|
||||||
sx={{ opacity: 0.8 }}
|
sx={{ opacity: 0.8 }}
|
||||||
>
|
>
|
||||||
{formatDateWithTz(
|
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
|
||||||
check.createdAt,
|
|
||||||
"ddd, MMMM D, YYYY, HH:mm A",
|
|
||||||
uiTimezone
|
|
||||||
)}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import Stack from "@mui/material/Stack";
|
|||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import type { SxProps, Theme } from "@mui/material/styles";
|
import type { SxProps, Theme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import type { RootState } from "@/store";
|
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
import type { CheckSnapshot } from "@/Types/Check";
|
||||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
const CELLS = 25;
|
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||||
|
const CELLS = MAX_RECENT_CHECKS;
|
||||||
const MIN_HEIGHT_PCT = 6;
|
const MIN_HEIGHT_PCT = 6;
|
||||||
|
|
||||||
export type BarKind = "up" | "down" | "empty";
|
export type BarKind = "up" | "down" | "empty";
|
||||||
@@ -34,7 +33,7 @@ export const ThemedHistogram = ({
|
|||||||
statsGap = 1,
|
statsGap = 1,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
const { timezone } = useStatusPageTheme();
|
||||||
|
|
||||||
const { padded, max, avg, peak } = useMemo(() => {
|
const { padded, max, avg, peak } = useMemo(() => {
|
||||||
const source = checks.slice(-CELLS);
|
const source = checks.slice(-CELLS);
|
||||||
@@ -83,7 +82,7 @@ export const ThemedHistogram = ({
|
|||||||
{formatDateWithTz(
|
{formatDateWithTz(
|
||||||
check.createdAt,
|
check.createdAt,
|
||||||
"ddd, MMMM D, YYYY, HH:mm A",
|
"ddd, MMMM D, YYYY, HH:mm A",
|
||||||
uiTimezone
|
timezone
|
||||||
)}
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ import { useSelector } from "react-redux";
|
|||||||
import type { Monitor } from "@/Types/Monitor";
|
import type { Monitor } from "@/Types/Monitor";
|
||||||
import type { ActionMenuItem } from "@/Components/actions-menu";
|
import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||||
import type { RootState } from "@/Types/state";
|
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 = ({
|
export const MonitorTable = ({
|
||||||
monitors,
|
monitors,
|
||||||
@@ -34,31 +52,34 @@ export const MonitorTable = ({
|
|||||||
setPage,
|
setPage,
|
||||||
rowsPerPage,
|
rowsPerPage,
|
||||||
setRowsPerPage,
|
setRowsPerPage,
|
||||||
}: {
|
selectedRows = [],
|
||||||
monitors: Monitor[];
|
onSelectionChange,
|
||||||
refetch: Function;
|
}: MonitorTableProps) => {
|
||||||
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 { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram");
|
const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram");
|
||||||
const {
|
const { post } = usePost<Record<string, never>, Monitor>();
|
||||||
post,
|
|
||||||
// loading: isPosting,
|
|
||||||
// error: postError,
|
|
||||||
} = usePost<any, Monitor>();
|
|
||||||
|
|
||||||
const handleSort = (e: any, field: string) => {
|
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) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
@@ -110,13 +131,6 @@ export const MonitorTable = ({
|
|||||||
navigate(`/uptime/configure/${monitor.id}`);
|
navigate(`/uptime/configure/${monitor.id}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// id: 5,
|
|
||||||
// label: "Clone",
|
|
||||||
// action: () => {
|
|
||||||
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
label:
|
label:
|
||||||
@@ -160,8 +174,35 @@ export const MonitorTable = ({
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
const headers: Header<Monitor>[] = [
|
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",
|
id: "name",
|
||||||
|
align: "left",
|
||||||
|
mobileLabel: t("common.table.headers.name"),
|
||||||
content: (
|
content: (
|
||||||
<Stack
|
<Stack
|
||||||
gap={theme.spacing(4)}
|
gap={theme.spacing(4)}
|
||||||
@@ -181,6 +222,7 @@ export const MonitorTable = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
|
mobileLabel: t("common.table.headers.status"),
|
||||||
content: (
|
content: (
|
||||||
<Stack
|
<Stack
|
||||||
gap={theme.spacing(4)}
|
gap={theme.spacing(4)}
|
||||||
@@ -212,6 +254,7 @@ export const MonitorTable = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "type",
|
id: "type",
|
||||||
|
mobileLabel: t("common.table.headers.type"),
|
||||||
content: (
|
content: (
|
||||||
<Stack
|
<Stack
|
||||||
gap={theme.spacing(4)}
|
gap={theme.spacing(4)}
|
||||||
@@ -251,6 +294,11 @@ export const MonitorTable = ({
|
|||||||
onRowClick={(row) => {
|
onRowClick={(row) => {
|
||||||
navigate(`/uptime/${row.id}`);
|
navigate(`/uptime/${row.id}`);
|
||||||
}}
|
}}
|
||||||
|
getRowSx={(row) => ({
|
||||||
|
backgroundColor: isRowSelected(row.id)
|
||||||
|
? theme.palette.action.selected
|
||||||
|
: "inherit",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<Pagination
|
<Pagination
|
||||||
component="div"
|
component="div"
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
|
import {
|
||||||
|
ControlsFilter,
|
||||||
|
HeaderMonitorsSummary,
|
||||||
|
BulkActionsBar,
|
||||||
|
} from "@/Components/monitors";
|
||||||
import { MonitorBasePageWithStates } from "@/Components/design-elements";
|
import { MonitorBasePageWithStates } from "@/Components/design-elements";
|
||||||
import { TextField, Dialog } from "@/Components/inputs";
|
import { TextField, Dialog, Button } from "@/Components/inputs";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
|
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
|
||||||
import { HeaderCreate } from "@/Components/common";
|
import { HeaderCreate } from "@/Components/common";
|
||||||
|
import { Play, Pause } from "lucide-react";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
@@ -16,6 +21,7 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
|||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import useDebounce from "@/Hooks/useDebounce";
|
import useDebounce from "@/Hooks/useDebounce";
|
||||||
|
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||||
|
|
||||||
const UptimeMonitorsPage = () => {
|
const UptimeMonitorsPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -37,6 +43,7 @@ const UptimeMonitorsPage = () => {
|
|||||||
const [sortField, setSortField] = useState<string>("");
|
const [sortField, setSortField] = useState<string>("");
|
||||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||||
|
|
||||||
const isDialogOpen = Boolean(selectedMonitor);
|
const isDialogOpen = Boolean(selectedMonitor);
|
||||||
|
|
||||||
const debouncedSearch = useDebounce<string>(search, 300);
|
const debouncedSearch = useDebounce<string>(search, 300);
|
||||||
@@ -102,6 +109,15 @@ const UptimeMonitorsPage = () => {
|
|||||||
count,
|
count,
|
||||||
} = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
|
} = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
|
||||||
|
|
||||||
|
// Bulk actions
|
||||||
|
const {
|
||||||
|
selectedRows,
|
||||||
|
setSelectedRows,
|
||||||
|
handleBulkPause,
|
||||||
|
handleBulkResume,
|
||||||
|
handleCancelSelection,
|
||||||
|
} = useBulkMonitorActions(refetch, page);
|
||||||
|
|
||||||
// Delete hook
|
// Delete hook
|
||||||
const { deleteFn, loading: isDeleting } = useDelete();
|
const { deleteFn, loading: isDeleting } = useDelete();
|
||||||
|
|
||||||
@@ -177,6 +193,29 @@ const UptimeMonitorsPage = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</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
|
<MonitorTable
|
||||||
monitors={monitorsWithChecks || []}
|
monitors={monitorsWithChecks || []}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
@@ -198,6 +237,8 @@ const UptimeMonitorsPage = () => {
|
|||||||
);
|
);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}}
|
}}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
onSelectionChange={setSelectedRows}
|
||||||
/>
|
/>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { PageSpeedGroupedCheck } from "@/Types/Check";
|
|||||||
import type { GeoContinent } from "@/Types/GeoCheck";
|
import type { GeoContinent } from "@/Types/GeoCheck";
|
||||||
export type { GeoContinent } from "@/Types/GeoCheck";
|
export type { GeoContinent } from "@/Types/GeoCheck";
|
||||||
|
|
||||||
|
export const MAX_RECENT_CHECKS = 50;
|
||||||
|
|
||||||
export const MonitorTypes = [
|
export const MonitorTypes = [
|
||||||
"http",
|
"http",
|
||||||
"ping",
|
"ping",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface QueueJobSummary {
|
|||||||
monitorUrl: string | null;
|
monitorUrl: string | null;
|
||||||
monitorType: MonitorType | null;
|
monitorType: MonitorType | null;
|
||||||
monitorInterval: number | null;
|
monitorInterval: number | null;
|
||||||
|
monitorGeoInterval: number | null;
|
||||||
|
monitorActive: boolean | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
lockedAt: number | null;
|
lockedAt: number | null;
|
||||||
runCount: number;
|
runCount: number;
|
||||||
@@ -32,6 +34,7 @@ export interface QueueJobSummary {
|
|||||||
lastFinishedAt: number | null;
|
lastFinishedAt: number | null;
|
||||||
lastRunTook: number | null;
|
lastRunTook: number | null;
|
||||||
lastFailedAt: number | null;
|
lastFailedAt: number | null;
|
||||||
|
repeat: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueueData {
|
export interface QueueData {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface Settings {
|
|||||||
systemEmailHost?: string;
|
systemEmailHost?: string;
|
||||||
systemEmailPort?: number;
|
systemEmailPort?: number;
|
||||||
systemEmailAddress?: string;
|
systemEmailAddress?: string;
|
||||||
|
systemEmailDisplayName?: string;
|
||||||
systemEmailUser?: string;
|
systemEmailUser?: string;
|
||||||
systemEmailConnectionHost?: string;
|
systemEmailConnectionHost?: string;
|
||||||
systemEmailTLSServername?: string;
|
systemEmailTLSServername?: string;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { alpha } from "@mui/material/styles";
|
||||||
|
|
||||||
const typographyBase = 13;
|
const typographyBase = 13;
|
||||||
|
|
||||||
export const typographyLevels = {
|
export const typographyLevels = {
|
||||||
@@ -17,6 +19,9 @@ export const colors = {
|
|||||||
gray850: "#1c1c21",
|
gray850: "#1c1c21",
|
||||||
brandGreen: "#13715B",
|
brandGreen: "#13715B",
|
||||||
brandGreenLight: "#4DAF94",
|
brandGreenLight: "#4DAF94",
|
||||||
|
green200: "#ECF7F2",
|
||||||
|
yellow200: "#FFF4E5",
|
||||||
|
orange500: "#FFA500",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const lightPalette = {
|
export const lightPalette = {
|
||||||
@@ -29,6 +34,10 @@ export const lightPalette = {
|
|||||||
sidebar: {
|
sidebar: {
|
||||||
accent: colors.brandGreen,
|
accent: colors.brandGreen,
|
||||||
},
|
},
|
||||||
|
rowStatus: {
|
||||||
|
running: colors.green200,
|
||||||
|
paused: colors.yellow200,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const darkPalette = {
|
export const darkPalette = {
|
||||||
@@ -45,4 +54,8 @@ export const darkPalette = {
|
|||||||
sidebar: {
|
sidebar: {
|
||||||
accent: colors.brandGreenLight,
|
accent: colors.brandGreenLight,
|
||||||
},
|
},
|
||||||
|
rowStatus: {
|
||||||
|
running: alpha(colors.brandGreen, 0.18),
|
||||||
|
paused: alpha(colors.orange500, 0.18),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -15,9 +15,11 @@ declare module "@mui/material/styles" {
|
|||||||
}
|
}
|
||||||
interface Palette {
|
interface Palette {
|
||||||
sidebar: { accent: string };
|
sidebar: { accent: string };
|
||||||
|
rowStatus: { running: string; paused: string };
|
||||||
}
|
}
|
||||||
interface PaletteOptions {
|
interface PaletteOptions {
|
||||||
sidebar?: { accent: string };
|
sidebar?: { accent: string };
|
||||||
|
rowStatus?: { running: string; paused: string };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const SPACING = {
|
export const SPACING = {
|
||||||
|
XXS: 0.25,
|
||||||
XS: 0.5,
|
XS: 0.5,
|
||||||
SM: 1,
|
SM: 1,
|
||||||
MD: 1.5,
|
MD: 1.5,
|
||||||
|
|||||||
@@ -15,12 +15,33 @@ export const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|||||||
export const MS_PER_DAY = 24 * MS_PER_HOUR;
|
export const MS_PER_DAY = 24 * MS_PER_HOUR;
|
||||||
export const MS_PER_WEEK = MS_PER_DAY * 7;
|
export const MS_PER_WEEK = MS_PER_DAY * 7;
|
||||||
|
|
||||||
export const formatDateWithTz = (timestamp: string, format: string, timezone: string) => {
|
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
|
||||||
|
) => {
|
||||||
if (!timestamp) {
|
if (!timestamp) {
|
||||||
return "Unknown time";
|
return "Unknown time";
|
||||||
}
|
}
|
||||||
const formattedDate = dayjs(timestamp).tz(timezone).format(format);
|
const tz = resolveTimezone(timezone);
|
||||||
return formattedDate;
|
try {
|
||||||
|
return dayjs(timestamp).tz(tz).format(format);
|
||||||
|
} catch {
|
||||||
|
return dayjs(timestamp).utc().format(format);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tickDateFormatLookup = (range: string) => {
|
export const tickDateFormatLookup = (range: string) => {
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export const settingsSchema = z.object({
|
|||||||
.or(z.literal(""))
|
.or(z.literal(""))
|
||||||
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
|
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
|
||||||
.optional(),
|
.optional(),
|
||||||
|
systemEmailDisplayName: z
|
||||||
|
.string()
|
||||||
|
.max(100, "Display name must be 100 characters or fewer")
|
||||||
|
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||||
|
.optional(),
|
||||||
systemEmailUser: z
|
systemEmailUser: z
|
||||||
.string()
|
.string()
|
||||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "دعوة عضو",
|
"inviteMember": "دعوة عضو",
|
||||||
"openSite": "فتح الموقع",
|
"openSite": "فتح الموقع",
|
||||||
"pause": "إيقاف مؤقت",
|
"pause": "إيقاف مؤقت",
|
||||||
"resume": "استئناف"
|
"resume": "استئناف",
|
||||||
|
"selectAll": "تحديد جميع الشاشات",
|
||||||
|
"selectMonitor": "تحديد {{name}}",
|
||||||
|
"bulkSelected_one": "{{count}} شاشة محددة",
|
||||||
|
"bulkSelected_other": "{{count}} شاشات محددة"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "نشط منذ",
|
"activeFor": "نشط منذ",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "البنية التحتية",
|
"optionHardware": "البنية التحتية",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"optionWebSocket": "WebSocket"
|
||||||
|
},
|
||||||
|
"bulkPause": {
|
||||||
|
"paused_one": "تم إيقاف {{count}} شاشة مؤقتاً",
|
||||||
|
"paused_other": "تم إيقاف {{count}} شاشات مؤقتاً",
|
||||||
|
"resumed_one": "تم استئناف {{count}} شاشة",
|
||||||
|
"resumed_other": "تم استئناف {{count}} شاشات",
|
||||||
|
"alreadyPaused_one": "الشاشة المحددة متوقفة مؤقتاً بالفعل",
|
||||||
|
"alreadyPaused_other": "جميع الشاشات المحددة متوقفة مؤقتاً بالفعل",
|
||||||
|
"alreadyRunning_one": "الشاشة المحددة قيد التشغيل بالفعل",
|
||||||
|
"alreadyRunning_other": "جميع الشاشات المحددة قيد التشغيل بالفعل"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "رابط WebSocket",
|
"label": "رابط WebSocket",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "نقطة نهاية Capture",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "الإعدادات العامة",
|
"title": "الإعدادات العامة",
|
||||||
@@ -546,7 +564,7 @@
|
|||||||
"pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.",
|
"pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.",
|
||||||
"grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.",
|
"grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.",
|
||||||
"websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.",
|
"websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.",
|
||||||
"hardware": "مراقبة استخدام CPU والذاكرة والقرص ودرجة الحرارة للبنية التحتية الخاصة بك."
|
"hardware": "راقب استخدام وحدة المعالجة المركزية والذاكرة والقرص ودرجة الحرارة لبنيتك التحتية. يجب أن يتضمن عنوان URL لنقطة نهاية Capture المسار الكامل /api/v1/metrics (مثال: http://192.168.1.10:5001/api/v1/metrics). بالنسبة لمفتاح التفويض، الصق سلسلة الرمز فقط — لا تُضمّن كلمة 'Bearer'."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "Pozvat člena",
|
"inviteMember": "Pozvat člena",
|
||||||
"openSite": "Otevřít stránku",
|
"openSite": "Otevřít stránku",
|
||||||
"pause": "Pozastavit",
|
"pause": "Pozastavit",
|
||||||
"resume": "Obnovit"
|
"resume": "Obnovit",
|
||||||
|
"selectAll": "Vybrat všechny monitory",
|
||||||
|
"selectMonitor": "Vybrat {{name}}",
|
||||||
|
"bulkSelected_one": "Vybrán {{count}} monitor",
|
||||||
|
"bulkSelected_other": "Vybráno {{count}} monitorů"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Aktivní po dobu",
|
"activeFor": "Aktivní po dobu",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "Infrastruktura",
|
"optionHardware": "Infrastruktura",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"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ěží"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "WebSocket URL",
|
"label": "WebSocket URL",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "Endpoint Capture",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Obecná nastavení",
|
"title": "Obecná nastavení",
|
||||||
@@ -546,7 +564,7 @@
|
|||||||
"pagespeed": "Sledujte výkon načítání stránky, metriky Core Web Vitals a hodnocení optimalizace vašeho webu.",
|
"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.",
|
"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.",
|
"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."
|
"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'."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "Mitglied einladen",
|
"inviteMember": "Mitglied einladen",
|
||||||
"openSite": "Seite öffnen",
|
"openSite": "Seite öffnen",
|
||||||
"pause": "Pausieren",
|
"pause": "Pausieren",
|
||||||
"resume": "Fortsetzen"
|
"resume": "Fortsetzen",
|
||||||
|
"selectAll": "Alle Monitore auswählen",
|
||||||
|
"selectMonitor": "{{name}} auswählen",
|
||||||
|
"bulkSelected_one": "{{count}} Monitor ausgewählt",
|
||||||
|
"bulkSelected_other": "{{count}} Monitore ausgewählt"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Aktiv seit",
|
"activeFor": "Aktiv seit",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "Infrastruktur",
|
"optionHardware": "Infrastruktur",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "WebSocket-URL",
|
"label": "WebSocket-URL",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "Capture-Endpunkt",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Allgemeine Einstellungen",
|
"title": "Allgemeine Einstellungen",
|
||||||
@@ -546,7 +564,7 @@
|
|||||||
"pagespeed": "Verfolgen Sie die Ladezeit, Core Web Vitals und Optimierungswerte Ihrer Website.",
|
"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.",
|
"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.",
|
"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-, Arbeitsspeicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur."
|
"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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -70,7 +70,9 @@
|
|||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
"paused": "paused",
|
"running": "Running",
|
||||||
|
"paused": "Paused",
|
||||||
|
"idle": "Idle",
|
||||||
"na": "N/A",
|
"na": "N/A",
|
||||||
"resolved": "Resolved",
|
"resolved": "Resolved",
|
||||||
"responseTime": "Response time"
|
"responseTime": "Response time"
|
||||||
@@ -421,7 +423,21 @@
|
|||||||
"inviteMember": "Invite member",
|
"inviteMember": "Invite member",
|
||||||
"openSite": "Open site",
|
"openSite": "Open site",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
"resume": "Resume"
|
"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"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Active for",
|
"activeFor": "Active for",
|
||||||
@@ -534,6 +550,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "WebSocket URL",
|
"label": "WebSocket URL",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "Capture endpoint",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "General settings",
|
"title": "General settings",
|
||||||
@@ -546,7 +566,7 @@
|
|||||||
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
|
"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.",
|
"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.",
|
"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."
|
"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'."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
@@ -883,6 +903,7 @@
|
|||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"headers": {
|
"headers": {
|
||||||
|
"state": "State",
|
||||||
"timestamp": "Timestamp",
|
"timestamp": "Timestamp",
|
||||||
"level": "Level",
|
"level": "Level",
|
||||||
"service": "Service",
|
"service": "Service",
|
||||||
@@ -1198,6 +1219,10 @@
|
|||||||
"label": "Email address - Used for authentication",
|
"label": "Email address - Used for authentication",
|
||||||
"placeholder": "you@example.com"
|
"placeholder": "you@example.com"
|
||||||
},
|
},
|
||||||
|
"displayName": {
|
||||||
|
"label": "Sender name (optional) - Friendly name shown in the From field",
|
||||||
|
"placeholder": "Checkmate notifications"
|
||||||
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"label": "Email user - Username for authentication, overrides email address if specified",
|
"label": "Email user - Username for authentication, overrides email address if specified",
|
||||||
"placeholder": "Leave empty if not required"
|
"placeholder": "Leave empty if not required"
|
||||||
|
|||||||
@@ -428,7 +428,11 @@
|
|||||||
"inviteMember": "Invitar miembro",
|
"inviteMember": "Invitar miembro",
|
||||||
"openSite": "Abrir sitio",
|
"openSite": "Abrir sitio",
|
||||||
"pause": "Pausar",
|
"pause": "Pausar",
|
||||||
"resume": "Reanudar"
|
"resume": "Reanudar",
|
||||||
|
"selectAll": "Seleccionar todos los monitores",
|
||||||
|
"selectMonitor": "Seleccionar {{name}}",
|
||||||
|
"bulkSelected_one": "{{count}} monitor seleccionado",
|
||||||
|
"bulkSelected_other": "{{count}} monitores seleccionados"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Activo desde hace",
|
"activeFor": "Activo desde hace",
|
||||||
@@ -456,6 +460,16 @@
|
|||||||
"optionHardware": "Infraestructura",
|
"optionHardware": "Infraestructura",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -541,6 +555,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "URL de WebSocket",
|
"label": "URL de WebSocket",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "Endpoint de Capture",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Configuración general",
|
"title": "Configuración general",
|
||||||
@@ -553,7 +571,7 @@
|
|||||||
"pagespeed": "Rastree el rendimiento de carga de página, Core Web Vitals y puntuaciones de optimización de su sitio web.",
|
"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.",
|
"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.",
|
"websocket": "Ingrese la URL de WebSocket a monitorear (p. ej., wss://example.com/socket) y agregue un nombre para mostrar.",
|
||||||
"hardware": "Monitoree el uso de CPU, memoria, disco y temperatura de su infraestructura."
|
"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'."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "Kutsu jäsen",
|
"inviteMember": "Kutsu jäsen",
|
||||||
"openSite": "Avaa sivusto",
|
"openSite": "Avaa sivusto",
|
||||||
"pause": "Keskeytä",
|
"pause": "Keskeytä",
|
||||||
"resume": "Jatka"
|
"resume": "Jatka",
|
||||||
|
"selectAll": "Valitse kaikki näytöt",
|
||||||
|
"selectMonitor": "Valitse {{name}}",
|
||||||
|
"bulkSelected_one": "{{count}} näyttö valittu",
|
||||||
|
"bulkSelected_other": "{{count}} näyttöä valittu"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Aktiivinen",
|
"activeFor": "Aktiivinen",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "Infrastruktuuri",
|
"optionHardware": "Infrastruktuuri",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"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ä"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "WebSocket URL",
|
"label": "WebSocket URL",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "Capture-päätepiste",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Yleiset asetukset",
|
"title": "Yleiset asetukset",
|
||||||
@@ -546,7 +564,7 @@
|
|||||||
"pagespeed": "Seuraa sivun latausnopeutta, Core Web Vitals -arvoja ja optimointipisteitä verkkosivustollasi.",
|
"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.",
|
"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.",
|
"websocket": "Syötä monitoroitava WebSocket URL (esim. wss://example.com/socket) ja lisää näyttönimi.",
|
||||||
"hardware": "Monitoroi infrastruktuurisi CPU:n, muistin ja levyn käyttöä sekä lämpötilaa."
|
"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'."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "Inviter un membre",
|
"inviteMember": "Inviter un membre",
|
||||||
"openSite": "Ouvrir le site",
|
"openSite": "Ouvrir le site",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
"resume": "Reprendre"
|
"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"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Actif depuis",
|
"activeFor": "Actif depuis",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "Infrastructure",
|
"optionHardware": "Infrastructure",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "URL WebSocket",
|
"label": "URL WebSocket",
|
||||||
"placeholder": "wss://example.com/socket"
|
"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",
|
"title": "Paramètres généraux",
|
||||||
@@ -546,7 +564,7 @@
|
|||||||
"pagespeed": "Suivez les performances de chargement, les Core Web Vitals et les scores d'optimisation de votre site web.",
|
"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.",
|
"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.",
|
"websocket": "Saisissez l'URL WebSocket à surveiller (ex. wss://example.com/socket) et ajoutez un nom d'affichage.",
|
||||||
"hardware": "Surveillez l'utilisation du CPU, de la mémoire, du disque et la température de votre infrastructure."
|
"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'."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "メンバーを招待",
|
"inviteMember": "メンバーを招待",
|
||||||
"openSite": "サイトを開く",
|
"openSite": "サイトを開く",
|
||||||
"pause": "一時停止",
|
"pause": "一時停止",
|
||||||
"resume": "再開"
|
"resume": "再開",
|
||||||
|
"selectAll": "すべてのモニターを選択",
|
||||||
|
"selectMonitor": "{{name}} を選択",
|
||||||
|
"bulkSelected_one": "{{count}} 個のモニターが選択されました",
|
||||||
|
"bulkSelected_other": "{{count}} 個のモニターが選択されました"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "稼働期間",
|
"activeFor": "稼働期間",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "インフラストラクチャ",
|
"optionHardware": "インフラストラクチャ",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"optionWebSocket": "WebSocket"
|
||||||
|
},
|
||||||
|
"bulkPause": {
|
||||||
|
"paused_one": "{{count}} 個のモニターを一時停止しました",
|
||||||
|
"paused_other": "{{count}} 個のモニターを一時停止しました",
|
||||||
|
"resumed_one": "{{count}} 個のモニターを再開しました",
|
||||||
|
"resumed_other": "{{count}} 個のモニターを再開しました",
|
||||||
|
"alreadyPaused_one": "選択したモニターはすでに一時停止されています",
|
||||||
|
"alreadyPaused_other": "選択したすべてのモニターはすでに一時停止されています",
|
||||||
|
"alreadyRunning_one": "選択したモニターはすでに実行中です",
|
||||||
|
"alreadyRunning_other": "選択したすべてのモニターはすでに実行中です"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "WebSocket URL",
|
"label": "WebSocket URL",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "Capture エンドポイント",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "一般設定",
|
"title": "一般設定",
|
||||||
@@ -546,7 +564,7 @@
|
|||||||
"pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。",
|
"pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。",
|
||||||
"grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。",
|
"grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。",
|
||||||
"websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。",
|
"websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。",
|
||||||
"hardware": "インフラストラクチャのCPU、メモリ、ディスク使用量、温度を監視します。"
|
"hardware": "インフラの CPU、メモリ、ディスク使用率、温度を監視します。Capture エンドポイント URL には完全なパス /api/v1/metrics を含める必要があります (例: http://192.168.1.10:5001/api/v1/metrics)。認証シークレットにはトークン文字列のみを貼り付けてください — 'Bearer' は含めないでください。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "Convidar membro",
|
"inviteMember": "Convidar membro",
|
||||||
"openSite": "Abrir site",
|
"openSite": "Abrir site",
|
||||||
"pause": "Pausar",
|
"pause": "Pausar",
|
||||||
"resume": "Retomar"
|
"resume": "Retomar",
|
||||||
|
"selectAll": "Selecionar todos os monitores",
|
||||||
|
"selectMonitor": "Selecionar {{name}}",
|
||||||
|
"bulkSelected_one": "{{count}} monitor selecionado",
|
||||||
|
"bulkSelected_other": "{{count}} monitores selecionados"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Ativo há",
|
"activeFor": "Ativo há",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "Infraestrutura",
|
"optionHardware": "Infraestrutura",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "URL do WebSocket",
|
"label": "URL do WebSocket",
|
||||||
"placeholder": "wss://exemplo.com/socket"
|
"placeholder": "wss://exemplo.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "Endpoint do Capture",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Configurações gerais",
|
"title": "Configurações gerais",
|
||||||
@@ -546,7 +564,7 @@
|
|||||||
"pagespeed": "Acompanhe o desempenho de carregamento da página, Core Web Vitals e pontuações de otimização do seu site.",
|
"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.",
|
"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.",
|
"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."
|
"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'."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "Пригласить участника",
|
"inviteMember": "Пригласить участника",
|
||||||
"openSite": "Открыть сайт",
|
"openSite": "Открыть сайт",
|
||||||
"pause": "Пауза",
|
"pause": "Пауза",
|
||||||
"resume": "Продолжить"
|
"resume": "Продолжить",
|
||||||
|
"selectAll": "Выбрать все мониторы",
|
||||||
|
"selectMonitor": "Выбрать {{name}}",
|
||||||
|
"bulkSelected_one": "Выбран {{count}} монитор",
|
||||||
|
"bulkSelected_other": "Выбрано {{count}} мониторов"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Активен",
|
"activeFor": "Активен",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "Инфраструктура",
|
"optionHardware": "Инфраструктура",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"optionWebSocket": "WebSocket"
|
||||||
|
},
|
||||||
|
"bulkPause": {
|
||||||
|
"paused_one": "{{count}} монитор приостановлен",
|
||||||
|
"paused_other": "{{count}} мониторов приостановлено",
|
||||||
|
"resumed_one": "{{count}} монитор возобновлен",
|
||||||
|
"resumed_other": "{{count}} мониторов возобновлено",
|
||||||
|
"alreadyPaused_one": "Выбранный монитор уже приостановлен",
|
||||||
|
"alreadyPaused_other": "Все выбранные мониторы уже приостановлены",
|
||||||
|
"alreadyRunning_one": "Выбранный монитор уже запущен",
|
||||||
|
"alreadyRunning_other": "Все выбранные мониторы уже запущены"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "WebSocket URL",
|
"label": "WebSocket URL",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "Эндпоинт Capture",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Основные настройки",
|
"title": "Основные настройки",
|
||||||
@@ -546,7 +564,7 @@
|
|||||||
"pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.",
|
"pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.",
|
||||||
"grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.",
|
"grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.",
|
||||||
"websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.",
|
"websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.",
|
||||||
"hardware": "Мониторинг CPU, памяти, дисков и температуры Вашей инфраструктуры."
|
"hardware": "Отслеживайте использование ЦП, памяти, диска и температуру вашей инфраструктуры. URL эндпоинта Capture должен включать полный путь /api/v1/metrics (например, http://192.168.1.10:5001/api/v1/metrics). В секрете авторизации укажите только строку токена — не включайте 'Bearer'."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "เชิญสมาชิก",
|
"inviteMember": "เชิญสมาชิก",
|
||||||
"openSite": "เปิดเว็บไซต์",
|
"openSite": "เปิดเว็บไซต์",
|
||||||
"pause": "หยุดชั่วคราว",
|
"pause": "หยุดชั่วคราว",
|
||||||
"resume": "ดำเนินการต่อ"
|
"resume": "ดำเนินการต่อ",
|
||||||
|
"selectAll": "เลือกจอภาพทั้งหมด",
|
||||||
|
"selectMonitor": "เลือก {{name}}",
|
||||||
|
"bulkSelected_one": "เลือกแล้ว {{count}} จอภาพ",
|
||||||
|
"bulkSelected_other": "เลือกแล้ว {{count}} จอภาพ"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "ทำงานมาแล้ว",
|
"activeFor": "ทำงานมาแล้ว",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "โครงสร้างพื้นฐาน",
|
"optionHardware": "โครงสร้างพื้นฐาน",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"optionWebSocket": "WebSocket"
|
||||||
|
},
|
||||||
|
"bulkPause": {
|
||||||
|
"paused_one": "หยุดจอภาพชั่วคราว {{count}} จอ",
|
||||||
|
"paused_other": "หยุดจอภาพชั่วคราว {{count}} จอ",
|
||||||
|
"resumed_one": "กลับมาทำงานต่อ {{count}} จอ",
|
||||||
|
"resumed_other": "กลับมาทำงานต่อ {{count}} จอ",
|
||||||
|
"alreadyPaused_one": "จอภาพที่เลือกถูกหยุดชั่วคราวแล้ว",
|
||||||
|
"alreadyPaused_other": "จอภาพที่เลือกทั้งหมดถูกหยุดชั่วคราวแล้ว",
|
||||||
|
"alreadyRunning_one": "จอภาพที่เลือกกำลังทำงานอยู่แล้ว",
|
||||||
|
"alreadyRunning_other": "จอภาพที่เลือกทั้งหมดกำลังทำงานอยู่แล้ว"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "URL ของ WebSocket",
|
"label": "URL ของ WebSocket",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "ปลายทาง Capture",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "ตั้งค่าทั่วไป",
|
"title": "ตั้งค่าทั่วไป",
|
||||||
@@ -546,7 +564,7 @@
|
|||||||
"pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์",
|
"pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์",
|
||||||
"grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง",
|
"grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง",
|
||||||
"websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง",
|
"websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง",
|
||||||
"hardware": "ตรวจสอบการใช้งาน CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐาน"
|
"hardware": "ตรวจสอบการใช้ CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐานของคุณ URL ของปลายทาง Capture ต้องรวมเส้นทางแบบเต็ม /api/v1/metrics (ตัวอย่าง: http://192.168.1.10:5001/api/v1/metrics) สำหรับรหัสลับสำหรับยืนยันตัวตน ให้วางเฉพาะสตริงโทเค็น — อย่ารวมคำว่า 'Bearer'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "Üye davet et",
|
"inviteMember": "Üye davet et",
|
||||||
"openSite": "Siteyi aç",
|
"openSite": "Siteyi aç",
|
||||||
"pause": "Duraklat",
|
"pause": "Duraklat",
|
||||||
"resume": "Devam et"
|
"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"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Aktif süresi",
|
"activeFor": "Aktif süresi",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "Altyapı",
|
"optionHardware": "Altyapı",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "WebSocket URL",
|
"label": "WebSocket URL",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "Capture uç noktası",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Genel ayarlar",
|
"title": "Genel ayarlar",
|
||||||
@@ -546,7 +564,7 @@
|
|||||||
"pagespeed": "Web sitenizin sayfa yükleme performansını, Core Web Vitals metriklerini ve optimizasyon puanlarını takip edin.",
|
"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.",
|
"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.",
|
"websocket": "İzlenecek WebSocket URL'sini girin (örn. wss://example.com/socket) ve bir görünen ad ekleyin.",
|
||||||
"hardware": "Altyapınız için CPU, bellek, disk kullanımı ve sıcaklığı izleyin."
|
"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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "Запросити учасника",
|
"inviteMember": "Запросити учасника",
|
||||||
"openSite": "Відкрити сайт",
|
"openSite": "Відкрити сайт",
|
||||||
"pause": "Призупинити",
|
"pause": "Призупинити",
|
||||||
"resume": "Відновити"
|
"resume": "Відновити",
|
||||||
|
"selectAll": "Вибрати всі монітори",
|
||||||
|
"selectMonitor": "Вибрати {{name}}",
|
||||||
|
"bulkSelected_one": "Вибрано {{count}} монітор",
|
||||||
|
"bulkSelected_other": "Вибрано {{count}} моніторів"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Активний протягом",
|
"activeFor": "Активний протягом",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "Інфраструктура",
|
"optionHardware": "Інфраструктура",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"optionWebSocket": "WebSocket"
|
||||||
|
},
|
||||||
|
"bulkPause": {
|
||||||
|
"paused_one": "{{count}} монітор призупинено",
|
||||||
|
"paused_other": "{{count}} моніторів призупинено",
|
||||||
|
"resumed_one": "{{count}} монітор відновлено",
|
||||||
|
"resumed_other": "{{count}} моніторів відновлено",
|
||||||
|
"alreadyPaused_one": "Вибраний монітор уже призупинено",
|
||||||
|
"alreadyPaused_other": "Всі вибрані монітори уже призупинено",
|
||||||
|
"alreadyRunning_one": "Вибраний монітор уже запущено",
|
||||||
|
"alreadyRunning_other": "Всі вибрані монітори уже запущено"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "WebSocket URL",
|
"label": "WebSocket URL",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "Точка доступу Capture",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "Загальні налаштування",
|
"title": "Загальні налаштування",
|
||||||
@@ -546,7 +564,7 @@
|
|||||||
"pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.",
|
"pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.",
|
||||||
"grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.",
|
"grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.",
|
||||||
"websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.",
|
"websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.",
|
||||||
"hardware": "Моніторинг CPU, пам'яті, використання диска та температури Вашої інфраструктури."
|
"hardware": "Відстежуйте використання ЦП, пам'яті, диска та температуру вашої інфраструктури. URL точки доступу Capture має містити повний шлях /api/v1/metrics (наприклад, http://192.168.1.10:5001/api/v1/metrics). У секрет авторизації вставте лише рядок токена — не додавайте 'Bearer'."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "Mời thành viên",
|
"inviteMember": "Mời thành viên",
|
||||||
"openSite": "Mở trang web",
|
"openSite": "Mở trang web",
|
||||||
"pause": "Tạm dừng",
|
"pause": "Tạm dừng",
|
||||||
"resume": "Tiếp tục"
|
"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"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "Hoạt động từ",
|
"activeFor": "Hoạt động từ",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "Hạ tầng",
|
"optionHardware": "Hạ tầng",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "URL WebSocket",
|
"label": "URL WebSocket",
|
||||||
"placeholder": "wss://example.com/socket"
|
"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",
|
"title": "Cài đặt chung",
|
||||||
@@ -546,7 +564,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.",
|
"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ị.",
|
"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ị.",
|
"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 CPU, bộ nhớ, dung lượng ổ đĩa và nhiệt độ cho hạ tầng của bạn."
|
"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'."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "邀请成员",
|
"inviteMember": "邀请成员",
|
||||||
"openSite": "打开站点",
|
"openSite": "打开站点",
|
||||||
"pause": "暂停",
|
"pause": "暂停",
|
||||||
"resume": "恢复"
|
"resume": "恢复",
|
||||||
|
"selectAll": "选择所有监视器",
|
||||||
|
"selectMonitor": "选择 {{name}}",
|
||||||
|
"bulkSelected_one": "已选择 {{count}} 个监视器",
|
||||||
|
"bulkSelected_other": "已选择 {{count}} 个监视器"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "已活跃",
|
"activeFor": "已活跃",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "基础设施",
|
"optionHardware": "基础设施",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"optionWebSocket": "WebSocket"
|
||||||
|
},
|
||||||
|
"bulkPause": {
|
||||||
|
"paused_one": "已暂停 {{count}} 个监视器",
|
||||||
|
"paused_other": "已暂停 {{count}} 个监视器",
|
||||||
|
"resumed_one": "已恢复 {{count}} 个监视器",
|
||||||
|
"resumed_other": "已恢复 {{count}} 个监视器",
|
||||||
|
"alreadyPaused_one": "所选监视器已暂停",
|
||||||
|
"alreadyPaused_other": "所有选定的监视器均已暂停",
|
||||||
|
"alreadyRunning_one": "所选监视器已经在运行",
|
||||||
|
"alreadyRunning_other": "所有选定的监视器均已在运行"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "WebSocket URL",
|
"label": "WebSocket URL",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "Capture 端点",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "常规设置",
|
"title": "常规设置",
|
||||||
@@ -546,7 +564,7 @@
|
|||||||
"pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。",
|
"pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。",
|
||||||
"grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。",
|
"grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。",
|
||||||
"websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。",
|
"websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。",
|
||||||
"hardware": "监控基础设施的 CPU、内存、磁盘使用率和温度。"
|
"hardware": "监控基础设施的 CPU、内存、磁盘使用情况和温度。Capture 端点 URL 必须包含完整路径 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授权密钥只需粘贴令牌字符串——请勿包含 'Bearer'。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -421,7 +421,11 @@
|
|||||||
"inviteMember": "邀請成員",
|
"inviteMember": "邀請成員",
|
||||||
"openSite": "開啟網站",
|
"openSite": "開啟網站",
|
||||||
"pause": "暫停",
|
"pause": "暫停",
|
||||||
"resume": "恢復"
|
"resume": "恢復",
|
||||||
|
"selectAll": "選擇所有監視器",
|
||||||
|
"selectMonitor": "選擇 {{name}}",
|
||||||
|
"bulkSelected_one": "已選擇 {{count}} 個監視器",
|
||||||
|
"bulkSelected_other": "已選擇 {{count}} 個監視器"
|
||||||
},
|
},
|
||||||
"statBoxes": {
|
"statBoxes": {
|
||||||
"activeFor": "已啟用",
|
"activeFor": "已啟用",
|
||||||
@@ -449,6 +453,16 @@
|
|||||||
"optionHardware": "基礎架構",
|
"optionHardware": "基礎架構",
|
||||||
"optionGrpc": "gRPC",
|
"optionGrpc": "gRPC",
|
||||||
"optionWebSocket": "WebSocket"
|
"optionWebSocket": "WebSocket"
|
||||||
|
},
|
||||||
|
"bulkPause": {
|
||||||
|
"paused_one": "已暫停 {{count}} 個監視器",
|
||||||
|
"paused_other": "已暫停 {{count}} 個監視器",
|
||||||
|
"resumed_one": "已恢復 {{count}} 個監視器",
|
||||||
|
"resumed_other": "已恢復 {{count}} 個監視器",
|
||||||
|
"alreadyPaused_one": "所選監視器已暫停",
|
||||||
|
"alreadyPaused_other": "所有選定的監視器均已暫停",
|
||||||
|
"alreadyRunning_one": "所選監視器已經在運行",
|
||||||
|
"alreadyRunning_other": "所有選定的監視器均已在運行"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -534,6 +548,10 @@
|
|||||||
"wsUrl": {
|
"wsUrl": {
|
||||||
"label": "WebSocket URL",
|
"label": "WebSocket URL",
|
||||||
"placeholder": "wss://example.com/socket"
|
"placeholder": "wss://example.com/socket"
|
||||||
|
},
|
||||||
|
"captureUrl": {
|
||||||
|
"label": "Capture 端點",
|
||||||
|
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "一般設定",
|
"title": "一般設定",
|
||||||
@@ -546,7 +564,7 @@
|
|||||||
"pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。",
|
"pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。",
|
||||||
"grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。",
|
"grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。",
|
||||||
"websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。",
|
"websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。",
|
||||||
"hardware": "監控您基礎架構的 CPU、記憶體、磁碟使用量和溫度。"
|
"hardware": "監控基礎架構的 CPU、記憶體、磁碟使用情況和溫度。Capture 端點 URL 必須包含完整路徑 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授權密鑰僅貼上權杖字串——請勿包含 'Bearer'。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignoreTls": {
|
"ignoreTls": {
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ services:
|
|||||||
image: mongo:6.0
|
image: mongo:6.0
|
||||||
restart: always
|
restart: always
|
||||||
command: ["mongod", "--quiet", "--bind_ip_all"]
|
command: ["mongod", "--quiet", "--bind_ip_all"]
|
||||||
ports:
|
|
||||||
- "27017:27017"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./mongo/data:/data/db
|
- ./mongo/data:/data/db
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
# 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.
|
|
||||||
+10530
-4423
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,53 @@
|
|||||||
|
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) },
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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",
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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 },
|
||||||
|
});
|
||||||
Generated
+33
-8
@@ -12,7 +12,7 @@
|
|||||||
"@grpc/grpc-js": "^1.14.3",
|
"@grpc/grpc-js": "^1.14.3",
|
||||||
"@grpc/proto-loader": "^0.8.0",
|
"@grpc/proto-loader": "^0.8.0",
|
||||||
"@pulsecron/pulse": "1.6.8",
|
"@pulsecron/pulse": "1.6.8",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.15.2",
|
||||||
"bcryptjs": "3.0.2",
|
"bcryptjs": "3.0.2",
|
||||||
"cacheable-lookup": "7.0.0",
|
"cacheable-lookup": "7.0.0",
|
||||||
"compression": "1.8.1",
|
"compression": "1.8.1",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"mailersend": "^2.2.0",
|
"mailersend": "^2.2.0",
|
||||||
"mjml": "^5.0.0-alpha.4",
|
"mjml": "^5.0.0-alpha.4",
|
||||||
"mongoose": "^8.3.3",
|
"mongoose": "^8.3.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.2",
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "8.0.5",
|
||||||
"ping": "0.4.4",
|
"ping": "0.4.4",
|
||||||
"sharp": "0.33.5",
|
"sharp": "0.33.5",
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
"@types/compression": "1.8.1",
|
"@types/compression": "1.8.1",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
@@ -126,6 +127,19 @@
|
|||||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@aws-crypto/sha256-browser": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
|
||||||
@@ -5441,9 +5455,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.15.1",
|
"version": "1.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||||
"integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==",
|
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.15.11",
|
||||||
@@ -11956,6 +11970,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -12329,9 +12353,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.12",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -15538,6 +15562,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -11,7 +11,9 @@
|
|||||||
"test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --no-coverage test/integration",
|
"test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --no-coverage test/integration",
|
||||||
"dev": "nodemon --exec tsx src/index.js",
|
"dev": "nodemon --exec tsx src/index.js",
|
||||||
"start": "node --watch ./dist/index.js",
|
"start": "node --watch ./dist/index.js",
|
||||||
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
|
"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",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint-fix": "eslint --fix .",
|
"lint-fix": "eslint --fix .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
@@ -30,7 +32,7 @@
|
|||||||
"@grpc/grpc-js": "^1.14.3",
|
"@grpc/grpc-js": "^1.14.3",
|
||||||
"@grpc/proto-loader": "^0.8.0",
|
"@grpc/proto-loader": "^0.8.0",
|
||||||
"@pulsecron/pulse": "1.6.8",
|
"@pulsecron/pulse": "1.6.8",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.15.2",
|
||||||
"bcryptjs": "3.0.2",
|
"bcryptjs": "3.0.2",
|
||||||
"cacheable-lookup": "7.0.0",
|
"cacheable-lookup": "7.0.0",
|
||||||
"compression": "1.8.1",
|
"compression": "1.8.1",
|
||||||
@@ -52,7 +54,7 @@
|
|||||||
"mailersend": "^2.2.0",
|
"mailersend": "^2.2.0",
|
||||||
"mjml": "^5.0.0-alpha.4",
|
"mjml": "^5.0.0-alpha.4",
|
||||||
"mongoose": "^8.3.3",
|
"mongoose": "^8.3.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.2",
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "8.0.5",
|
||||||
"ping": "0.4.4",
|
"ping": "0.4.4",
|
||||||
"sharp": "0.33.5",
|
"sharp": "0.33.5",
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
"@types/compression": "1.8.1",
|
"@types/compression": "1.8.1",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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}`);
|
||||||
@@ -14,12 +14,12 @@ import {
|
|||||||
getUptimeDetailsByIdParamValidation,
|
getUptimeDetailsByIdParamValidation,
|
||||||
getUptimeDetailsByIdQueryValidation,
|
getUptimeDetailsByIdQueryValidation,
|
||||||
importMonitorsBodyValidation,
|
importMonitorsBodyValidation,
|
||||||
|
bulkPauseMonitorBodyValidation,
|
||||||
} from "@/validation/monitorValidation.js";
|
} from "@/validation/monitorValidation.js";
|
||||||
import sslChecker from "ssl-checker";
|
import sslChecker from "ssl-checker";
|
||||||
import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
|
import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
|
||||||
import { AppError } from "@/utils/AppError.js";
|
import { AppError } from "@/utils/AppError.js";
|
||||||
import { IMonitorService, INotificationsService } from "@/service/index.js";
|
import { IMonitorService, INotificationsService } from "@/service/index.js";
|
||||||
import { GeoContinent } from "@/types/geoCheck.js";
|
|
||||||
|
|
||||||
const SERVICE_NAME = "monitorController";
|
const SERVICE_NAME = "monitorController";
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ export interface IMonitorController {
|
|||||||
deleteAllMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
deleteAllMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||||
editMonitor: (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>;
|
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>;
|
addDemoMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||||
getMonitorsByTeamId: (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>;
|
getMonitorsWithChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||||
@@ -307,6 +308,33 @@ 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) => {
|
addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const id = requireUserId(req.user?.id);
|
const id = requireUserId(req.user?.id);
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ class SettingsController implements ISettingsController {
|
|||||||
systemEmailHost,
|
systemEmailHost,
|
||||||
systemEmailPort,
|
systemEmailPort,
|
||||||
systemEmailAddress,
|
systemEmailAddress,
|
||||||
|
systemEmailDisplayName,
|
||||||
systemEmailPassword,
|
systemEmailPassword,
|
||||||
systemEmailUser,
|
systemEmailUser,
|
||||||
systemEmailConnectionHost,
|
systemEmailConnectionHost,
|
||||||
@@ -112,6 +113,7 @@ class SettingsController implements ISettingsController {
|
|||||||
systemEmailPort,
|
systemEmailPort,
|
||||||
systemEmailUser,
|
systemEmailUser,
|
||||||
systemEmailAddress,
|
systemEmailAddress,
|
||||||
|
systemEmailDisplayName,
|
||||||
systemEmailPassword,
|
systemEmailPassword,
|
||||||
systemEmailConnectionHost,
|
systemEmailConnectionHost,
|
||||||
systemEmailSecure,
|
systemEmailSecure,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
|
|||||||
systemEmailHost: { type: String },
|
systemEmailHost: { type: String },
|
||||||
systemEmailPort: { type: Number },
|
systemEmailPort: { type: Number },
|
||||||
systemEmailAddress: { type: String },
|
systemEmailAddress: { type: String },
|
||||||
|
systemEmailDisplayName: { type: String },
|
||||||
systemEmailPassword: { type: String },
|
systemEmailPassword: { type: String },
|
||||||
systemEmailUser: { type: String },
|
systemEmailUser: { type: String },
|
||||||
systemEmailConnectionHost: { type: String, default: "localhost" },
|
systemEmailConnectionHost: { type: String, default: "localhost" },
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface IMonitorsRepository {
|
|||||||
statusPatch?: Partial<Monitor>
|
statusPatch?: Partial<Monitor>
|
||||||
): Promise<Monitor>;
|
): Promise<Monitor>;
|
||||||
togglePauseById(monitorId: string, teamId: string): Promise<Monitor>;
|
togglePauseById(monitorId: string, teamId: string): Promise<Monitor>;
|
||||||
|
bulkTogglePause(monitorIds: string[], teamId: string, pause: boolean): Promise<Monitor[]>;
|
||||||
// delete
|
// delete
|
||||||
deleteById(monitorId: string, teamId: string): Promise<Monitor>;
|
deleteById(monitorId: string, teamId: string): Promise<Monitor>;
|
||||||
deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }>;
|
deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }>;
|
||||||
|
|||||||
@@ -234,6 +234,34 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
|||||||
return this.toEntity(monitor);
|
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) => {
|
deleteById = async (monitorId: string, teamId: string) => {
|
||||||
const deletedMonitor = await MonitorModel.findOneAndDelete({ _id: monitorId, teamId });
|
const deletedMonitor = await MonitorModel.findOneAndDelete({ _id: monitorId, teamId });
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class MonitorRoutes {
|
|||||||
|
|
||||||
// General monitor routes
|
// General monitor routes
|
||||||
this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor);
|
this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor);
|
||||||
|
this.router.post("/bulk/pause", isAllowed(["admin", "superadmin"]), this.monitorController.bulkPauseMonitors);
|
||||||
|
|
||||||
// Util routes
|
// Util routes
|
||||||
this.router.get("/certificate/:monitorId", (req, res, next) => {
|
this.router.get("/certificate/:monitorId", (req, res, next) => {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface IMonitorService {
|
|||||||
// update
|
// update
|
||||||
editMonitor(args: { teamId: string; monitorId: string; body: Partial<Monitor> }): Promise<Monitor>;
|
editMonitor(args: { teamId: string; monitorId: string; body: Partial<Monitor> }): Promise<Monitor>;
|
||||||
pauseMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
|
pauseMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
|
||||||
|
bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise<{ monitors: Monitor[]; failedCount: number }>;
|
||||||
|
|
||||||
// delete
|
// delete
|
||||||
deleteMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
|
deleteMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
|
||||||
@@ -462,6 +463,43 @@ export class MonitorService implements IMonitorService {
|
|||||||
return monitor;
|
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> => {
|
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
|
||||||
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
|
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
|
||||||
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
|
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type QueueJobSummary = {
|
|||||||
monitorUrl: string | null;
|
monitorUrl: string | null;
|
||||||
monitorType: string | null;
|
monitorType: string | null;
|
||||||
monitorInterval: number | null;
|
monitorInterval: number | null;
|
||||||
|
monitorGeoInterval: number | null;
|
||||||
|
monitorActive: boolean | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
lockedAt: number | null;
|
lockedAt: number | null;
|
||||||
runCount: number;
|
runCount: number;
|
||||||
@@ -37,6 +39,7 @@ type QueueJobSummary = {
|
|||||||
lastFinishedAt: number | null;
|
lastFinishedAt: number | null;
|
||||||
lastRunTook: number | null;
|
lastRunTook: number | null;
|
||||||
lastFailedAt: number | null;
|
lastFailedAt: number | null;
|
||||||
|
repeat: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ISuperSimpleQueue {
|
export interface ISuperSimpleQueue {
|
||||||
@@ -223,8 +226,20 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
|||||||
if (result === false) {
|
if (result === false) {
|
||||||
throw new Error("Failed to pause monitor");
|
throw new Error("Failed to pause monitor");
|
||||||
}
|
}
|
||||||
|
this.scheduler.updateJob(monitor.id, { data: monitor });
|
||||||
|
|
||||||
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
|
const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`);
|
||||||
if (geoJob) await this.scheduler.removeJob(`${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 });
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.debug({
|
this.logger.debug({
|
||||||
message: `Paused monitor ${monitor.id}`,
|
message: `Paused monitor ${monitor.id}`,
|
||||||
@@ -238,9 +253,20 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
|||||||
if (result === false) {
|
if (result === false) {
|
||||||
throw new Error("Failed to resume monitor");
|
throw new Error("Failed to resume monitor");
|
||||||
}
|
}
|
||||||
|
this.scheduler.updateJob(monitor.id, { data: monitor });
|
||||||
|
|
||||||
await this.scheduler.resumeJob(`${monitor.id}-geo`);
|
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 });
|
||||||
|
}
|
||||||
this.logger.debug({
|
this.logger.debug({
|
||||||
message: `Resumed monitor ${monitor.id}`,
|
message: `Resumed monitor ${monitor.id}`,
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
@@ -341,7 +367,10 @@ export class SuperSimpleQueue implements ISuperSimpleQueue {
|
|||||||
monitorUrl: job.data?.url || null,
|
monitorUrl: job.data?.url || null,
|
||||||
monitorType: job.data?.type || null,
|
monitorType: job.data?.type || null,
|
||||||
monitorInterval: job.data?.interval || null,
|
monitorInterval: job.data?.interval || null,
|
||||||
|
monitorGeoInterval: job.data?.geoCheckInterval || null,
|
||||||
|
monitorActive: job.data?.isActive ?? null,
|
||||||
active: job.active,
|
active: job.active,
|
||||||
|
repeat: job.repeat ?? null,
|
||||||
lockedAt: job.lockedAt ?? null,
|
lockedAt: job.lockedAt ?? null,
|
||||||
runCount: job.runCount ?? 0,
|
runCount: job.runCount ?? 0,
|
||||||
failCount: job.failCount ?? 0,
|
failCount: job.failCount ?? 0,
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export class EmailService implements IEmailService {
|
|||||||
systemEmailPool,
|
systemEmailPool,
|
||||||
systemEmailUser,
|
systemEmailUser,
|
||||||
systemEmailAddress,
|
systemEmailAddress,
|
||||||
|
systemEmailDisplayName,
|
||||||
systemEmailPassword,
|
systemEmailPassword,
|
||||||
systemEmailConnectionHost,
|
systemEmailConnectionHost,
|
||||||
systemEmailTLSServername,
|
systemEmailTLSServername,
|
||||||
@@ -158,10 +159,13 @@ export class EmailService implements IEmailService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trimmedDisplayName = systemEmailDisplayName?.trim();
|
||||||
|
const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await this.transporter.sendMail({
|
const info = await this.transporter.sendMail({
|
||||||
to: to,
|
to: to,
|
||||||
from: systemEmailAddress,
|
from: from,
|
||||||
subject: subject,
|
subject: subject,
|
||||||
html: html,
|
html: html,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import { AppError } from "@/utils/AppError.js";
|
|||||||
import { ILogger } from "@/utils/logger.js";
|
import { ILogger } from "@/utils/logger.js";
|
||||||
import { IBufferService } from "./bufferService.js";
|
import { IBufferService } from "./bufferService.js";
|
||||||
import type { HardwareStatusMetrics } from "@/types/network.js";
|
import type { HardwareStatusMetrics } from "@/types/network.js";
|
||||||
|
import { MAX_RECENT_CHECKS } from "@/types/index.js";
|
||||||
|
|
||||||
const SERVICE_NAME = "StatusService";
|
const SERVICE_NAME = "StatusService";
|
||||||
const MAX_RECENT_CHECKS = 25;
|
|
||||||
const HARDWARE_ALERT_COUNTER_START = 5;
|
const HARDWARE_ALERT_COUNTER_START = 5;
|
||||||
const HARDWARE_METRIC_KEYS = ["cpu", "memory", "disk", "temp"] as const;
|
const HARDWARE_METRIC_KEYS = ["cpu", "memory", "disk", "temp"] as const;
|
||||||
type HardwareMetricKey = (typeof HARDWARE_METRIC_KEYS)[number];
|
type HardwareMetricKey = (typeof HARDWARE_METRIC_KEYS)[number];
|
||||||
|
|||||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user