Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ee6d342e9 | ||
|
|
49d3737a93 | ||
|
|
4402e4fbfa | ||
|
|
721f77ed26 | ||
|
|
34f29e5ae6 | ||
|
|
c2170267e8 | ||
|
|
321ca59380 | ||
|
|
e3ecfe6ad8 | ||
|
|
f4664eec16 | ||
|
|
56f601e34b | ||
|
|
1244ff6d93 | ||
|
|
c0ea804c98 | ||
|
|
c21cf29db6 | ||
|
|
3d0da70ce6 | ||
|
|
feb061240b | ||
|
|
a924227805 | ||
|
|
2144d3f8c0 | ||
|
|
fcd6786149 | ||
|
|
ea968ebf3c | ||
|
|
8a7b852974 | ||
|
|
c8d3e3b70c | ||
|
|
7a90a6f9f4 |
No files matched your search
Executable → Regular
+2
-21
@@ -1,23 +1,4 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Skip during merge/rebase/cherry-pick/revert. lint-staged's internal
|
||||
# git-stash mechanism is incompatible with these states and will silently
|
||||
# wipe MERGE_HEAD and your conflict resolutions on commit failure.
|
||||
git_dir=$(git rev-parse --git-dir)
|
||||
if [ -e "$git_dir/MERGE_HEAD" ] \
|
||||
|| [ -e "$git_dir/CHERRY_PICK_HEAD" ] \
|
||||
|| [ -e "$git_dir/REVERT_HEAD" ] \
|
||||
|| [ -d "$git_dir/rebase-merge" ] \
|
||||
|| [ -d "$git_dir/rebase-apply" ]; then
|
||||
echo "Skipping lint-staged (mid merge/rebase/cherry-pick/revert)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Anchor to repo root so commits made from a subdirectory still work, and
|
||||
# run each package's lint-staged in a subshell so cwd is scoped (a failure
|
||||
# inside one package can't poison the cwd for the next).
|
||||
cd "$(git rev-parse --show-toplevel)" || exit 1
|
||||
|
||||
echo "Running lint-staged..."
|
||||
(cd client && npx lint-staged) || exit 1
|
||||
(cd server && npx lint-staged) || exit 1
|
||||
cd client && npx lint-staged && cd ..
|
||||
cd server && npx lint-staged && cd ..
|
||||
@@ -159,26 +159,14 @@ When working on anything related to check scheduling, incident lifecycle, or not
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Coding conventions (mandatory)
|
||||
Read `docs/coding-conventions.md` before touching any `.tsx` or `.ts` file. The doc covers both frontend and backend rules, all enforced in code review.
|
||||
|
||||
**Universal — rule 0:** look at peer files first and follow the established pattern. Don't sneak in novel shapes.
|
||||
|
||||
**Frontend (`client/src`):**
|
||||
### Frontend conventions (mandatory for `client/src`)
|
||||
Read `docs/frontend-conventions.md` before touching any `.tsx` file. Five rules, all enforced in code review:
|
||||
1. Prefer MUI native props over `sx` (e.g. `color={…}`, `bgcolor={…}`, `mt={…}` — not `sx={{ color, bgcolor, mt }}`).
|
||||
2. Use the full theme path for colors: `color={theme.palette.text.secondary}`, never `color="text.secondary"` (greppability).
|
||||
3. No hardcoded literals — use `LAYOUT.*`, `typographyLevels.*`, `theme.shape.borderRadius`, `theme.palette.*`.
|
||||
4. Use `useTheme()` inside components; don't `import { theme } from "@/Utils/Theme/Theme"`.
|
||||
5. Pair runtime tuples with derived types: `const X = [...] as const; type X = (typeof X)[number]`.
|
||||
|
||||
**Backend (`server/src`):**
|
||||
6. Layering: Controller → Service → Repository → Mongoose. No DB calls outside repositories.
|
||||
7. Provider conventions: DI for stdlib clients, `SERVICE_NAME` + `TIMEOUT_MS` constants, `timeRequest()` helper, inline `setTimeout` race for timeout, outer try/catch into `AppError`.
|
||||
8. Mongoose schema fields with closed value sets must declare `enum` reusing the same `types/*` const tuple.
|
||||
9. Provider tests at `server/test/unit/providers/network/<name>.test.ts`, `from "@jest/globals"`, `testStatusProviderContract`, inline `setup()` per test (not `beforeEach`).
|
||||
10. Centralize validation enums in `types/*` const tuples; never inline `z.enum([…])` more than once.
|
||||
11. New entity fields must land in the validator's `*ResponseSchema` so the auto-generated OpenAPI spec sees them.
|
||||
|
||||
### Internationalization
|
||||
All user-facing strings must use the translation function:
|
||||
```javascript
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||

|
||||

|
||||

|
||||
[](https://www.bestpractices.dev/projects/9901)
|
||||
[](https://deepwiki.com/bluewave-labs/checkmate)
|
||||
|
||||
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
||||
@@ -15,10 +16,7 @@
|
||||
|
||||
[](https://www.pikapods.com/pods?run=checkmate)
|
||||
|
||||
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
|
||||
|
||||
<br>
|
||||
|
||||
<img width="1660" alt="image" src="https://github.com/user-attachments/assets/b748f36d-a271-4965-ad0a-18bf153bbee7" />
|
||||
|
||||
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.
|
||||
|
||||
@@ -26,6 +24,8 @@ 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.
|
||||
|
||||
**If you would like to sponsor a feature, [see this link](https://checkmate.so/sponsored-features).**
|
||||
|
||||
## 📚 Table of contents
|
||||
|
||||
- [📦 Demo](#demo)
|
||||
@@ -38,6 +38,7 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
|
||||
- [🏗️ Tech stack](#tech-stack)
|
||||
- [🔗 A few links](#a-few-links)
|
||||
- [🤝 Contributing](#contributing)
|
||||
- [💰 Our sponsors](#our-sponsors)
|
||||
|
||||
|
||||
## Demo
|
||||
@@ -88,17 +89,30 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
||||
## Features
|
||||
|
||||
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
|
||||
- Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server
|
||||
- Website monitoring
|
||||
- Page speed monitoring
|
||||
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
|
||||
- Selective disk monitoring with mountpoint selection
|
||||
- Docker monitoring
|
||||
- Ping monitoring
|
||||
- SSL monitoring
|
||||
- Port monitoring
|
||||
- Game server monitoring (3.0)
|
||||
- Incidents at a glance
|
||||
- Status pages with 4 beautiful themes
|
||||
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
||||
- Status pages
|
||||
- E-mail, Webhooks, Discord and Slack notifications
|
||||
- Scheduled maintenance
|
||||
- JSON query monitoring
|
||||
- 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
|
||||
- Multi-language support for English, German, Japanese, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, Chinese (Traditional, Taiwan)
|
||||
|
||||
**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
|
||||
|
||||
|
||||
Generated
+28
-43
@@ -18,7 +18,7 @@
|
||||
"@mui/x-date-pickers": "7.29.4",
|
||||
"@reduxjs/toolkit": "2.7.0",
|
||||
"@types/maplibre-gl": "^1.13.2",
|
||||
"axios": "^1.15.2",
|
||||
"axios": "^1.7.4",
|
||||
"dayjs": "1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"human-interval": "2.0.1",
|
||||
@@ -59,7 +59,7 @@
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^6.4.2"
|
||||
"vite": "6.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -2925,14 +2925,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-macros": {
|
||||
@@ -3239,9 +3239,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig/node_modules/yaml": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -4191,16 +4191,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"version": "3.3.3",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"version": "1.15.11",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -5317,9 +5313,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"version": "4.17.23",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -5476,9 +5470,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5887,9 +5881,7 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "4.0.3",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -5920,9 +5912,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||
"version": "8.5.6",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6014,19 +6004,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
"version": "1.1.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -7380,9 +7365,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
@@ -7663,9 +7648,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@
|
||||
"@mui/x-date-pickers": "7.29.4",
|
||||
"@reduxjs/toolkit": "2.7.0",
|
||||
"@types/maplibre-gl": "^1.13.2",
|
||||
"axios": "^1.15.2",
|
||||
"axios": "^1.7.4",
|
||||
"dayjs": "1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"human-interval": "2.0.1",
|
||||
@@ -68,6 +68,6 @@
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^6.4.2"
|
||||
"vite": "6.4.1"
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { getResponseColor } from "@/Utils/DataUtils";
|
||||
import { HeatmapResponseTimeTooltip } from "@/Components/common/charts/HeatmapResponseTimeTooltip";
|
||||
import type { SxProps } from "@mui/material/styles";
|
||||
import type { ResponsiveStyleValue } from "@mui/system";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
interface PlaceholderCheck {
|
||||
status: "placeholder";
|
||||
responseTime: 0;
|
||||
@@ -32,11 +32,11 @@ export const HeatmapResponseTime = ({
|
||||
|
||||
if (!checks || checks.length === 0) return null;
|
||||
|
||||
const latestChecks = checks.slice(-MAX_RECENT_CHECKS);
|
||||
const latestChecks = checks.slice(-25);
|
||||
|
||||
let data: Array<CheckSnapshot | PlaceholderCheck>;
|
||||
if (latestChecks.length !== MAX_RECENT_CHECKS) {
|
||||
const placeholders = Array(MAX_RECENT_CHECKS - latestChecks.length).fill({
|
||||
if (latestChecks.length !== 25) {
|
||||
const placeholders = Array(25 - latestChecks.length).fill({
|
||||
status: "placeholder" as const,
|
||||
});
|
||||
data = [...latestChecks, ...placeholders];
|
||||
@@ -44,13 +44,13 @@ export const HeatmapResponseTime = ({
|
||||
data = latestChecks;
|
||||
}
|
||||
return (
|
||||
<Box width={"100%"}>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Box
|
||||
width={"100%"}
|
||||
display={"grid"}
|
||||
gap={gap}
|
||||
sx={{
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
width: "100%",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: gap,
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
@@ -82,50 +82,50 @@ export const HeatmapResponseTime = ({
|
||||
check={check}
|
||||
>
|
||||
<Box
|
||||
display={"grid"}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateRows: "auto auto",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display={"flex"}
|
||||
gap={theme.spacing(2)}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width={"100%"}
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "10",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
bgcolor={statusBg}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
bgcolor: statusBg,
|
||||
borderRadius: theme.spacing(0.5),
|
||||
...availabilityCellSx,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
borderRadius: theme.spacing(0.5),
|
||||
boxShadow: `inset 0 0 0 2px ${statusBorder}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
width={"100%"}
|
||||
bgcolor={respBg}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
sx={{
|
||||
width: "100%",
|
||||
aspectRatio: "1 / 1",
|
||||
bgcolor: respBg,
|
||||
borderRadius: theme.spacing(0.5),
|
||||
...responseCellSx,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
interface HistogramResponseTimeProps {
|
||||
checks: CheckSnapshot[];
|
||||
@@ -58,11 +57,8 @@ export const HistogramResponseTime = ({
|
||||
if (!Array.isArray(checks) || checks.length === 0) return null;
|
||||
|
||||
const data =
|
||||
checks.length !== MAX_RECENT_CHECKS
|
||||
? [
|
||||
...checks,
|
||||
...Array(MAX_RECENT_CHECKS - checks.length).fill({ status: "placeholder" }),
|
||||
]
|
||||
checks.length !== 25
|
||||
? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })]
|
||||
: checks;
|
||||
|
||||
const chartHeight = typeof height === "number" ? `${height}px` : height;
|
||||
@@ -90,17 +86,17 @@ export const HistogramResponseTime = ({
|
||||
<Stack
|
||||
direction={statsPosition === "left" ? "row-reverse" : "row"}
|
||||
alignItems="center"
|
||||
width={"100%"}
|
||||
sx={{ width: "100%" }}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
width={"100%"}
|
||||
display={"grid"}
|
||||
gap={gridGap}
|
||||
height={chartHeight}
|
||||
sx={{
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
width: "100%",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: gridGap,
|
||||
alignItems: "end",
|
||||
height: chartHeight,
|
||||
}}
|
||||
>
|
||||
{data.map((check, index) => {
|
||||
@@ -112,23 +108,23 @@ export const HistogramResponseTime = ({
|
||||
: theme.palette.error.light;
|
||||
const bar = (
|
||||
<Box
|
||||
width={"100%"}
|
||||
height={"100%"}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
bgcolor={theme.palette.action.hover}
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: theme.spacing(1),
|
||||
bgcolor: theme.palette.action.hover,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width={"100%"}
|
||||
height={isPlaceholder ? 0 : heightPct}
|
||||
bgcolor={barColor}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: isPlaceholder ? 0 : heightPct,
|
||||
bgcolor: barColor,
|
||||
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}
|
||||
/>
|
||||
@@ -137,8 +133,8 @@ export const HistogramResponseTime = ({
|
||||
|
||||
return isPlaceholder ? (
|
||||
<Box
|
||||
height={"100%"}
|
||||
key={index}
|
||||
sx={{ height: "100%" }}
|
||||
>
|
||||
{bar}
|
||||
</Box>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
|
||||
import { PulseDot, Dot } from "@/Components/design-elements";
|
||||
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
@@ -29,7 +28,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems={"center"}
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<PulseDot color={getStatusColor(monitor.status, theme)} />
|
||||
<Typography
|
||||
@@ -42,12 +41,6 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||
>
|
||||
{formatUrl(monitor?.url)}
|
||||
</Typography>
|
||||
{monitor.type === "pagespeed" && monitor.strategy && (
|
||||
<>
|
||||
<Dot />
|
||||
<StrategyBadge strategy={monitor.strategy} />
|
||||
</>
|
||||
)}
|
||||
{!isSmall && (
|
||||
<>
|
||||
<Dot />
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatStatusCode,
|
||||
getStatusCodeTooltip,
|
||||
getStatusCodeValueType,
|
||||
} from "@/Utils/statusCode";
|
||||
import { ValueLabel } from "./StatusLabel";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
export type StatusCodeLabelProps = {
|
||||
statusCode: number | null | undefined;
|
||||
message?: string | null;
|
||||
fallback?: string;
|
||||
};
|
||||
|
||||
export const StatusCodeLabel = ({
|
||||
statusCode,
|
||||
message,
|
||||
fallback = "N/A",
|
||||
}: StatusCodeLabelProps) => {
|
||||
const { t } = useTranslation();
|
||||
if (!statusCode) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
const text = formatStatusCode(statusCode, t);
|
||||
const tooltip = getStatusCodeTooltip(statusCode, message, t);
|
||||
const body = (
|
||||
<ValueLabel
|
||||
value={getStatusCodeValueType(statusCode)}
|
||||
text={text}
|
||||
/>
|
||||
);
|
||||
if (!tooltip) return body;
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<span>{body}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PageSpeedStrategy } from "@/Types/Monitor";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
|
||||
interface StrategyBadgeProps {
|
||||
strategy: PageSpeedStrategy | undefined;
|
||||
variant?: "caption" | "body2";
|
||||
}
|
||||
|
||||
export const StrategyBadge = ({ strategy, variant = "body2" }: StrategyBadgeProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!strategy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography
|
||||
variant={variant}
|
||||
px={SPACING.XXL}
|
||||
py={SPACING.SM}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
bgcolor={theme.palette.secondary.light}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{t(`pages.pageSpeed.strategy.${strategy}`)}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
@@ -35,9 +35,6 @@ import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
export type Header<T> = {
|
||||
id: number | string;
|
||||
content: React.ReactNode;
|
||||
mobileLabel?: React.ReactNode;
|
||||
hideMobileLabel?: boolean;
|
||||
align?: "left" | "center" | "right" | "justify" | "inherit";
|
||||
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
|
||||
render: (row: T) => React.ReactNode;
|
||||
};
|
||||
@@ -112,24 +109,6 @@ export function DataTable<
|
||||
key={key}
|
||||
>
|
||||
{headers.map((header) => {
|
||||
if (header.hideMobileLabel) {
|
||||
return (
|
||||
<Grid2
|
||||
container
|
||||
key={header.id}
|
||||
>
|
||||
<Grid2
|
||||
size={12}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
{header.render(row)}
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid2
|
||||
container
|
||||
@@ -142,9 +121,9 @@ export function DataTable<
|
||||
>
|
||||
<Typography
|
||||
component="div"
|
||||
color={theme.palette.text.secondary}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{header.mobileLabel ?? header.content}
|
||||
{header.content}
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2
|
||||
@@ -214,7 +193,7 @@ export function DataTable<
|
||||
{headers.map((header, idx) => {
|
||||
return (
|
||||
<TableCell
|
||||
align={header.align ?? (idx === 0 ? "left" : "center")}
|
||||
align={idx === 0 ? "left" : "center"}
|
||||
key={header.id}
|
||||
>
|
||||
{header.content}
|
||||
@@ -243,7 +222,7 @@ export function DataTable<
|
||||
{headers.map((header, index) => {
|
||||
return (
|
||||
<TableCell
|
||||
align={header.align ?? (index === 0 ? "left" : "center")}
|
||||
align={index === 0 ? "left" : "center"}
|
||||
key={header.id}
|
||||
onClick={
|
||||
header.onClick ? (e) => header.onClick!(e, row) : undefined
|
||||
|
||||
@@ -25,5 +25,3 @@ export * from "./TextLink";
|
||||
export * from "./NoticeBanner";
|
||||
export * from "./OfflineBanner";
|
||||
export * from "./Avatar";
|
||||
export * from "./StatusCodeLabel";
|
||||
export * from "./StrategyBadge";
|
||||
@@ -10,7 +10,6 @@ import type { RootState } from "@/Types/state";
|
||||
export const languageNames: Record<string, string> = {
|
||||
en: "English",
|
||||
de: "Deutsch",
|
||||
ca: "Català",
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
"pt-BR": "Português (BR)",
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import React from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Slide from "@mui/material/Slide";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { useSidebar } from "@/Hooks/useSidebar";
|
||||
|
||||
interface BulkActionsBarProps {
|
||||
selectedCount: number;
|
||||
onCancel: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BulkActionsBar = ({
|
||||
selectedCount,
|
||||
onCancel,
|
||||
children,
|
||||
}: BulkActionsBarProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isOpen = selectedCount > 0;
|
||||
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const { width, collapsedWidth, transition } = useSidebar();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "fixed",
|
||||
bottom: theme.spacing(LAYOUT.XS),
|
||||
left: isSmall ? collapsedWidth : width,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
zIndex: theme.zIndex.snackbar,
|
||||
transition: transition.replace("width", "left"),
|
||||
}}
|
||||
>
|
||||
<Slide
|
||||
direction="up"
|
||||
in={isOpen}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
>
|
||||
<Paper
|
||||
elevation={6}
|
||||
sx={{
|
||||
pointerEvents: "auto",
|
||||
px: LAYOUT.MD,
|
||||
py: LAYOUT.XS,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: LAYOUT.XS,
|
||||
boxShadow: theme.shadows[8],
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={LAYOUT.XXS}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={600}
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{t("pages.common.monitors.actions.bulkSelected", {
|
||||
count: selectedCount,
|
||||
})}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onCancel}
|
||||
aria-label={t("common.buttons.cancel")}
|
||||
>
|
||||
<X size={18} />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={LAYOUT.XXS}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Slide>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,138 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Dialog, Select, RadioWithDescription } from "@/Components/inputs";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import RadioGroup from "@mui/material/RadioGroup";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import { useGet, usePatch } from "@/Hooks/UseApi";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
|
||||
interface BulkEditNotificationsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
selectedMonitors: string[];
|
||||
onComplete: (success: boolean) => void;
|
||||
}
|
||||
|
||||
export const BulkEditNotificationsModal: React.FC<BulkEditNotificationsModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
selectedMonitors,
|
||||
onComplete,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const [action, setAction] = useState<"add" | "remove" | "set">("add");
|
||||
const [notificationIds, setNotificationIds] = useState<string[]>([]);
|
||||
|
||||
// Fetch available notifications
|
||||
const { data: notifications } = useGet<Notification[]>(
|
||||
"/notifications/team",
|
||||
undefined,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
const { patch, loading: isPatching, error } = usePatch();
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setAction("add");
|
||||
setNotificationIds([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const res = await patch("/monitors/notifications", {
|
||||
monitorIds: selectedMonitors,
|
||||
notificationIds,
|
||||
action,
|
||||
});
|
||||
|
||||
onComplete(!!res);
|
||||
};
|
||||
|
||||
// Prevent submitting empty arrays unless the action is 'set'
|
||||
const isMissingSelection = action !== "set" && notificationIds.length === 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={t("pages.common.monitors.bulkEdit.title")}
|
||||
onCancel={onClose}
|
||||
onConfirm={isMissingSelection ? undefined : handleConfirm}
|
||||
loading={isPatching}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<Stack
|
||||
spacing={theme.spacing(LAYOUT.XS)}
|
||||
mt={theme.spacing(SPACING.LG)}
|
||||
>
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
<Typography variant="body1">
|
||||
{t("pages.common.monitors.bulkEdit.selectedText", {
|
||||
count: selectedMonitors.length,
|
||||
})}
|
||||
</Typography>
|
||||
|
||||
<RadioGroup
|
||||
value={action}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === "add" || val === "remove" || val === "set") {
|
||||
setAction(val);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RadioWithDescription
|
||||
value="add"
|
||||
label={t("pages.common.monitors.bulkEdit.actionAdd")}
|
||||
description={t("pages.common.monitors.bulkEdit.actionAddDesc")}
|
||||
/>
|
||||
<RadioWithDescription
|
||||
value="remove"
|
||||
label={t("pages.common.monitors.bulkEdit.actionRemove")}
|
||||
description={t("pages.common.monitors.bulkEdit.actionRemoveDesc")}
|
||||
/>
|
||||
<RadioWithDescription
|
||||
value="set"
|
||||
label={t("pages.common.monitors.bulkEdit.actionSet")}
|
||||
description={t("pages.common.monitors.bulkEdit.actionSetDesc")}
|
||||
/>
|
||||
</RadioGroup>
|
||||
|
||||
<Select
|
||||
multiple
|
||||
value={notificationIds}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (typeof val === "string") {
|
||||
setNotificationIds(val.split(","));
|
||||
} else if (Array.isArray(val)) {
|
||||
setNotificationIds(val.filter((v) => typeof v === "string"));
|
||||
}
|
||||
}}
|
||||
fieldLabel={t("pages.common.monitors.bulkEdit.selectLabel")}
|
||||
placeholder={t("pages.common.monitors.bulkEdit.selectPlaceholder")}
|
||||
>
|
||||
{notifications?.map((n) => (
|
||||
<MenuItem
|
||||
key={n.id}
|
||||
value={n.id}
|
||||
>
|
||||
{n.notificationName} ({n.type.toUpperCase()})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -3,8 +3,6 @@ import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
|
||||
import Map, { Marker, Popup } from "react-map-gl/maplibre";
|
||||
import type { MapRef } from "react-map-gl/maplibre";
|
||||
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatStatusCode } from "@/Utils/statusCode";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
interface GeoChecksMapProps {
|
||||
@@ -16,7 +14,6 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
||||
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
|
||||
const theme = useTheme();
|
||||
const isDarkMode = theme.palette.mode === "dark";
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mapPopupStyles = (
|
||||
<GlobalStyles
|
||||
@@ -177,7 +174,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
||||
>
|
||||
Status Code:
|
||||
</Typography>{" "}
|
||||
{formatStatusCode(selectedCheck.statusCode, t)}
|
||||
{selectedCheck.statusCode}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Typography
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
ResponsiveContainer,
|
||||
Text,
|
||||
} from "recharts";
|
||||
import type { TooltipProps } from "recharts";
|
||||
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import { useSelector } from "react-redux";
|
||||
@@ -20,14 +18,13 @@ import {
|
||||
tooltipDateFormatLookup,
|
||||
} from "@/Utils/TimeUtils";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
import type { GroupedCheck } from "@/Types/Check";
|
||||
import type { RootState } from "@/Types/state";
|
||||
|
||||
type XTickProps = {
|
||||
x: number;
|
||||
y: number;
|
||||
payload: { value: string };
|
||||
payload: { value: any };
|
||||
range: string;
|
||||
};
|
||||
|
||||
@@ -49,9 +46,12 @@ export const XTick = ({ x, y, payload, range }: XTickProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
|
||||
type ResponseTimeToolTipProps = {
|
||||
active?: boolean | undefined;
|
||||
payload?: any[];
|
||||
label?: string | number;
|
||||
range: string;
|
||||
theme: Theme;
|
||||
theme: any;
|
||||
uiTimezone: string;
|
||||
};
|
||||
|
||||
@@ -68,8 +68,7 @@ const ResponseTimeToolTip = ({
|
||||
if (!active) return null;
|
||||
|
||||
const format = tooltipDateFormatLookup(range);
|
||||
console.log(payload);
|
||||
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
|
||||
const responseTime = Math.floor(payload?.[0]?.payload?.avgResponseTime || 0);
|
||||
return (
|
||||
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
||||
|
||||
@@ -14,5 +14,3 @@ export * from "./charts/HistogramPageSpeedDetails";
|
||||
export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
||||
export * from "./charts/HistogramInfrastructure";
|
||||
export * from "./HeaderMonitorsSummary";
|
||||
export * from "./BulkActionsBar";
|
||||
export * from "./BulkEditNotificationsModal";
|
||||
@@ -89,9 +89,7 @@ const uiSlice = createSlice({
|
||||
},
|
||||
|
||||
setTimezone: (state, action: PayloadAction<{ timezone: string }>) => {
|
||||
if (action.payload.timezone) {
|
||||
state.timezone = action.payload.timezone;
|
||||
}
|
||||
state.timezone = action.payload.timezone;
|
||||
},
|
||||
setLanguage: (state, action: PayloadAction<string>) => {
|
||||
state.language = action.payload;
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { post } from "@/Utils/ApiClient";
|
||||
import { useToast } from "@/Hooks/UseToast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@/Utils/logger";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
interface ApiResponse {
|
||||
success: boolean;
|
||||
msg: string;
|
||||
data: Monitor[];
|
||||
}
|
||||
|
||||
interface UseBulkMonitorActionsReturn {
|
||||
selectedRows: string[];
|
||||
setSelectedRows: (rows: string[]) => void;
|
||||
handleBulkPause: () => Promise<void>;
|
||||
handleBulkResume: () => Promise<void>;
|
||||
handleCancelSelection: () => void;
|
||||
}
|
||||
|
||||
export const useBulkMonitorActions = (
|
||||
refetch: () => void,
|
||||
page?: number
|
||||
): UseBulkMonitorActionsReturn => {
|
||||
const [selectedRows, setSelectedRows] = useState<string[]>([]);
|
||||
const { toastSuccess, toastError, toastInfo } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Clear selection when page changes
|
||||
useEffect(() => {
|
||||
setSelectedRows([]);
|
||||
}, [page]);
|
||||
|
||||
const executeBulkAction = async (pause: boolean) => {
|
||||
try {
|
||||
const res = await post<ApiResponse>("/monitors/bulk/pause", {
|
||||
monitorIds: selectedRows,
|
||||
pause,
|
||||
});
|
||||
|
||||
const affectedCount = res.data?.data?.length ?? 0;
|
||||
|
||||
if (affectedCount === 0) {
|
||||
const key = pause
|
||||
? "pages.common.monitors.bulkPause.alreadyPaused"
|
||||
: "pages.common.monitors.bulkPause.alreadyRunning";
|
||||
toastInfo(t(key, { count: selectedRows.length }));
|
||||
} else {
|
||||
const key = pause
|
||||
? "pages.common.monitors.bulkPause.paused"
|
||||
: "pages.common.monitors.bulkPause.resumed";
|
||||
toastSuccess(t(key, { count: affectedCount }));
|
||||
}
|
||||
|
||||
setSelectedRows([]);
|
||||
refetch();
|
||||
} catch (err: unknown) {
|
||||
let errMsg = "An error occurred";
|
||||
|
||||
if (axios.isAxiosError(err)) {
|
||||
errMsg = err.response?.data?.msg || err.message || errMsg;
|
||||
} else if (err instanceof Error) {
|
||||
errMsg = err.message;
|
||||
}
|
||||
|
||||
logger.error("Bulk pause/resume failed", err instanceof Error ? err : undefined, {
|
||||
pause,
|
||||
});
|
||||
toastError(errMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkPause = async () => {
|
||||
await executeBulkAction(true);
|
||||
};
|
||||
|
||||
const handleBulkResume = async () => {
|
||||
await executeBulkAction(false);
|
||||
};
|
||||
|
||||
const handleCancelSelection = () => {
|
||||
setSelectedRows([]);
|
||||
};
|
||||
|
||||
return {
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
handleBulkPause,
|
||||
handleBulkResume,
|
||||
handleCancelSelection,
|
||||
};
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
maintenanceWindowSchema,
|
||||
maintenanceWindowEditSchema,
|
||||
repeatOptions,
|
||||
type MaintenanceWindowFormData,
|
||||
} from "@/Validation/maintenanceWindow";
|
||||
@@ -33,7 +32,7 @@ export const useMaintenanceWindowForm = ({
|
||||
startTime: startDate.format("HH:mm"),
|
||||
duration: data.duration ?? 0,
|
||||
durationUnit: data.durationUnit ?? "minutes",
|
||||
monitors: data.monitorIds,
|
||||
monitors: [data.monitorId],
|
||||
};
|
||||
} else {
|
||||
const now = dayjs();
|
||||
@@ -48,9 +47,6 @@ export const useMaintenanceWindowForm = ({
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
schema: data ? maintenanceWindowEditSchema : maintenanceWindowSchema,
|
||||
defaults,
|
||||
};
|
||||
return { schema: maintenanceWindowSchema, defaults };
|
||||
}, [data]);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
|
||||
import { DefaultPageSpeedStrategy } from "@/Types/Monitor";
|
||||
import type { Monitor, MonitorType } from "@/Types/Monitor";
|
||||
|
||||
interface UseMonitorFormOptions {
|
||||
@@ -89,7 +88,6 @@ export const useMonitorForm = ({
|
||||
...base,
|
||||
type: "pagespeed",
|
||||
url: data?.url || "",
|
||||
strategy: data?.strategy ?? DefaultPageSpeedStrategy,
|
||||
};
|
||||
break;
|
||||
case "hardware":
|
||||
@@ -113,15 +111,6 @@ export const useMonitorForm = ({
|
||||
ignoreTlsErrors: data?.ignoreTlsErrors || false,
|
||||
};
|
||||
break;
|
||||
case "dns":
|
||||
defaults = {
|
||||
...base,
|
||||
type: "dns",
|
||||
url: data?.url || "",
|
||||
dnsServer: data?.dnsServer || "",
|
||||
dnsRecordType: data?.dnsRecordType || "A",
|
||||
};
|
||||
break;
|
||||
default:
|
||||
defaults = {
|
||||
...base,
|
||||
|
||||
@@ -18,7 +18,6 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
|
||||
systemEmailHost: data?.systemEmailHost || "",
|
||||
systemEmailUser: data?.systemEmailUser || "",
|
||||
systemEmailAddress: data?.systemEmailAddress || "",
|
||||
systemEmailDisplayName: data?.systemEmailDisplayName || "",
|
||||
systemEmailConnectionHost: data?.systemEmailConnectionHost || "localhost",
|
||||
systemEmailTLSServername: data?.systemEmailTLSServername || "",
|
||||
systemEmailPort: data?.systemEmailPort,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
DEFAULT_STATUS_PAGE_THEME_MODE,
|
||||
} from "@/Types/StatusPage";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import { resolveTimezone } from "@/Utils/TimeUtils";
|
||||
|
||||
interface UseStatusPageFormOptions {
|
||||
data?: StatusPage | null;
|
||||
@@ -31,7 +30,7 @@ export const useStatusPageForm = ({
|
||||
const defaults: StatusPageFormData = {
|
||||
companyName: data?.companyName || "",
|
||||
url: data?.url || generateDefaultUrl(),
|
||||
timezone: resolveTimezone(data?.timezone),
|
||||
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
type: data?.type || ["uptime"],
|
||||
color: data?.color || "#13715B",
|
||||
monitors: data?.monitors || [],
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
StatusLabel,
|
||||
StatusCodeLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import { Table, Pagination, ValueLabel, StatusLabel } from "@/Components/design-elements";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { Check } from "@/Types/Check";
|
||||
@@ -33,7 +29,7 @@ export const ChecksTable = ({
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
const getHeaders = () => {
|
||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
const headers: Header<Check>[] = [
|
||||
{
|
||||
id: "monitorName",
|
||||
@@ -67,10 +63,13 @@ export const ChecksTable = ({
|
||||
id: "statusCode",
|
||||
content: t("pages.checks.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
const code = row.statusCode;
|
||||
if (!code) return "N/A";
|
||||
const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative";
|
||||
return (
|
||||
<StatusCodeLabel
|
||||
statusCode={row.statusCode}
|
||||
message={row.message}
|
||||
<ValueLabel
|
||||
value={value}
|
||||
text={String(code)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -86,7 +85,7 @@ export const ChecksTable = ({
|
||||
return headers;
|
||||
};
|
||||
|
||||
const headers = getHeaders();
|
||||
const headers = getHeaders(t, uiTimezone);
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
type MonitorType,
|
||||
type GamesMap,
|
||||
supportsGeoCheck,
|
||||
DefaultPageSpeedStrategy,
|
||||
} from "@/Types/Monitor";
|
||||
import type { Notification } from "@/Types/Notification";
|
||||
import type { MonitorFormData } from "@/Validation/monitor";
|
||||
@@ -52,9 +51,6 @@ interface GeneralSettingsConfig {
|
||||
showSecret: boolean;
|
||||
showGrpcServiceName: boolean;
|
||||
showIgnoreTls: boolean;
|
||||
showStrategy: boolean;
|
||||
showDnsServer: boolean;
|
||||
showDnsRecordType: boolean;
|
||||
}
|
||||
|
||||
const getGeneralSettingsConfig = (
|
||||
@@ -72,9 +68,6 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showStrategy: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
ping: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
||||
@@ -86,9 +79,6 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showStrategy: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
docker: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
|
||||
@@ -100,9 +90,6 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showStrategy: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
port: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
@@ -114,9 +101,6 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showStrategy: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
game: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
@@ -128,9 +112,6 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showStrategy: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
grpc: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
|
||||
@@ -142,9 +123,6 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: true,
|
||||
showIgnoreTls: true,
|
||||
showStrategy: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
pagespeed: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
@@ -156,13 +134,10 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showStrategy: true,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
hardware: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.captureUrl.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"),
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showPort: false,
|
||||
@@ -170,9 +145,6 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: true,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showStrategy: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
websocket: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
|
||||
@@ -184,23 +156,6 @@ const getGeneralSettingsConfig = (
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: true,
|
||||
showStrategy: false,
|
||||
showDnsServer: false,
|
||||
showDnsRecordType: false,
|
||||
},
|
||||
dns: {
|
||||
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
|
||||
urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"),
|
||||
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
|
||||
showUrl: true,
|
||||
showPort: false,
|
||||
showGameSelect: false,
|
||||
showSecret: false,
|
||||
showGrpcServiceName: false,
|
||||
showIgnoreTls: false,
|
||||
showStrategy: false,
|
||||
showDnsServer: true,
|
||||
showDnsRecordType: true,
|
||||
},
|
||||
};
|
||||
return configs[type] || configs.http;
|
||||
@@ -394,13 +349,6 @@ const CreateMonitorPage = () => {
|
||||
"pages.createMonitor.form.type.optionWebSocketDescription"
|
||||
)}
|
||||
/>
|
||||
<RadioWithDescription
|
||||
value="dns"
|
||||
label={t("pages.common.monitors.monitorTypes.optionDns")}
|
||||
description={t(
|
||||
"pages.createMonitor.form.type.optionDnsDescription"
|
||||
)}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -447,31 +395,6 @@ const CreateMonitorPage = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Strategy field - only for pagespeed type */}
|
||||
{generalSettingsConfig.showStrategy && (
|
||||
<Controller
|
||||
name="strategy"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value ?? DefaultPageSpeedStrategy}
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.general.option.strategy.label"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
>
|
||||
<MenuItem value="desktop">
|
||||
{t("pages.createMonitor.form.general.option.strategy.desktop")}
|
||||
</MenuItem>
|
||||
<MenuItem value="mobile">
|
||||
{t("pages.createMonitor.form.general.option.strategy.mobile")}
|
||||
</MenuItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Port field - only for port and game types */}
|
||||
{generalSettingsConfig.showPort && (
|
||||
<Controller
|
||||
@@ -572,74 +495,6 @@ const CreateMonitorPage = () => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{generalSettingsConfig.showDnsServer && (
|
||||
<Controller
|
||||
name="dnsServer"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
type="text"
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.general.option.dnsServer.label"
|
||||
)}
|
||||
placeholder={t(
|
||||
"pages.createMonitor.form.general.option.dnsServer.placeholder"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{generalSettingsConfig.showDnsRecordType && (
|
||||
<Controller
|
||||
name="dnsRecordType"
|
||||
control={control}
|
||||
defaultValue="A"
|
||||
render={({ field, fieldState }) => (
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value ?? "A"}
|
||||
fieldLabel={t(
|
||||
"pages.createMonitor.form.general.option.dnsRecordType.label"
|
||||
)}
|
||||
error={!!fieldState.error}
|
||||
>
|
||||
<MenuItem value={"A"}>
|
||||
{t("pages.createMonitor.form.general.option.dnsRecordType.value.A")}
|
||||
</MenuItem>
|
||||
<MenuItem value={"AAAA"}>
|
||||
{t(
|
||||
"pages.createMonitor.form.general.option.dnsRecordType.value.AAAA"
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem value={"CNAME"}>
|
||||
{t(
|
||||
"pages.createMonitor.form.general.option.dnsRecordType.value.CNAME"
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem value={"MX"}>
|
||||
{t(
|
||||
"pages.createMonitor.form.general.option.dnsRecordType.value.MX"
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem value={"NS"}>
|
||||
{t(
|
||||
"pages.createMonitor.form.general.option.dnsRecordType.value.NS"
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem value={"TXT"}>
|
||||
{t(
|
||||
"pages.createMonitor.form.general.option.dnsRecordType.value.TXT"
|
||||
)}
|
||||
</MenuItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name="name"
|
||||
@@ -1045,7 +900,7 @@ const CreateMonitorPage = () => {
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
color={theme.palette.text.secondary}
|
||||
color="text.secondary"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
<Trans
|
||||
|
||||
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { BaseBox, ValueLabel, StatusCodeLabel } from "@/Components/design-elements";
|
||||
import { BaseBox, ValueLabel } from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -46,7 +46,6 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
if (!incident) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
@@ -124,12 +123,7 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
<Cell>{t("pages.incidents.dialog.details.statusCode")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Cell>
|
||||
<StatusCodeLabel
|
||||
statusCode={incident.statusCode}
|
||||
message={incident.message}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell>{incident.statusCode ?? "N/A"}</Cell>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Cell>{t("pages.incidents.dialog.details.downtime")}</Cell>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Table, ValueLabel, StatusCodeLabel } from "@/Components/design-elements";
|
||||
import { Table } from "@/Components/design-elements";
|
||||
import { Pagination } from "@/Components/design-elements/Table";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import { ValueLabel } from "@/Components/design-elements";
|
||||
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
||||
import { ActionsMenu } from "@/Components/actions-menu";
|
||||
import type { Incident } from "@/Types/Incident";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
@@ -134,12 +136,13 @@ export const IncidentsTable = ({
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={
|
||||
row.resolutionType === "manual"
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.success.main
|
||||
}
|
||||
textTransform={"capitalize"}
|
||||
sx={{
|
||||
textTransform: "capitalize",
|
||||
color:
|
||||
row.resolutionType === "manual"
|
||||
? theme.palette.warning.main
|
||||
: theme.palette.success.main,
|
||||
}}
|
||||
>
|
||||
{row.resolutionType}
|
||||
</Typography>
|
||||
@@ -152,10 +155,15 @@ export const IncidentsTable = ({
|
||||
id: "statusCode",
|
||||
content: t("pages.incidents.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
const code = row.statusCode;
|
||||
if (!code) return "N/A";
|
||||
let value: ValueType = "neutral";
|
||||
if (code < 300) value = "positive";
|
||||
else if (code >= 400) value = "negative";
|
||||
return (
|
||||
<StatusCodeLabel
|
||||
statusCode={row.statusCode}
|
||||
message={row.message}
|
||||
<ValueLabel
|
||||
value={value}
|
||||
text={String(code)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
@@ -13,24 +12,6 @@ import { useNavigate } from "react-router-dom";
|
||||
import { usePost } from "@/Hooks/UseApi";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
|
||||
interface InfraMonitorsTableProps {
|
||||
monitors: Monitor[];
|
||||
refetch: () => void;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
selectedRows?: string[];
|
||||
onSelectionChange?: (selected: string[]) => void;
|
||||
}
|
||||
|
||||
export const InfraMonitorsTable = ({
|
||||
monitors,
|
||||
@@ -45,32 +26,29 @@ export const InfraMonitorsTable = ({
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
selectedRows = [],
|
||||
onSelectionChange,
|
||||
}: InfraMonitorsTableProps) => {
|
||||
}: {
|
||||
monitors: Monitor[];
|
||||
refetch: Function;
|
||||
setSelectedMonitor: Function;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const navigate = useNavigate();
|
||||
const { post } = usePost<Record<string, never>, Monitor>();
|
||||
|
||||
const selectedSet = new Set(selectedRows);
|
||||
const isAllSelected =
|
||||
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
|
||||
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
|
||||
};
|
||||
|
||||
const handleSelectRow = (id: string, checked: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
onSelectionChange(
|
||||
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
|
||||
);
|
||||
};
|
||||
|
||||
const isRowSelected = (id: string) => selectedSet.has(id);
|
||||
const {
|
||||
post,
|
||||
// loading: isPatching,
|
||||
// error: postError,
|
||||
} = usePost<any, Monitor>();
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
@@ -87,7 +65,7 @@ export const InfraMonitorsTable = ({
|
||||
setRowsPerPage(value);
|
||||
};
|
||||
|
||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||
const handleSort = (e: any, field: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (sortField === field) {
|
||||
@@ -123,6 +101,13 @@ export const InfraMonitorsTable = ({
|
||||
navigate(`/infrastructure/configure/${monitor.id}`);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: 5,
|
||||
// label: "Clone",
|
||||
// action: () => {
|
||||
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: 6,
|
||||
label:
|
||||
@@ -167,35 +152,8 @@ export const InfraMonitorsTable = ({
|
||||
</Box>
|
||||
);
|
||||
const headers: Header<Monitor>[] = [
|
||||
{
|
||||
id: "selection",
|
||||
content: (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectAll")}
|
||||
indeterminate={isSomeSelected}
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
hideMobileLabel: true,
|
||||
render: (row) => (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectMonitor", {
|
||||
name: row.name,
|
||||
})}
|
||||
checked={isRowSelected(row.id)}
|
||||
onChange={(e) => {
|
||||
handleSelectRow(row.id, e.target.checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
onClick: (e) => e?.stopPropagation(),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
align: "left",
|
||||
mobileLabel: t("common.table.headers.name"),
|
||||
content: (
|
||||
<Typography
|
||||
component="div"
|
||||
@@ -215,20 +173,18 @@ export const InfraMonitorsTable = ({
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
mobileLabel: t("common.table.headers.status"),
|
||||
content: (
|
||||
<Stack
|
||||
<Typography
|
||||
component="div"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(4)}
|
||||
direction={"row"}
|
||||
justifyContent={"center"}
|
||||
alignItems={"center"}
|
||||
onClick={(e) => handleSort(e, "status")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<Box width={theme.spacing(8)} />
|
||||
{t("common.table.headers.status")}
|
||||
{renderSortIcon(sortField === "status")}
|
||||
</Stack>
|
||||
</Typography>
|
||||
),
|
||||
render: (row) => {
|
||||
return <StatusLabel status={row.status} />;
|
||||
@@ -292,11 +248,6 @@ export const InfraMonitorsTable = ({
|
||||
onRowClick={(row) => {
|
||||
navigate(`/infrastructure/${row.id}`);
|
||||
}}
|
||||
getRowSx={(row) => ({
|
||||
backgroundColor: isRowSelected(row.id)
|
||||
? theme.palette.action.selected
|
||||
: "inherit",
|
||||
})}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
|
||||
@@ -2,13 +2,8 @@ import Stack from "@mui/material/Stack";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { MonitorBasePageWithStates } from "@/Components/design-elements";
|
||||
import { HeaderCreate } from "@/Components/common";
|
||||
import {
|
||||
ControlsFilter,
|
||||
HeaderMonitorsSummary,
|
||||
BulkActionsBar,
|
||||
} from "@/Components/monitors";
|
||||
import { TextField, Dialog, Button } from "@/Components/inputs";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
|
||||
import { TextField, Dialog } from "@/Components/inputs";
|
||||
|
||||
import { useGet, useDelete } from "@/Hooks/UseApi";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
@@ -21,7 +16,6 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { InfraMonitorsTable } from "./Components/MonitorsTable";
|
||||
import useDebounce from "@/Hooks/useDebounce";
|
||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||
|
||||
const InfrastructureMonitors = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -43,7 +37,6 @@ const InfrastructureMonitors = () => {
|
||||
const [sortField, setSortField] = useState<string>("");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||
|
||||
const isDialogOpen = Boolean(selectedMonitor);
|
||||
|
||||
const debouncedSearch = useDebounce<string>(search, 300);
|
||||
@@ -104,15 +97,6 @@ const InfrastructureMonitors = () => {
|
||||
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
|
||||
const isLoading = monitorsWithChecksLoading;
|
||||
|
||||
// Bulk actions
|
||||
const {
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
handleBulkPause,
|
||||
handleBulkResume,
|
||||
handleCancelSelection,
|
||||
} = useBulkMonitorActions(refetch, page);
|
||||
|
||||
// Check if any filters are active
|
||||
const hasActiveFilters = Boolean(selectedStatus || selectedState || search);
|
||||
|
||||
@@ -175,29 +159,6 @@ const InfrastructureMonitors = () => {
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{!isLoading && (
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedRows.length}
|
||||
onCancel={handleCancelSelection}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Play size={16} />}
|
||||
onClick={handleBulkResume}
|
||||
>
|
||||
{t("common.buttons.resume")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Pause size={16} />}
|
||||
onClick={handleBulkPause}
|
||||
>
|
||||
{t("common.buttons.pause")}
|
||||
</Button>
|
||||
</BulkActionsBar>
|
||||
)}
|
||||
|
||||
<InfraMonitorsTable
|
||||
monitors={monitorsWithChecksData?.monitors || []}
|
||||
refetch={refetch}
|
||||
@@ -219,8 +180,6 @@ const InfrastructureMonitors = () => {
|
||||
);
|
||||
setPage(0);
|
||||
}}
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { QueueData } from "@/Types/Queue";
|
||||
import { Metrics } from "@/Pages/Logs/components/Metrics";
|
||||
import { Button } from "@/Components/inputs";
|
||||
import { EmptyState } from "@/Components/design-elements";
|
||||
import { SPACING } from "@/Utils/Theme/constants";
|
||||
|
||||
export const TabQueue = () => {
|
||||
const theme = useTheme();
|
||||
@@ -20,7 +19,7 @@ export const TabQueue = () => {
|
||||
isLoading,
|
||||
// error,
|
||||
refetch,
|
||||
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 1000 });
|
||||
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 5000 });
|
||||
|
||||
const { post, loading: isFlushing } = usePost();
|
||||
|
||||
@@ -50,22 +49,32 @@ export const TabQueue = () => {
|
||||
<Stack gap={theme.spacing(1)}>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.secondary}
|
||||
color="text.secondary"
|
||||
>
|
||||
{t("pages.logs.jobQueue")}
|
||||
</Typography>
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="pages.logs.jobQueueExplainer"
|
||||
components={{
|
||||
highlight: (
|
||||
<Box
|
||||
component="span"
|
||||
bgcolor={theme.palette.rowStatus.running}
|
||||
color={theme.palette.text.primary}
|
||||
px={SPACING.SM}
|
||||
py={SPACING.XXS}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
sx={{
|
||||
backgroundColor:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(19, 113, 91, 0.18)"
|
||||
: "#ECF7F2",
|
||||
color: theme.palette.text.primary,
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 0.5,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
@@ -78,11 +87,16 @@ export const TabQueue = () => {
|
||||
<Stack gap={theme.spacing(1)}>
|
||||
<Typography
|
||||
variant="eyebrow"
|
||||
color={theme.palette.text.secondary}
|
||||
color="text.secondary"
|
||||
>
|
||||
{t("pages.logs.failedJobs")}
|
||||
</Typography>
|
||||
<Typography color={theme.palette.text.secondary}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
{t("pages.logs.failedJobsExplainer")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -30,22 +30,6 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
};
|
||||
|
||||
const headers: Header<QueueJobWithId>[] = [
|
||||
{
|
||||
id: "state",
|
||||
content: t("pages.logs.table.headers.state"),
|
||||
render: (row) => {
|
||||
if (row.lockedAt) {
|
||||
return t("common.labels.running");
|
||||
}
|
||||
if (row.monitorActive === true) {
|
||||
return t("common.labels.active");
|
||||
}
|
||||
if (row.monitorActive === false) {
|
||||
return t("common.labels.paused");
|
||||
}
|
||||
return t("common.labels.idle");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "id",
|
||||
content: t("common.table.headers.monitorId"),
|
||||
@@ -63,10 +47,7 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
id: "url",
|
||||
content: t("common.table.headers.url"),
|
||||
render: (row) => (
|
||||
<Typography
|
||||
title={row.monitorUrl ?? ""}
|
||||
sx={cellSx}
|
||||
>
|
||||
<Typography title={row.monitorUrl ?? ""} sx={cellSx}>
|
||||
{row.monitorUrl}
|
||||
</Typography>
|
||||
),
|
||||
@@ -79,15 +60,9 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
{
|
||||
id: "interval",
|
||||
content: t("common.table.headers.interval"),
|
||||
render: (row) => {
|
||||
let interval;
|
||||
if (row.monitorId.toString().includes("geo")) {
|
||||
interval = row.monitorGeoInterval ?? 0;
|
||||
} else {
|
||||
interval = row.repeat ?? 0;
|
||||
}
|
||||
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
|
||||
},
|
||||
render: (row) => (
|
||||
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "lastRun",
|
||||
@@ -95,10 +70,7 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
render: (row) => {
|
||||
const v = formatTimestamp(row.lastRunAt) ?? "-";
|
||||
return (
|
||||
<Typography
|
||||
title={v}
|
||||
sx={cellSx}
|
||||
>
|
||||
<Typography title={v} sx={cellSx}>
|
||||
{v}
|
||||
</Typography>
|
||||
);
|
||||
@@ -118,10 +90,7 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
render: (row) => {
|
||||
const v = formatTimestamp(row.lockedAt) ?? "-";
|
||||
return (
|
||||
<Typography
|
||||
title={v}
|
||||
sx={cellSx}
|
||||
>
|
||||
<Typography title={v} sx={cellSx}>
|
||||
{v}
|
||||
</Typography>
|
||||
);
|
||||
@@ -129,16 +98,16 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
|
||||
},
|
||||
];
|
||||
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
const runningBg = isDark ? "rgba(19, 113, 91, 0.18)" : "#ECF7F2";
|
||||
|
||||
return (
|
||||
<Table
|
||||
headers={headers}
|
||||
data={jobsWithId}
|
||||
getRowSx={(row) => ({
|
||||
...(row.lockedAt && {
|
||||
"& td": { backgroundColor: theme.palette.rowStatus.running },
|
||||
}),
|
||||
...(row.monitorActive === false && {
|
||||
"& td": { backgroundColor: theme.palette.rowStatus.paused },
|
||||
"& td": { backgroundColor: runningBg },
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,6 @@ import type { MaintenanceWindowFormData } from "@/Validation/maintenanceWindow";
|
||||
import { repeatOptions, durationUnitOptions } from "@/Validation/maintenanceWindow";
|
||||
import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm";
|
||||
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
|
||||
import { mutate } from "swr";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
@@ -86,6 +85,10 @@ const CreateMaintenanceWindowPage = () => {
|
||||
repeat,
|
||||
};
|
||||
|
||||
if (repeat === 0) {
|
||||
payload.expiry = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
@@ -100,9 +103,6 @@ const CreateMaintenanceWindowPage = () => {
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
await mutate(
|
||||
(key) => typeof key === "string" && key.startsWith("/maintenance-window")
|
||||
);
|
||||
navigate("/maintenance");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
StatusLabel,
|
||||
StrategyBadge,
|
||||
} from "@/Components/design-elements";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
|
||||
import { HistogramPageSpeed } from "@/Components/monitors";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
@@ -185,19 +179,7 @@ export const PageSpeedMonitorsTable = ({
|
||||
</Stack>
|
||||
),
|
||||
render: (row) => {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={LAYOUT.XS}
|
||||
>
|
||||
<Typography>{row.name}</Typography>
|
||||
<StrategyBadge
|
||||
strategy={row.strategy}
|
||||
variant="caption"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
return row.name;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,7 +3,6 @@ import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
interface DummyChartProps {
|
||||
chartType: string;
|
||||
@@ -11,7 +10,7 @@ interface DummyChartProps {
|
||||
|
||||
const generateDummyChecks = (): CheckSnapshot[] => {
|
||||
const checks: CheckSnapshot[] = [];
|
||||
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const isUp = Math.random() > 0.1;
|
||||
const responseTime = Math.floor(Math.random() * 80) + 20;
|
||||
checks.push({
|
||||
|
||||
@@ -128,8 +128,8 @@ export const SettingsPage = () => {
|
||||
timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null;
|
||||
|
||||
const handleTimezoneChange = (newValue: Timezone | null) => {
|
||||
if (!newValue?.id) return;
|
||||
dispatch(setTimezone({ timezone: newValue.id }));
|
||||
const newId = newValue?.id ?? "";
|
||||
dispatch(setTimezone({ timezone: newId }));
|
||||
};
|
||||
|
||||
const handleModeChange = (e: SelectChangeEvent<ThemeMode>) => {
|
||||
@@ -182,9 +182,6 @@ export const SettingsPage = () => {
|
||||
systemEmailRequireTLS: formValues.systemEmailRequireTLS,
|
||||
systemEmailRejectUnauthorized: formValues.systemEmailRejectUnauthorized,
|
||||
...(formValues.systemEmailUser && { systemEmailUser: formValues.systemEmailUser }),
|
||||
...(formValues.systemEmailDisplayName && {
|
||||
systemEmailDisplayName: formValues.systemEmailDisplayName,
|
||||
}),
|
||||
...(formValues.systemEmailTLSServername && {
|
||||
systemEmailTLSServername: formValues.systemEmailTLSServername,
|
||||
}),
|
||||
@@ -580,65 +577,44 @@ export const SettingsPage = () => {
|
||||
href="https://nodemailer.com/smtp/"
|
||||
target="_blank"
|
||||
/>
|
||||
{(() => {
|
||||
const address = form.watch("systemEmailAddress") || "";
|
||||
const displayName = form.watch("systemEmailDisplayName")?.trim();
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
component="pre"
|
||||
p={2}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
bgcolor={theme.palette.action.hover}
|
||||
sx={{
|
||||
fontFamily: theme.typography.fontFamilyMonospace,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<code>
|
||||
{JSON.stringify(
|
||||
{
|
||||
host: form.watch("systemEmailHost") || "",
|
||||
port: form.watch("systemEmailPort") || "",
|
||||
secure: form.watch("systemEmailSecure") ?? false,
|
||||
auth: {
|
||||
user: form.watch("systemEmailUser") || address,
|
||||
pass: "<your_password>",
|
||||
},
|
||||
name: form.watch("systemEmailConnectionHost") || "localhost",
|
||||
pool: form.watch("systemEmailPool") ?? false,
|
||||
tls: {
|
||||
rejectUnauthorized:
|
||||
form.watch("systemEmailRejectUnauthorized") ?? true,
|
||||
ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
|
||||
requireTLS: form.watch("systemEmailRequireTLS") ?? false,
|
||||
servername: form.watch("systemEmailTLSServername") || "",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
fontFamily: theme.typography.fontFamilyMonospace,
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
overflow: "auto",
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
}}
|
||||
>
|
||||
<code>
|
||||
{JSON.stringify(
|
||||
{
|
||||
host: form.watch("systemEmailHost") || "",
|
||||
port: form.watch("systemEmailPort") || "",
|
||||
secure: form.watch("systemEmailSecure") ?? false,
|
||||
auth: {
|
||||
user:
|
||||
form.watch("systemEmailUser") ||
|
||||
form.watch("systemEmailAddress") ||
|
||||
"",
|
||||
pass: "<your_password>",
|
||||
},
|
||||
name: form.watch("systemEmailConnectionHost") || "localhost",
|
||||
pool: form.watch("systemEmailPool") ?? false,
|
||||
tls: {
|
||||
rejectUnauthorized:
|
||||
form.watch("systemEmailRejectUnauthorized") ?? true,
|
||||
ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
|
||||
requireTLS: form.watch("systemEmailRequireTLS") ?? false,
|
||||
servername: form.watch("systemEmailTLSServername") || "",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</code>
|
||||
</Box>
|
||||
</Stack>
|
||||
}
|
||||
rightContent={
|
||||
@@ -704,24 +680,6 @@ 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) */}
|
||||
<Controller
|
||||
name="systemEmailUser"
|
||||
|
||||
@@ -32,7 +32,6 @@ import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"
|
||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||
import timezones from "@/Utils/timezones.json";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { mutate } from "swr";
|
||||
import axios from "axios";
|
||||
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
|
||||
import { ThemePicker } from "./Components/ThemePicker";
|
||||
@@ -50,9 +49,6 @@ interface TimezoneOption {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const buildStatusPageKey = (slug: string | null | undefined) =>
|
||||
slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
|
||||
|
||||
const CreateStatusPage = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
@@ -63,7 +59,9 @@ const CreateStatusPage = () => {
|
||||
|
||||
// Fetch existing status page data when configuring
|
||||
const { data: statusPageData, isLoading: isLoadingStatusPage } =
|
||||
useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url));
|
||||
useGet<StatusPageResponse>(
|
||||
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
|
||||
);
|
||||
|
||||
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
|
||||
const monitors = monitorsResponse ?? [];
|
||||
@@ -183,10 +181,6 @@ const CreateStatusPage = () => {
|
||||
}
|
||||
|
||||
if (result) {
|
||||
await mutate(buildStatusPageKey(data.url));
|
||||
if (!isCreate && url !== data.url) {
|
||||
await mutate(buildStatusPageKey(url));
|
||||
}
|
||||
navigate(`/status/${data.url}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,56 +8,35 @@ import { useTranslation } from "react-i18next";
|
||||
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
import { useGet } from "@/Hooks/UseApi";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import { resolveStatusPageTheme } from "@/Types/StatusPage";
|
||||
import {
|
||||
PUBLIC_STATUS_PAGE_PREFIX,
|
||||
type StatusPage,
|
||||
type StatusPageResponse,
|
||||
type StatusPageTheme,
|
||||
} from "@/Types/StatusPage";
|
||||
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
|
||||
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||
import {
|
||||
BaseStatusPage,
|
||||
type ThemeConfig,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import { BrowserFrame } from "@/Pages/StatusPage/Status/themes/BrowserFrame";
|
||||
import { refinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
|
||||
import { RefinedHeader } from "@/Pages/StatusPage/Status/themes/refined/RefinedHeader";
|
||||
import { RefinedHero } from "@/Pages/StatusPage/Status/themes/refined/RefinedHero";
|
||||
import { modernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
|
||||
import { ModernHeader } from "@/Pages/StatusPage/Status/themes/modern/ModernHeader";
|
||||
import { ModernHero } from "@/Pages/StatusPage/Status/themes/modern/ModernHero";
|
||||
import { boldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
|
||||
import { BoldHeader } from "@/Pages/StatusPage/Status/themes/bold/BoldHeader";
|
||||
import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero";
|
||||
import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
|
||||
import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader";
|
||||
import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero";
|
||||
import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls";
|
||||
import { StatusPageThemeProvider } from "./themes/StatusPageThemeProvider";
|
||||
import { RefinedStatusPage } from "./themes/refined/RefinedStatusPage";
|
||||
import { ModernStatusPage } from "./themes/modern/ModernStatusPage";
|
||||
import { BoldStatusPage } from "./themes/bold/BoldStatusPage";
|
||||
import { EditorialStatusPage } from "./themes/editorial/EditorialStatusPage";
|
||||
import { BrowserFrame } from "./themes/BrowserFrame";
|
||||
|
||||
const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
|
||||
refined: {
|
||||
createStyles: refinedStyles,
|
||||
HeaderSlot: RefinedHeader,
|
||||
HeroSlot: RefinedHero,
|
||||
},
|
||||
modern: {
|
||||
createStyles: modernStyles,
|
||||
HeaderSlot: ModernHeader,
|
||||
HeroSlot: ModernHero,
|
||||
overallStatusOptions: { iconSize: 20 },
|
||||
},
|
||||
bold: {
|
||||
createStyles: boldStyles,
|
||||
HeaderSlot: BoldHeader,
|
||||
HeroSlot: BoldHero,
|
||||
overallStatusOptions: { iconSize: 18 },
|
||||
},
|
||||
editorial: {
|
||||
createStyles: editorialStyles,
|
||||
HeaderSlot: EditorialHeader,
|
||||
HeroSlot: EditorialHero,
|
||||
overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" },
|
||||
},
|
||||
type ThemedRendererProps = {
|
||||
statusPage: StatusPage;
|
||||
monitors: (Monitor & { checks?: Monitor["recentChecks"] })[];
|
||||
};
|
||||
|
||||
const THEMED_RENDERERS: Record<
|
||||
StatusPageTheme,
|
||||
React.ComponentType<ThemedRendererProps>
|
||||
> = {
|
||||
refined: RefinedStatusPage,
|
||||
modern: ModernStatusPage,
|
||||
bold: BoldStatusPage,
|
||||
editorial: EditorialStatusPage,
|
||||
};
|
||||
|
||||
const StatusPageView = () => {
|
||||
@@ -111,12 +90,11 @@ const StatusPageView = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
|
||||
const ThemedRenderer = THEMED_RENDERERS[resolveStatusPageTheme(statusPage.theme)];
|
||||
const themedRenderer = (
|
||||
<BaseStatusPage
|
||||
<ThemedRenderer
|
||||
statusPage={statusPage}
|
||||
monitors={monitors}
|
||||
config={themeConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -126,7 +104,6 @@ const StatusPageView = () => {
|
||||
<StatusPageThemeProvider
|
||||
theme={statusPage.theme}
|
||||
themeMode={statusPage.themeMode}
|
||||
timezone={statusPage.timezone}
|
||||
paintBody
|
||||
>
|
||||
{themedRenderer}
|
||||
@@ -150,7 +127,6 @@ const StatusPageView = () => {
|
||||
<StatusPageThemeProvider
|
||||
theme={statusPage.theme}
|
||||
themeMode={statusPage.themeMode}
|
||||
timezone={statusPage.timezone}
|
||||
transparent
|
||||
>
|
||||
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type StatusPageTheme,
|
||||
type StatusPageThemeMode,
|
||||
} from "@/Types/StatusPage";
|
||||
import { resolveTimezone } from "@/Utils/TimeUtils";
|
||||
import { themeTokens, type StatusPageThemeTokens } from "./tokens";
|
||||
|
||||
type ResolvedMode = "light" | "dark";
|
||||
@@ -22,7 +21,6 @@ interface StatusPageThemeContextValue {
|
||||
theme: StatusPageTheme;
|
||||
mode: ResolvedMode;
|
||||
tokens: StatusPageThemeTokens;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
const StatusPageThemeContext = createContext<StatusPageThemeContextValue | null>(null);
|
||||
@@ -35,7 +33,6 @@ const resolveSystemMode = (): ResolvedMode => {
|
||||
interface Props {
|
||||
theme?: StatusPageTheme;
|
||||
themeMode?: StatusPageThemeMode;
|
||||
timezone?: string;
|
||||
/**
|
||||
* When true, paint the document body with the theme background so it
|
||||
* covers beyond the provider's wrapper (useful on the public route
|
||||
@@ -57,14 +54,12 @@ interface Props {
|
||||
export const StatusPageThemeProvider = ({
|
||||
theme,
|
||||
themeMode,
|
||||
timezone,
|
||||
paintBody = false,
|
||||
transparent = false,
|
||||
children,
|
||||
}: Props) => {
|
||||
const resolvedTheme = resolveStatusPageTheme(theme);
|
||||
const resolvedThemeMode = resolveStatusPageThemeMode(themeMode);
|
||||
const resolvedTimezone = resolveTimezone(timezone);
|
||||
|
||||
const [systemMode, setSystemMode] = useState<ResolvedMode>(resolveSystemMode);
|
||||
|
||||
@@ -112,13 +107,8 @@ export const StatusPageThemeProvider = ({
|
||||
const tokens = themeTokens[resolvedTheme][resolvedMode];
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
theme: resolvedTheme,
|
||||
mode: resolvedMode,
|
||||
tokens,
|
||||
timezone: resolvedTimezone,
|
||||
}),
|
||||
[resolvedTheme, resolvedMode, tokens, resolvedTimezone]
|
||||
() => ({ theme: resolvedTheme, mode: resolvedMode, tokens }),
|
||||
[resolvedTheme, resolvedMode, tokens]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -146,7 +136,6 @@ export const useStatusPageTheme = (): StatusPageThemeContextValue => {
|
||||
theme: DEFAULT_STATUS_PAGE_THEME,
|
||||
mode: resolveSystemMode(),
|
||||
tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()],
|
||||
timezone: resolveTimezone(),
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
|
||||
|
||||
export const BoldHeader = ({ statusPage, logoSrc, styles }: SlotProps<BoldStyles>) => (
|
||||
<Box sx={styles.brand}>
|
||||
{logoSrc ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
sx={styles.logoImg}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={styles.logoConic}>{monoFirstChar(statusPage.companyName)}</Box>
|
||||
)}
|
||||
{statusPage.companyName}
|
||||
</Box>
|
||||
);
|
||||
@@ -1,32 +0,0 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
|
||||
|
||||
export const BoldHero = ({ overall, monitorCount, styles }: SlotProps<BoldStyles>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box sx={styles.hero}>
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.heroTitle}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.heroCheck(overall.tone)}
|
||||
>
|
||||
{overall.icon}
|
||||
</Box>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.heroSub}
|
||||
>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitorCount,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { StatusPage } from "@/Types/StatusPage";
|
||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
|
||||
import { ThemedHistogram } from "../shared/ThemedHistogram";
|
||||
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
|
||||
import {
|
||||
monitorBadgeTone,
|
||||
monoFirstChar,
|
||||
resolveOverallStatus,
|
||||
statusBadgeKey,
|
||||
} from "../shared/overallStatus";
|
||||
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||
import { boldStyles } from "./styles";
|
||||
|
||||
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
|
||||
|
||||
interface Props {
|
||||
statusPage: StatusPage;
|
||||
monitors: StatusPageMonitor[];
|
||||
}
|
||||
|
||||
export const BoldStatusPage = ({ statusPage, monitors }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { tokens, mode } = useStatusPageTheme();
|
||||
const styles = useMemo(() => boldStyles(tokens, mode === "dark"), [tokens, mode]);
|
||||
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
|
||||
|
||||
const overall = resolveOverallStatus(monitors, t, { iconSize: 18 });
|
||||
const logoSrc = statusPage.logo?.data
|
||||
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box sx={styles.page}>
|
||||
<Box
|
||||
component="header"
|
||||
sx={styles.top}
|
||||
>
|
||||
<Box sx={styles.brand}>
|
||||
{logoSrc ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
sx={styles.logoImg}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={styles.logoConic}>{monoFirstChar(statusPage.companyName)}</Box>
|
||||
)}
|
||||
{statusPage.companyName}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={styles.hero}>
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.heroTitle}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.heroCheck(overall.tone)}
|
||||
>
|
||||
{overall.icon}
|
||||
</Box>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.heroSub}
|
||||
>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitors.length,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{statusPage.showCharts && (
|
||||
<Box sx={styles.chartSwitchWrap}>
|
||||
<Box
|
||||
sx={styles.chartSwitch}
|
||||
role="radiogroup"
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "heatmap"}
|
||||
onClick={() => setChartMode("heatmap")}
|
||||
sx={styles.chartSwitchButton(chartMode === "heatmap")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "histogram"}
|
||||
onClick={() => setChartMode("histogram")}
|
||||
sx={styles.chartSwitchButton(chartMode === "histogram")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
component="ul"
|
||||
sx={styles.monitorList}
|
||||
>
|
||||
{monitors.map((monitor) => {
|
||||
const isHardware = monitor.type === "hardware";
|
||||
const showInfra = isHardware && statusPage.showInfrastructure !== false;
|
||||
const showChart = !isHardware && statusPage.showCharts !== false;
|
||||
const badgeTone = monitorBadgeTone(monitor.status);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
key={monitor.id}
|
||||
sx={styles.card}
|
||||
>
|
||||
<Box sx={styles.cardRow}>
|
||||
<Box sx={styles.cardLeft}>
|
||||
<Box sx={styles.monitorName}>{monitor.name}</Box>
|
||||
<Box sx={styles.monitorMeta}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={isHardware ? styles.pillHardware : styles.pill}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</Box>
|
||||
{monitor.url && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.monitorUrl}
|
||||
title={monitor.url}
|
||||
>
|
||||
{monitor.url}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.badge(badgeTone)}
|
||||
>
|
||||
{t(statusBadgeKey[monitor.status])}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showInfra && (
|
||||
<ThemedInfrastructure
|
||||
monitor={monitor}
|
||||
sxApi={{
|
||||
containerSx: styles.infra,
|
||||
emptySx: styles.infraEmpty,
|
||||
gaugeSx: styles.gauge,
|
||||
gaugeLabelSx: styles.gaugeLabel,
|
||||
gaugeValueSx: styles.gaugeValue,
|
||||
gaugeBarSx: styles.gaugeBar,
|
||||
gaugeFillSx: styles.gaugeFill,
|
||||
gaugeSubSx: styles.gaugeSub,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showChart &&
|
||||
(chartMode === "heatmap" ? (
|
||||
<ThemedHeatmap
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.heatmap}
|
||||
cellSx={styles.heatmapCell}
|
||||
/>
|
||||
) : (
|
||||
<ThemedHistogram
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.histogram}
|
||||
barSx={styles.bar}
|
||||
statsSx={styles.chartStats}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={styles.footer}
|
||||
>
|
||||
{t("pages.statusPages.footer.poweredBy")}{" "}
|
||||
<a
|
||||
href="https://checkmate.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Checkmate
|
||||
</a>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||
export type BoldBarKind = "up" | "down" | "empty";
|
||||
@@ -298,7 +297,7 @@ export const boldStyles = (
|
||||
heatmap: {
|
||||
padding: "0 28px 22px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: "3px",
|
||||
height: 48,
|
||||
},
|
||||
@@ -314,7 +313,7 @@ export const boldStyles = (
|
||||
histogram: {
|
||||
padding: "0 28px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: "3px",
|
||||
alignItems: "flex-end",
|
||||
height: 48,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
|
||||
|
||||
export const EditorialHeader = ({
|
||||
statusPage,
|
||||
logoSrc,
|
||||
styles,
|
||||
}: SlotProps<EditorialStyles>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box sx={styles.brandWrap}>
|
||||
{logoSrc && (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
sx={styles.logoImg}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
component="h2"
|
||||
sx={styles.brandEyebrow}
|
||||
>
|
||||
{statusPage.companyName}
|
||||
</Box>
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.brandTitle}
|
||||
>
|
||||
{t("pages.statusPages.editorial.reportTitle")}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
|
||||
|
||||
export const EditorialHero = ({
|
||||
overall,
|
||||
monitorCount,
|
||||
styles,
|
||||
}: SlotProps<EditorialStyles>) => {
|
||||
const { t } = useTranslation();
|
||||
const todayLabel = useMemo(
|
||||
() =>
|
||||
new Date().toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.statusLine}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.statusDot(overall.tone)}
|
||||
/>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.dateline}
|
||||
>
|
||||
{todayLabel} ·{" "}
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitorCount,
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { StatusPage } from "@/Types/StatusPage";
|
||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
|
||||
import { ThemedHistogram } from "../shared/ThemedHistogram";
|
||||
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
|
||||
import {
|
||||
monitorBadgeTone,
|
||||
resolveOverallStatus,
|
||||
statusBadgeKey,
|
||||
} from "../shared/overallStatus";
|
||||
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||
import { editorialStyles } from "./styles";
|
||||
|
||||
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
|
||||
|
||||
interface Props {
|
||||
statusPage: StatusPage;
|
||||
monitors: StatusPageMonitor[];
|
||||
}
|
||||
|
||||
export const EditorialStatusPage = ({ statusPage, monitors }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { tokens } = useStatusPageTheme();
|
||||
const styles = useMemo(() => editorialStyles(tokens), [tokens]);
|
||||
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
|
||||
|
||||
const todayLabel = useMemo(
|
||||
() =>
|
||||
new Date().toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const overall = resolveOverallStatus(monitors, t, {
|
||||
allUpKey: "pages.statusPages.editorial.allUp",
|
||||
});
|
||||
const logoSrc = statusPage.logo?.data
|
||||
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box sx={styles.page}>
|
||||
<Box
|
||||
component="header"
|
||||
sx={styles.top}
|
||||
>
|
||||
<Box sx={styles.brandWrap}>
|
||||
{logoSrc && (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
sx={styles.logoImg}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
component="h2"
|
||||
sx={styles.brandEyebrow}
|
||||
>
|
||||
{statusPage.companyName}
|
||||
</Box>
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.brandTitle}
|
||||
>
|
||||
{t("pages.statusPages.editorial.reportTitle")}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.statusLine}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.statusDot(overall.tone)}
|
||||
/>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.dateline}
|
||||
>
|
||||
{todayLabel} ·{" "}
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitors.length,
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{statusPage.showCharts && (
|
||||
<Box sx={styles.chartSwitchWrap}>
|
||||
<Box
|
||||
sx={styles.chartSwitch}
|
||||
role="radiogroup"
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "heatmap"}
|
||||
onClick={() => setChartMode("heatmap")}
|
||||
sx={styles.chartSwitchButton(chartMode === "heatmap")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "histogram"}
|
||||
onClick={() => setChartMode("histogram")}
|
||||
sx={styles.chartSwitchButton(chartMode === "histogram")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
component="ul"
|
||||
sx={styles.monitorList}
|
||||
>
|
||||
{monitors.map((monitor) => {
|
||||
const isHardware = monitor.type === "hardware";
|
||||
const showInfra = isHardware && statusPage.showInfrastructure !== false;
|
||||
const showChart = !isHardware && statusPage.showCharts !== false;
|
||||
const badgeTone = monitorBadgeTone(monitor.status);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
key={monitor.id}
|
||||
sx={styles.card}
|
||||
>
|
||||
<Box sx={styles.cardRow}>
|
||||
<Box sx={styles.cardLeft}>
|
||||
<Box sx={styles.monitorName}>{monitor.name}</Box>
|
||||
<Box sx={styles.monitorMeta}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={isHardware ? styles.pillHardware : styles.pill}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</Box>
|
||||
{monitor.url && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.monitorUrl}
|
||||
title={monitor.url}
|
||||
>
|
||||
{monitor.url}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.badge(badgeTone)}
|
||||
>
|
||||
{t(statusBadgeKey[monitor.status])}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showInfra && (
|
||||
<ThemedInfrastructure
|
||||
monitor={monitor}
|
||||
sxApi={{
|
||||
containerSx: styles.infra,
|
||||
emptySx: styles.infraEmpty,
|
||||
gaugeSx: styles.gauge,
|
||||
gaugeLabelSx: styles.gaugeLabel,
|
||||
gaugeValueSx: styles.gaugeValue,
|
||||
gaugeBarSx: styles.gaugeBar,
|
||||
gaugeFillSx: styles.gaugeFill,
|
||||
gaugeSubSx: styles.gaugeSub,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showChart &&
|
||||
(chartMode === "heatmap" ? (
|
||||
<ThemedHeatmap
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.heatmap}
|
||||
cellSx={styles.heatmapCell}
|
||||
/>
|
||||
) : (
|
||||
<ThemedHistogram
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.histogram}
|
||||
barSx={styles.bar}
|
||||
statsSx={styles.chartStats}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={styles.footer}
|
||||
>
|
||||
{t("pages.statusPages.footer.poweredBy")}{" "}
|
||||
<a
|
||||
href="https://checkmate.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Checkmate
|
||||
</a>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
MONO_STACK,
|
||||
SERIF_STACK,
|
||||
} from "../shared/fontStacks";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||
export type EditorialBarKind = "up" | "down" | "empty";
|
||||
@@ -51,10 +50,7 @@ export interface EditorialStyles {
|
||||
footer: SxProps<Theme>;
|
||||
}
|
||||
|
||||
export const editorialStyles = (
|
||||
tokens: StatusPageThemeTokens,
|
||||
_isDark: boolean
|
||||
): EditorialStyles => {
|
||||
export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles => {
|
||||
const heatCellBg: Record<EditorialHeatCell, string> = {
|
||||
fast: tokens.up,
|
||||
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
|
||||
@@ -249,7 +245,7 @@ export const editorialStyles = (
|
||||
heatmap: {
|
||||
mt: "16px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: "2px",
|
||||
height: 40,
|
||||
},
|
||||
@@ -261,7 +257,7 @@ export const editorialStyles = (
|
||||
histogram: {
|
||||
mt: "16px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: "2px",
|
||||
alignItems: "flex-end",
|
||||
height: 40,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
|
||||
|
||||
export const ModernHeader = ({
|
||||
statusPage,
|
||||
logoSrc,
|
||||
styles,
|
||||
}: SlotProps<ModernStyles>) => (
|
||||
<Box sx={styles.brand}>
|
||||
{logoSrc ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
sx={styles.logoImg}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={styles.logoGrad}>{monoFirstChar(statusPage.companyName)}</Box>
|
||||
)}
|
||||
{statusPage.companyName}
|
||||
</Box>
|
||||
);
|
||||
@@ -1,34 +0,0 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
|
||||
|
||||
export const ModernHero = ({
|
||||
overall,
|
||||
monitorCount,
|
||||
styles,
|
||||
}: SlotProps<ModernStyles>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box sx={styles.hero}>
|
||||
<Box sx={styles.pulse(overall.tone)} />
|
||||
<Box sx={styles.statusCopy}>
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.heroTitle}
|
||||
>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.heroSub}
|
||||
>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitorCount,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+43
-88
@@ -2,125 +2,80 @@ import { useMemo, useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { StatusPage } from "@/Types/StatusPage";
|
||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
|
||||
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
|
||||
import { ThemedHistogram } from "../shared/ThemedHistogram";
|
||||
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
|
||||
import {
|
||||
ThemedHeatmap,
|
||||
type HeatCellKind,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
|
||||
import {
|
||||
ThemedHistogram,
|
||||
type BarKind,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
|
||||
import {
|
||||
ThemedInfrastructure,
|
||||
type GaugeFillLevel,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure";
|
||||
import {
|
||||
type OverallStatus,
|
||||
type OverallTone,
|
||||
monitorBadgeTone,
|
||||
monoFirstChar,
|
||||
resolveOverallStatus,
|
||||
statusBadgeKey,
|
||||
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
|
||||
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||
} from "../shared/overallStatus";
|
||||
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||
import { modernStyles } from "./styles";
|
||||
|
||||
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
|
||||
|
||||
export interface BaseStyles {
|
||||
page: SxProps<Theme>;
|
||||
top: SxProps<Theme>;
|
||||
chartSwitchWrap: SxProps<Theme>;
|
||||
chartSwitch: SxProps<Theme>;
|
||||
chartSwitchButton: (active: boolean) => SxProps<Theme>;
|
||||
monitorList: SxProps<Theme>;
|
||||
card: SxProps<Theme>;
|
||||
cardRow: SxProps<Theme>;
|
||||
cardLeft: SxProps<Theme>;
|
||||
monitorName: SxProps<Theme>;
|
||||
monitorMeta: SxProps<Theme>;
|
||||
pill: SxProps<Theme>;
|
||||
pillHardware: SxProps<Theme>;
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
gauge: SxProps<Theme>;
|
||||
gaugeLabel: SxProps<Theme>;
|
||||
gaugeValue: SxProps<Theme>;
|
||||
gaugeBar: SxProps<Theme>;
|
||||
gaugeFill: (level: GaugeFillLevel, widthPct: number) => SxProps<Theme>;
|
||||
gaugeSub: SxProps<Theme>;
|
||||
footer: SxProps<Theme>;
|
||||
}
|
||||
|
||||
export interface SlotProps<S extends BaseStyles = BaseStyles> {
|
||||
statusPage: StatusPage;
|
||||
logoSrc: string | null;
|
||||
overall: OverallStatus;
|
||||
monitorCount: number;
|
||||
styles: S;
|
||||
}
|
||||
|
||||
export interface ThemeConfig<S extends BaseStyles = BaseStyles> {
|
||||
createStyles: (tokens: StatusPageThemeTokens, isDark: boolean) => S;
|
||||
HeaderSlot: React.ComponentType<SlotProps<S>>;
|
||||
HeroSlot: React.ComponentType<SlotProps<S>>;
|
||||
overallStatusOptions?: { iconSize?: number; allUpKey?: string };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
statusPage: StatusPage;
|
||||
monitors: StatusPageMonitor[];
|
||||
config: ThemeConfig<any>;
|
||||
}
|
||||
|
||||
export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
|
||||
export const ModernStatusPage = ({ statusPage, monitors }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { tokens, mode } = useStatusPageTheme();
|
||||
const styles = useMemo(
|
||||
() => config.createStyles(tokens, mode === "dark"),
|
||||
[config, tokens, mode]
|
||||
);
|
||||
const styles = useMemo(() => modernStyles(tokens, mode === "dark"), [tokens, mode]);
|
||||
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
|
||||
|
||||
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
|
||||
const overall = resolveOverallStatus(monitors, t, { iconSize: 20 });
|
||||
const logoSrc = statusPage.logo?.data
|
||||
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
|
||||
: null;
|
||||
|
||||
const { HeaderSlot, HeroSlot } = config;
|
||||
|
||||
return (
|
||||
<Box sx={styles.page}>
|
||||
<Box
|
||||
component="header"
|
||||
sx={styles.top}
|
||||
>
|
||||
<HeaderSlot
|
||||
statusPage={statusPage}
|
||||
logoSrc={logoSrc}
|
||||
overall={overall}
|
||||
monitorCount={monitors.length}
|
||||
styles={styles}
|
||||
/>
|
||||
<Box sx={styles.brand}>
|
||||
{logoSrc ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
sx={styles.logoImg}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={styles.logoGrad}>{monoFirstChar(statusPage.companyName)}</Box>
|
||||
)}
|
||||
{statusPage.companyName}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<HeroSlot
|
||||
statusPage={statusPage}
|
||||
logoSrc={logoSrc}
|
||||
overall={overall}
|
||||
monitorCount={monitors.length}
|
||||
styles={styles}
|
||||
/>
|
||||
<Box sx={styles.hero}>
|
||||
<Box sx={styles.pulse(overall.tone)} />
|
||||
<Box sx={styles.statusCopy}>
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.heroTitle}
|
||||
>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.heroSub}
|
||||
>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitors.length,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
|
||||
</Box>
|
||||
|
||||
{statusPage.showCharts && (
|
||||
<Box sx={styles.chartSwitchWrap}>
|
||||
@@ -3,7 +3,6 @@ import { keyframes } from "@mui/system";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||
export type ModernBarKind = "up" | "down" | "empty";
|
||||
@@ -321,7 +320,7 @@ export const modernStyles = (
|
||||
heatmap: {
|
||||
padding: "0 24px 20px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: "3px",
|
||||
height: 46,
|
||||
},
|
||||
@@ -336,7 +335,7 @@ export const modernStyles = (
|
||||
histogram: {
|
||||
padding: "0 24px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: "3px",
|
||||
alignItems: "flex-end",
|
||||
height: 46,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
|
||||
|
||||
export const RefinedHeader = ({
|
||||
statusPage,
|
||||
logoSrc,
|
||||
styles,
|
||||
}: SlotProps<RefinedStyles>) => (
|
||||
<Box sx={styles.brand}>
|
||||
{logoSrc ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
sx={styles.logoImg}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={styles.logoMono}>{monoFirstChar(statusPage.companyName)}</Box>
|
||||
)}
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.company}
|
||||
>
|
||||
{statusPage.companyName}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,34 +0,0 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
|
||||
import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
|
||||
|
||||
export const RefinedHero = ({
|
||||
overall,
|
||||
monitorCount,
|
||||
styles,
|
||||
}: SlotProps<RefinedStyles>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box sx={styles.hero}>
|
||||
<Box sx={styles.statusDot(overall.tone)} />
|
||||
<Box sx={styles.statusCopy}>
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.heroTitle}
|
||||
>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.heroSub}
|
||||
>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitorCount,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { StatusPage } from "@/Types/StatusPage";
|
||||
import { getMonitorTypeLabel } from "@/Types/StatusPage";
|
||||
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
|
||||
import { ThemedHistogram } from "../shared/ThemedHistogram";
|
||||
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
|
||||
import {
|
||||
monitorBadgeTone,
|
||||
monoFirstChar,
|
||||
resolveOverallStatus,
|
||||
statusBadgeKey,
|
||||
} from "../shared/overallStatus";
|
||||
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||
import { refinedStyles } from "./styles";
|
||||
|
||||
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
|
||||
|
||||
interface Props {
|
||||
statusPage: StatusPage;
|
||||
monitors: StatusPageMonitor[];
|
||||
}
|
||||
|
||||
export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { tokens } = useStatusPageTheme();
|
||||
const styles = useMemo(() => refinedStyles(tokens), [tokens]);
|
||||
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
|
||||
|
||||
const overall = resolveOverallStatus(monitors, t);
|
||||
const logoSrc = statusPage.logo?.data
|
||||
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box sx={styles.page}>
|
||||
<Box
|
||||
component="header"
|
||||
sx={styles.top}
|
||||
>
|
||||
<Box sx={styles.brand}>
|
||||
{logoSrc ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoSrc}
|
||||
alt={statusPage.companyName}
|
||||
sx={styles.logoImg}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={styles.logoMono}>{monoFirstChar(statusPage.companyName)}</Box>
|
||||
)}
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.company}
|
||||
>
|
||||
{statusPage.companyName}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={styles.hero}>
|
||||
<Box sx={styles.statusDot(overall.tone)} />
|
||||
<Box sx={styles.statusCopy}>
|
||||
<Box
|
||||
component="h1"
|
||||
sx={styles.heroTitle}
|
||||
>
|
||||
{overall.message}
|
||||
</Box>
|
||||
<Box
|
||||
component="p"
|
||||
sx={styles.heroSub}
|
||||
>
|
||||
{t("pages.statusPages.statusBar.monitoringSummary", {
|
||||
count: monitors.length,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
|
||||
</Box>
|
||||
|
||||
{statusPage.showCharts && (
|
||||
<Box sx={styles.chartSwitchWrap}>
|
||||
<Box
|
||||
sx={styles.chartSwitch}
|
||||
role="radiogroup"
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "heatmap"}
|
||||
onClick={() => setChartMode("heatmap")}
|
||||
sx={styles.chartSwitchButton(chartMode === "heatmap")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chartMode === "histogram"}
|
||||
onClick={() => setChartMode("histogram")}
|
||||
sx={styles.chartSwitchButton(chartMode === "histogram")}
|
||||
>
|
||||
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
component="ul"
|
||||
sx={styles.monitorList}
|
||||
>
|
||||
{monitors.map((monitor) => {
|
||||
const isHardware = monitor.type === "hardware";
|
||||
const showInfra = isHardware && statusPage.showInfrastructure !== false;
|
||||
const showChart = !isHardware && statusPage.showCharts !== false;
|
||||
const badgeTone = monitorBadgeTone(monitor.status);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
key={monitor.id}
|
||||
sx={styles.card}
|
||||
>
|
||||
<Box sx={styles.cardRow}>
|
||||
<Box sx={styles.cardLeft}>
|
||||
<Box sx={styles.monitorName}>{monitor.name}</Box>
|
||||
<Box sx={styles.monitorMeta}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={isHardware ? styles.pillHardware : styles.pill}
|
||||
>
|
||||
{getMonitorTypeLabel(monitor.type, t)}
|
||||
</Box>
|
||||
{monitor.url && (
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.monitorUrl}
|
||||
title={monitor.url}
|
||||
>
|
||||
{monitor.url}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={styles.badge(badgeTone)}
|
||||
>
|
||||
{t(statusBadgeKey[monitor.status])}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showInfra && (
|
||||
<ThemedInfrastructure
|
||||
monitor={monitor}
|
||||
sxApi={{
|
||||
containerSx: styles.infra,
|
||||
emptySx: styles.infraEmpty,
|
||||
gaugeSx: styles.gauge,
|
||||
gaugeLabelSx: styles.gaugeLabel,
|
||||
gaugeValueSx: styles.gaugeValue,
|
||||
gaugeBarSx: styles.gaugeBar,
|
||||
gaugeFillSx: styles.gaugeFill,
|
||||
gaugeSubSx: styles.gaugeSub,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showChart &&
|
||||
(chartMode === "heatmap" ? (
|
||||
<ThemedHeatmap
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.heatmap}
|
||||
cellSx={styles.heatmapCell}
|
||||
/>
|
||||
) : (
|
||||
<ThemedHistogram
|
||||
checks={monitor.recentChecks ?? []}
|
||||
containerSx={styles.histogram}
|
||||
barSx={styles.bar}
|
||||
statsSx={styles.chartStats}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={styles.footer}
|
||||
>
|
||||
{t("pages.statusPages.footer.poweredBy")}{" "}
|
||||
<a
|
||||
href="https://checkmate.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Checkmate
|
||||
</a>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
||||
export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
|
||||
export type RefinedBarKind = "up" | "down" | "empty";
|
||||
export type RefinedGaugeFill = "ok" | "warm" | "hot";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
|
||||
export interface RefinedStyles {
|
||||
page: SxProps<Theme>;
|
||||
@@ -63,10 +62,7 @@ const pillBase = {
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
export const refinedStyles = (
|
||||
tokens: StatusPageThemeTokens,
|
||||
_isDark: boolean
|
||||
): RefinedStyles => {
|
||||
export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => {
|
||||
const heatCellBg: Record<RefinedHeatCell, string> = {
|
||||
fast: tokens.up,
|
||||
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
|
||||
@@ -263,7 +259,7 @@ export const refinedStyles = (
|
||||
heatmap: {
|
||||
padding: "0 20px 16px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: "3px",
|
||||
height: 42,
|
||||
},
|
||||
@@ -278,7 +274,7 @@ export const refinedStyles = (
|
||||
histogram: {
|
||||
padding: "0 20px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: "3px",
|
||||
alignItems: "flex-end",
|
||||
height: 42,
|
||||
|
||||
@@ -4,12 +4,12 @@ import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/store";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||
|
||||
const CELLS = MAX_RECENT_CHECKS;
|
||||
const CELLS = 25;
|
||||
|
||||
export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty";
|
||||
|
||||
@@ -29,7 +29,7 @@ const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
|
||||
|
||||
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { timezone } = useStatusPageTheme();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
const source = checks.slice(-CELLS);
|
||||
const padded: (CheckSnapshot | null)[] = [
|
||||
@@ -67,7 +67,11 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
|
||||
variant="caption"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
|
||||
{formatDateWithTz(
|
||||
check.createdAt,
|
||||
"ddd, MMMM D, YYYY, HH:mm A",
|
||||
uiTimezone
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -5,11 +5,12 @@ import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/store";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
import { useStatusPageTheme } from "../StatusPageThemeProvider";
|
||||
const CELLS = MAX_RECENT_CHECKS;
|
||||
|
||||
const CELLS = 25;
|
||||
const MIN_HEIGHT_PCT = 6;
|
||||
|
||||
export type BarKind = "up" | "down" | "empty";
|
||||
@@ -33,7 +34,7 @@ export const ThemedHistogram = ({
|
||||
statsGap = 1,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { timezone } = useStatusPageTheme();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
const { padded, max, avg, peak } = useMemo(() => {
|
||||
const source = checks.slice(-CELLS);
|
||||
@@ -82,7 +83,7 @@ export const ThemedHistogram = ({
|
||||
{formatDateWithTz(
|
||||
check.createdAt,
|
||||
"ddd, MMMM D, YYYY, HH:mm A",
|
||||
timezone
|
||||
uiTimezone
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
StatusLabel,
|
||||
StatusCodeLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { Header } from "@/Components/design-elements";
|
||||
import type { Check } from "@/Types/Check";
|
||||
@@ -14,25 +9,7 @@ import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
export const ChecksTable = ({
|
||||
checks,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
checks: Check[];
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
const headers: Header<Check>[] = [
|
||||
{
|
||||
id: "status",
|
||||
@@ -59,15 +36,32 @@ export const ChecksTable = ({
|
||||
id: "statusCode",
|
||||
content: t("pages.checks.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
return (
|
||||
<StatusCodeLabel
|
||||
statusCode={row.statusCode}
|
||||
message={row.message}
|
||||
/>
|
||||
);
|
||||
return row.statusCode || "N/A";
|
||||
},
|
||||
},
|
||||
];
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const ChecksTable = ({
|
||||
checks,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
checks: Check[];
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const headers = getHeaders(t, uiTimezone);
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
StatusLabel,
|
||||
StatusCodeLabel,
|
||||
} from "@/Components/design-elements";
|
||||
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { Header } from "@/Components/design-elements";
|
||||
import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
||||
@@ -13,24 +8,7 @@ import type { RootState } from "@/Types/state";
|
||||
import { useSelector } from "react-redux";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
|
||||
export const GeoChecksTable = ({
|
||||
geoChecks,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
geoChecks: FlatGeoCheck[];
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
const headers: Header<FlatGeoCheck>[] = [
|
||||
{
|
||||
id: "status",
|
||||
@@ -51,7 +29,7 @@ export const GeoChecksTable = ({
|
||||
id: "statusCode",
|
||||
content: t("pages.checks.table.headers.statusCode"),
|
||||
render: (row) => {
|
||||
return <StatusCodeLabel statusCode={row.statusCode} />;
|
||||
return row.statusCode || "N/A";
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -73,6 +51,27 @@ export const GeoChecksTable = ({
|
||||
},
|
||||
},
|
||||
];
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const GeoChecksTable = ({
|
||||
geoChecks,
|
||||
count,
|
||||
page,
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
}: {
|
||||
geoChecks: FlatGeoCheck[];
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const headers = getHeaders(t, uiTimezone);
|
||||
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { usePost } from "@/Hooks/UseApi";
|
||||
import { useSelector } from "react-redux";
|
||||
@@ -21,25 +20,6 @@ import { useSelector } from "react-redux";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import type { ActionMenuItem } from "@/Components/actions-menu";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { Checkbox } from "@/Components/inputs";
|
||||
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
interface MonitorTableProps {
|
||||
monitors: Monitor[];
|
||||
refetch: () => void;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
selectedRows?: string[];
|
||||
onSelectionChange?: (selected: string[]) => void;
|
||||
}
|
||||
|
||||
export const MonitorTable = ({
|
||||
monitors,
|
||||
@@ -54,35 +34,31 @@ export const MonitorTable = ({
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
selectedRows = [],
|
||||
onSelectionChange,
|
||||
}: MonitorTableProps) => {
|
||||
}: {
|
||||
monitors: Monitor[];
|
||||
refetch: Function;
|
||||
setSelectedMonitor: (monitor: Monitor | null) => void;
|
||||
sortField: string;
|
||||
setSortField: (field: string) => void;
|
||||
sortOrder: "asc" | "desc";
|
||||
setSortOrder: (order: "asc" | "desc") => void;
|
||||
count: number;
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const navigate = useNavigate();
|
||||
const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram");
|
||||
const { post } = usePost<Record<string, never>, Monitor>();
|
||||
const {
|
||||
post,
|
||||
// loading: isPosting,
|
||||
// error: postError,
|
||||
} = usePost<any, Monitor>();
|
||||
|
||||
const selectedSet = new Set(selectedRows);
|
||||
const isAllSelected =
|
||||
monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
|
||||
const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
|
||||
};
|
||||
|
||||
const handleSelectRow = (id: string, checked: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
onSelectionChange(
|
||||
checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
|
||||
);
|
||||
};
|
||||
|
||||
const isRowSelected = (id: string) => selectedSet.has(id);
|
||||
|
||||
const handleSort = (e: React.MouseEvent, field: string) => {
|
||||
const handleSort = (e: any, field: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (sortField === field) {
|
||||
@@ -134,6 +110,13 @@ export const MonitorTable = ({
|
||||
navigate(`/uptime/configure/${monitor.id}`);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// id: 5,
|
||||
// label: "Clone",
|
||||
// action: () => {
|
||||
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: 6,
|
||||
label:
|
||||
@@ -177,38 +160,11 @@ export const MonitorTable = ({
|
||||
</Box>
|
||||
);
|
||||
const headers: Header<Monitor>[] = [
|
||||
{
|
||||
id: "selection",
|
||||
content: (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectAll")}
|
||||
indeterminate={isSomeSelected}
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
hideMobileLabel: true,
|
||||
render: (row) => (
|
||||
<Checkbox
|
||||
aria-label={t("pages.common.monitors.actions.selectMonitor", {
|
||||
name: row.name,
|
||||
})}
|
||||
checked={isRowSelected(row.id)}
|
||||
onChange={(e) => {
|
||||
handleSelectRow(row.id, e.target.checked);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
onClick: (e) => e?.stopPropagation(),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
align: "left",
|
||||
mobileLabel: t("common.table.headers.name"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
gap={theme.spacing(4)}
|
||||
direction={"row"}
|
||||
alignItems={"center"}
|
||||
onClick={(e) => handleSort(e, "name")}
|
||||
@@ -225,17 +181,16 @@ export const MonitorTable = ({
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
mobileLabel: t("common.table.headers.status"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
gap={theme.spacing(4)}
|
||||
direction={"row"}
|
||||
justifyContent={"center"}
|
||||
alignItems={"center"}
|
||||
onClick={(e) => handleSort(e, "status")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<Box width={theme.spacing(LAYOUT.MD)} />
|
||||
<Box width={theme.spacing(8)} />
|
||||
{t("common.table.headers.status")}
|
||||
{renderSortIcon(sortField === "status")}
|
||||
</Stack>
|
||||
@@ -257,17 +212,16 @@ export const MonitorTable = ({
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
mobileLabel: t("common.table.headers.type"),
|
||||
content: (
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
gap={theme.spacing(4)}
|
||||
direction={"row"}
|
||||
justifyContent={"center"}
|
||||
alignItems={"center"}
|
||||
onClick={(e) => handleSort(e, "type")}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<Box width={theme.spacing(LAYOUT.MD)} />
|
||||
<Box width={theme.spacing(8)} />
|
||||
{t("common.table.headers.type")}
|
||||
{renderSortIcon(sortField === "type")}
|
||||
</Stack>
|
||||
@@ -291,41 +245,12 @@ export const MonitorTable = ({
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{isSmall && (
|
||||
<Box
|
||||
px={SPACING.SM}
|
||||
pb={SPACING.LG}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={SPACING.SM}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isSomeSelected}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
{t("pages.common.monitors.bulkEdit.selectAll", "Select All")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
<Table
|
||||
headers={headers}
|
||||
data={monitors}
|
||||
onRowClick={(row) => {
|
||||
navigate(`/uptime/${row.id}`);
|
||||
}}
|
||||
getRowSx={(row) => ({
|
||||
backgroundColor: isRowSelected(row.id)
|
||||
? theme.palette.action.selected
|
||||
: "inherit",
|
||||
})}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import {
|
||||
ControlsFilter,
|
||||
HeaderMonitorsSummary,
|
||||
BulkActionsBar,
|
||||
} from "@/Components/monitors";
|
||||
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
|
||||
import { MonitorBasePageWithStates } from "@/Components/design-elements";
|
||||
import { TextField, Dialog, Button } from "@/Components/inputs";
|
||||
import { TextField, Dialog } from "@/Components/inputs";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
|
||||
import { HeaderCreate } from "@/Components/common";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import { BulkEditNotificationsModal } from "@/Components/monitors";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
@@ -22,8 +16,6 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useTheme } from "@mui/material";
|
||||
import useDebounce from "@/Hooks/useDebounce";
|
||||
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
|
||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||
|
||||
const UptimeMonitorsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -45,7 +37,6 @@ const UptimeMonitorsPage = () => {
|
||||
const [sortField, setSortField] = useState<string>("");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
|
||||
const [isBulkEditModalOpen, setIsBulkEditModalOpen] = useState(false);
|
||||
const isDialogOpen = Boolean(selectedMonitor);
|
||||
|
||||
const debouncedSearch = useDebounce<string>(search, 300);
|
||||
@@ -79,7 +70,7 @@ const UptimeMonitorsPage = () => {
|
||||
const effectiveTypes =
|
||||
selectedTypes.length > 0
|
||||
? selectedTypes
|
||||
: ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"];
|
||||
: ["http", "ping", "docker", "port", "game", "grpc", "websocket"];
|
||||
|
||||
// Build URL for monitors with checks
|
||||
const monitorsWithChecksUrl = useMemo(() => {
|
||||
@@ -111,15 +102,6 @@ const UptimeMonitorsPage = () => {
|
||||
count,
|
||||
} = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
|
||||
|
||||
// Bulk actions
|
||||
const {
|
||||
selectedRows,
|
||||
setSelectedRows,
|
||||
handleBulkPause,
|
||||
handleBulkResume,
|
||||
handleCancelSelection,
|
||||
} = useBulkMonitorActions(refetch, page);
|
||||
|
||||
// Delete hook
|
||||
const { deleteFn, loading: isDeleting } = useDelete();
|
||||
|
||||
@@ -138,14 +120,6 @@ const UptimeMonitorsPage = () => {
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleBulkEditComplete = (success: boolean) => {
|
||||
setIsBulkEditModalOpen(false);
|
||||
if (success) {
|
||||
setSelectedRows([]);
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectedMonitor(null);
|
||||
};
|
||||
@@ -184,7 +158,7 @@ const UptimeMonitorsPage = () => {
|
||||
<Stack
|
||||
direction={isSmall ? "column" : "row"}
|
||||
justifyContent={isSmall ? "flex-start" : "space-between"}
|
||||
gap={theme.spacing(LAYOUT.XS)}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<ControlsFilter
|
||||
selectedTypes={selectedTypes}
|
||||
@@ -203,47 +177,13 @@ const UptimeMonitorsPage = () => {
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{!isLoading && (
|
||||
<BulkActionsBar
|
||||
selectedCount={selectedRows.length}
|
||||
onCancel={handleCancelSelection}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Play size={16} />}
|
||||
onClick={handleBulkResume}
|
||||
>
|
||||
{t("common.buttons.resume")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Pause size={16} />}
|
||||
onClick={handleBulkPause}
|
||||
>
|
||||
{t("common.buttons.pause")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setIsBulkEditModalOpen(true)}
|
||||
>
|
||||
{t("pages.common.monitors.bulkEdit.editButton", {
|
||||
defaultValue: "Edit Notifications",
|
||||
})}
|
||||
</Button>
|
||||
</BulkActionsBar>
|
||||
)}
|
||||
|
||||
<MonitorTable
|
||||
page={page}
|
||||
monitors={monitorsWithChecks || []}
|
||||
refetch={refetch}
|
||||
setSelectedMonitor={setSelectedMonitor}
|
||||
count={count || 0}
|
||||
setPage={(newPage) => {
|
||||
setPage(newPage);
|
||||
setSelectedRows([]);
|
||||
}}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
sortField={sortField}
|
||||
setSortField={setSortField}
|
||||
@@ -257,10 +197,7 @@ const UptimeMonitorsPage = () => {
|
||||
})
|
||||
);
|
||||
setPage(0);
|
||||
setSelectedRows([]);
|
||||
}}
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
@@ -270,12 +207,6 @@ const UptimeMonitorsPage = () => {
|
||||
onCancel={handleCancel}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
<BulkEditNotificationsModal
|
||||
open={isBulkEditModalOpen}
|
||||
onClose={() => setIsBulkEditModalOpen(false)}
|
||||
selectedMonitors={selectedRows}
|
||||
onComplete={handleBulkEditComplete}
|
||||
/>
|
||||
</MonitorBasePageWithStates>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ export type DurationUnit = "seconds" | "minutes" | "hours" | "days";
|
||||
|
||||
export interface MaintenanceWindow {
|
||||
id: string;
|
||||
monitorIds: string[];
|
||||
monitorId: string;
|
||||
teamId: string;
|
||||
active: boolean;
|
||||
name: string;
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { PageSpeedGroupedCheck } from "@/Types/Check";
|
||||
import type { GeoContinent } from "@/Types/GeoCheck";
|
||||
export type { GeoContinent } from "@/Types/GeoCheck";
|
||||
|
||||
export const MAX_RECENT_CHECKS = 50;
|
||||
|
||||
export const MonitorTypes = [
|
||||
"http",
|
||||
"ping",
|
||||
@@ -15,14 +13,10 @@ export const MonitorTypes = [
|
||||
"game",
|
||||
"grpc",
|
||||
"websocket",
|
||||
"dns",
|
||||
"unknown",
|
||||
] as const;
|
||||
export type MonitorType = (typeof MonitorTypes)[number];
|
||||
|
||||
export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
|
||||
export type DnsRecordType = (typeof DnsRecordTypes)[number];
|
||||
|
||||
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
|
||||
|
||||
export const supportsGeoCheck = (type: MonitorType | undefined): boolean => {
|
||||
@@ -44,10 +38,6 @@ export type MonitorStatus = (typeof MonitorStatuses)[number];
|
||||
|
||||
export type MonitorMatchMethod = "equal" | "include" | "regex" | "";
|
||||
|
||||
export const PageSpeedStrategies = ["desktop", "mobile"] as const;
|
||||
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
|
||||
export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
|
||||
|
||||
export interface Monitor {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -82,13 +72,10 @@ export interface Monitor {
|
||||
selectedDisks: string[];
|
||||
gameId?: string;
|
||||
grpcServiceName?: string;
|
||||
strategy?: PageSpeedStrategy;
|
||||
group: string | null;
|
||||
geoCheckEnabled?: boolean;
|
||||
geoCheckLocations?: GeoContinent[];
|
||||
geoCheckInterval?: number;
|
||||
dnsServer?: string;
|
||||
dnsRecordType?: DnsRecordType;
|
||||
recentChecks: CheckSnapshot[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -23,8 +23,6 @@ export interface QueueJobSummary {
|
||||
monitorUrl: string | null;
|
||||
monitorType: MonitorType | null;
|
||||
monitorInterval: number | null;
|
||||
monitorGeoInterval: number | null;
|
||||
monitorActive: boolean | null;
|
||||
active: boolean;
|
||||
lockedAt: number | null;
|
||||
runCount: number;
|
||||
@@ -34,7 +32,6 @@ export interface QueueJobSummary {
|
||||
lastFinishedAt: number | null;
|
||||
lastRunTook: number | null;
|
||||
lastFailedAt: number | null;
|
||||
repeat: number | null;
|
||||
}
|
||||
|
||||
export interface QueueData {
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface Settings {
|
||||
systemEmailHost?: string;
|
||||
systemEmailPort?: number;
|
||||
systemEmailAddress?: string;
|
||||
systemEmailDisplayName?: string;
|
||||
systemEmailUser?: string;
|
||||
systemEmailConnectionHost?: string;
|
||||
systemEmailTLSServername?: string;
|
||||
|
||||
@@ -10,7 +10,6 @@ export const getMonitorPath = (type: MonitorType): string => {
|
||||
game: "uptime",
|
||||
grpc: "uptime",
|
||||
websocket: "uptime",
|
||||
dns: "uptime",
|
||||
unknown: "uptime",
|
||||
docker: "uptime",
|
||||
hardware: "infrastructure",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { alpha } from "@mui/material/styles";
|
||||
|
||||
const typographyBase = 13;
|
||||
|
||||
export const typographyLevels = {
|
||||
@@ -19,9 +17,6 @@ export const colors = {
|
||||
gray850: "#1c1c21",
|
||||
brandGreen: "#13715B",
|
||||
brandGreenLight: "#4DAF94",
|
||||
green200: "#ECF7F2",
|
||||
yellow200: "#FFF4E5",
|
||||
orange500: "#FFA500",
|
||||
};
|
||||
|
||||
export const lightPalette = {
|
||||
@@ -34,10 +29,6 @@ export const lightPalette = {
|
||||
sidebar: {
|
||||
accent: colors.brandGreen,
|
||||
},
|
||||
rowStatus: {
|
||||
running: colors.green200,
|
||||
paused: colors.yellow200,
|
||||
},
|
||||
};
|
||||
|
||||
export const darkPalette = {
|
||||
@@ -54,8 +45,4 @@ export const darkPalette = {
|
||||
sidebar: {
|
||||
accent: colors.brandGreenLight,
|
||||
},
|
||||
rowStatus: {
|
||||
running: alpha(colors.brandGreen, 0.18),
|
||||
paused: alpha(colors.orange500, 0.18),
|
||||
},
|
||||
};
|
||||
@@ -15,11 +15,9 @@ declare module "@mui/material/styles" {
|
||||
}
|
||||
interface Palette {
|
||||
sidebar: { accent: string };
|
||||
rowStatus: { running: string; paused: string };
|
||||
}
|
||||
interface PaletteOptions {
|
||||
sidebar?: { accent: string };
|
||||
rowStatus?: { running: string; paused: string };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export const SPACING = {
|
||||
XXS: 0.25,
|
||||
XS: 0.5,
|
||||
SM: 1,
|
||||
MD: 1.5,
|
||||
|
||||
@@ -15,33 +15,12 @@ export const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
||||
export const MS_PER_DAY = 24 * MS_PER_HOUR;
|
||||
export const MS_PER_WEEK = MS_PER_DAY * 7;
|
||||
|
||||
const resolveSystemTimezone = (): string => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
} catch {
|
||||
return "UTC";
|
||||
}
|
||||
};
|
||||
|
||||
export const SYSTEM_TIMEZONE = resolveSystemTimezone();
|
||||
|
||||
export const resolveTimezone = (preferred?: string | null): string =>
|
||||
preferred?.trim() || SYSTEM_TIMEZONE;
|
||||
|
||||
export const formatDateWithTz = (
|
||||
timestamp: string,
|
||||
format: string,
|
||||
timezone?: string
|
||||
) => {
|
||||
export const formatDateWithTz = (timestamp: string, format: string, timezone: string) => {
|
||||
if (!timestamp) {
|
||||
return "Unknown time";
|
||||
}
|
||||
const tz = resolveTimezone(timezone);
|
||||
try {
|
||||
return dayjs(timestamp).tz(tz).format(format);
|
||||
} catch {
|
||||
return dayjs(timestamp).utc().format(format);
|
||||
}
|
||||
const formattedDate = dayjs(timestamp).tz(timezone).format(format);
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
export const tickDateFormatLookup = (range: string) => {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { ValueType } from "@/Components/design-elements/StatusLabel";
|
||||
|
||||
export const NETWORK_ERROR_CODE = 5000;
|
||||
|
||||
export const NETWORK_ERROR_SUBTYPES = [
|
||||
"timeout",
|
||||
"dns",
|
||||
"refused",
|
||||
"tls",
|
||||
"reset",
|
||||
"unknown",
|
||||
] as const;
|
||||
export type NetworkErrorSubtype = (typeof NETWORK_ERROR_SUBTYPES)[number];
|
||||
|
||||
type TranslateFn = (key: string) => string;
|
||||
|
||||
export const isNetworkError = (code?: number | null): boolean =>
|
||||
code === NETWORK_ERROR_CODE;
|
||||
|
||||
const ERROR_CODE_PATTERN =
|
||||
/\b(E[A-Z_]{2,}|CERT_[A-Z_]+|UNABLE_TO_VERIFY[A-Z_]*|DEPTH_ZERO_SELF_SIGNED[A-Z_]*|SELF_SIGNED[A-Z_]*)\b/;
|
||||
|
||||
export const extractErrorCode = (message?: string | null): string | null => {
|
||||
if (!message) return null;
|
||||
const match = message.match(ERROR_CODE_PATTERN);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
export const getNetworkErrorSubtype = (message?: string | null): NetworkErrorSubtype => {
|
||||
if (!message) return "unknown";
|
||||
const m = message.toUpperCase();
|
||||
if (m.includes("ETIMEDOUT") || m.includes("TIMEOUT") || m.includes("ESOCKETTIMEDOUT"))
|
||||
return "timeout";
|
||||
if (m.includes("ENOTFOUND") || m.includes("EAI_AGAIN") || m.includes("DNS"))
|
||||
return "dns";
|
||||
if (m.includes("ECONNREFUSED")) return "refused";
|
||||
if (
|
||||
m.includes("CERT_") ||
|
||||
m.includes("UNABLE_TO_VERIFY") ||
|
||||
m.includes("DEPTH_ZERO_SELF_SIGNED") ||
|
||||
m.includes("SELF_SIGNED") ||
|
||||
m.includes("TLS") ||
|
||||
m.includes("SSL")
|
||||
)
|
||||
return "tls";
|
||||
if (m.includes("ECONNRESET")) return "reset";
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
export const formatStatusCode = (
|
||||
code: number | null | undefined,
|
||||
t: TranslateFn
|
||||
): string => {
|
||||
if (isNetworkError(code)) return t("common.statusCode.down");
|
||||
return String(code);
|
||||
};
|
||||
|
||||
export const getStatusCodeTooltip = (
|
||||
code: number | null | undefined,
|
||||
message: string | null | undefined,
|
||||
t: TranslateFn
|
||||
): string | null => {
|
||||
if (!isNetworkError(code)) return null;
|
||||
const description = t(
|
||||
`common.statusCode.networkError.${getNetworkErrorSubtype(message)}`
|
||||
);
|
||||
const errorCode = extractErrorCode(message);
|
||||
return errorCode ? `${description} (${errorCode})` : description;
|
||||
};
|
||||
|
||||
export const getStatusCodeValueType = (code: number): ValueType => {
|
||||
if (isNetworkError(code)) return "negative";
|
||||
if (code < 300) return "positive";
|
||||
if (code < 400) return "neutral";
|
||||
return "negative";
|
||||
};
|
||||
@@ -14,32 +14,30 @@ export const durationUnitOptions = [
|
||||
{ id: "days", name: "days", multiplier: 86400000 },
|
||||
] as const;
|
||||
|
||||
const maintenanceWindowBaseSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Name is required")
|
||||
.max(100, "Name must be at most 100 characters"),
|
||||
repeat: z.string(),
|
||||
startDate: z.string().min(1, "Start date is required"),
|
||||
startTime: z.string().min(1, "Start time is required"),
|
||||
duration: z.number().int().min(1, "Duration must be at least 1"),
|
||||
durationUnit: z.string(),
|
||||
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
||||
});
|
||||
|
||||
export const maintenanceWindowSchema = maintenanceWindowBaseSchema.refine(
|
||||
(data) => {
|
||||
const startDateTime = dayjs(data.startDate)
|
||||
.set("hour", parseInt(data.startTime.split(":")[0], 10))
|
||||
.set("minute", parseInt(data.startTime.split(":")[1], 10));
|
||||
return startDateTime.isAfter(dayjs());
|
||||
},
|
||||
{
|
||||
message: "Start date and time must be in the future",
|
||||
path: ["startDate"],
|
||||
}
|
||||
);
|
||||
|
||||
export const maintenanceWindowEditSchema = maintenanceWindowBaseSchema;
|
||||
export const maintenanceWindowSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Name is required")
|
||||
.max(100, "Name must be at most 100 characters"),
|
||||
repeat: z.string(),
|
||||
startDate: z.string().min(1, "Start date is required"),
|
||||
startTime: z.string().min(1, "Start time is required"),
|
||||
duration: z.number().int().min(1, "Duration must be at least 1"),
|
||||
durationUnit: z.string(),
|
||||
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const startDateTime = dayjs(data.startDate)
|
||||
.set("hour", parseInt(data.startTime.split(":")[0], 10))
|
||||
.set("minute", parseInt(data.startTime.split(":")[1], 10));
|
||||
return startDateTime.isAfter(dayjs());
|
||||
},
|
||||
{
|
||||
message: "Start date and time must be in the future",
|
||||
path: ["startDate"],
|
||||
}
|
||||
);
|
||||
|
||||
export type MaintenanceWindowFormData = z.infer<typeof maintenanceWindowSchema>;
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { GeoContinents } from "@/Types/GeoCheck";
|
||||
import { DnsRecordTypes, PageSpeedStrategies } from "@/Types/Monitor";
|
||||
|
||||
// URL schema with custom error message
|
||||
const urlSchema = z.url({ message: "Please enter a valid URL" });
|
||||
@@ -90,7 +89,6 @@ const grpcSchema = baseSchema.extend({
|
||||
const pagespeedSchema = baseSchema.extend({
|
||||
type: z.literal("pagespeed"),
|
||||
url: urlSchema,
|
||||
strategy: z.enum(PageSpeedStrategies),
|
||||
});
|
||||
|
||||
// Hardware/Infrastructure monitor schema
|
||||
@@ -124,29 +122,6 @@ const websocketSchema = baseSchema.extend({
|
||||
ignoreTlsErrors: z.boolean(),
|
||||
});
|
||||
|
||||
// Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots,
|
||||
// optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp).
|
||||
// No scheme, port, path, or whitespace. Total length ≤ 253.
|
||||
const hostnameRegex =
|
||||
/^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
|
||||
|
||||
// DNS monitor schema
|
||||
const dnsSchema = baseSchema.extend({
|
||||
type: z.literal("dns"),
|
||||
url: z
|
||||
.string()
|
||||
.min(1, "Domain is required")
|
||||
.regex(hostnameRegex, "Enter a valid domain (e.g. www.example.com)"),
|
||||
dnsServer: z
|
||||
.string()
|
||||
.min(1, "DNS server is required")
|
||||
.refine(
|
||||
(v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success,
|
||||
"Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)"
|
||||
),
|
||||
dnsRecordType: z.enum(DnsRecordTypes),
|
||||
});
|
||||
|
||||
// Discriminated union of all monitor types
|
||||
export const monitorSchema = z.discriminatedUnion("type", [
|
||||
httpSchema,
|
||||
@@ -158,7 +133,6 @@ export const monitorSchema = z.discriminatedUnion("type", [
|
||||
pagespeedSchema,
|
||||
hardwareSchema,
|
||||
websocketSchema,
|
||||
dnsSchema,
|
||||
]);
|
||||
|
||||
export type MonitorFormData = z.infer<typeof monitorSchema>;
|
||||
|
||||
@@ -32,11 +32,6 @@ export const settingsSchema = z.object({
|
||||
.or(z.literal(""))
|
||||
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
|
||||
.optional(),
|
||||
systemEmailDisplayName: z
|
||||
.string()
|
||||
.max(100, "Display name must be 100 characters or fewer")
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
.optional(),
|
||||
systemEmailUser: z
|
||||
.string()
|
||||
.transform((val) => (val.trim() === "" ? null : val.trim()))
|
||||
|
||||
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "دعوة عضو",
|
||||
"openSite": "فتح الموقع",
|
||||
"pause": "إيقاف مؤقت",
|
||||
"resume": "استئناف",
|
||||
"selectAll": "تحديد جميع الشاشات",
|
||||
"selectMonitor": "تحديد {{name}}",
|
||||
"bulkSelected_one": "{{count}} شاشة محددة",
|
||||
"bulkSelected_other": "{{count}} شاشات محددة"
|
||||
"resume": "استئناف"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "نشط منذ",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "البنية التحتية",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "تم إيقاف {{count}} شاشة مؤقتاً",
|
||||
"paused_other": "تم إيقاف {{count}} شاشات مؤقتاً",
|
||||
"resumed_one": "تم استئناف {{count}} شاشة",
|
||||
"resumed_other": "تم استئناف {{count}} شاشات",
|
||||
"alreadyPaused_one": "الشاشة المحددة متوقفة مؤقتاً بالفعل",
|
||||
"alreadyPaused_other": "جميع الشاشات المحددة متوقفة مؤقتاً بالفعل",
|
||||
"alreadyRunning_one": "الشاشة المحددة قيد التشغيل بالفعل",
|
||||
"alreadyRunning_other": "جميع الشاشات المحددة قيد التشغيل بالفعل"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "رابط WebSocket",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "نقطة نهاية Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "الإعدادات العامة",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.",
|
||||
"grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.",
|
||||
"websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.",
|
||||
"hardware": "راقب استخدام وحدة المعالجة المركزية والذاكرة والقرص ودرجة الحرارة لبنيتك التحتية. يجب أن يتضمن عنوان URL لنقطة نهاية Capture المسار الكامل /api/v1/metrics (مثال: http://192.168.1.10:5001/api/v1/metrics). بالنسبة لمفتاح التفويض، الصق سلسلة الرمز فقط — لا تُضمّن كلمة 'Bearer'."
|
||||
"hardware": "مراقبة استخدام CPU والذاكرة والقرص ودرجة الحرارة للبنية التحتية الخاصة بك."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "Pozvat člena",
|
||||
"openSite": "Otevřít stránku",
|
||||
"pause": "Pozastavit",
|
||||
"resume": "Obnovit",
|
||||
"selectAll": "Vybrat všechny monitory",
|
||||
"selectMonitor": "Vybrat {{name}}",
|
||||
"bulkSelected_one": "Vybrán {{count}} monitor",
|
||||
"bulkSelected_other": "Vybráno {{count}} monitorů"
|
||||
"resume": "Obnovit"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktivní po dobu",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "Infrastruktura",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} monitor pozastaven",
|
||||
"paused_other": "{{count}} monitorů pozastaveno",
|
||||
"resumed_one": "{{count}} monitor obnoven",
|
||||
"resumed_other": "{{count}} monitorů obnoveno",
|
||||
"alreadyPaused_one": "Vybraný monitor je již pozastaven",
|
||||
"alreadyPaused_other": "Všechny vybrané monitory jsou již pozastaveny",
|
||||
"alreadyRunning_one": "Vybraný monitor již běží",
|
||||
"alreadyRunning_other": "Všechny vybrané monitory již běží"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Endpoint Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Obecná nastavení",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "Sledujte výkon načítání stránky, metriky Core Web Vitals a hodnocení optimalizace vašeho webu.",
|
||||
"grpc": "Zadejte název hostitele a port gRPC serveru, volitelně zadejte název služby pro kontrolu stavu a přidejte zobrazovaný název.",
|
||||
"websocket": "Zadejte WebSocket URL ke sledování (např. wss://example.com/socket) a přidejte zobrazovaný název.",
|
||||
"hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury. URL endpointu Capture musí obsahovat celou cestu /api/v1/metrics (např. http://192.168.1.10:5001/api/v1/metrics). Do autorizačního klíče vložte pouze řetězec tokenu — nezahrnujte 'Bearer'."
|
||||
"hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "Mitglied einladen",
|
||||
"openSite": "Seite öffnen",
|
||||
"pause": "Pausieren",
|
||||
"resume": "Fortsetzen",
|
||||
"selectAll": "Alle Monitore auswählen",
|
||||
"selectMonitor": "{{name}} auswählen",
|
||||
"bulkSelected_one": "{{count}} Monitor ausgewählt",
|
||||
"bulkSelected_other": "{{count}} Monitore ausgewählt"
|
||||
"resume": "Fortsetzen"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktiv seit",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "Infrastruktur",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} Monitor pausiert",
|
||||
"paused_other": "{{count}} Monitore pausiert",
|
||||
"resumed_one": "{{count}} Monitor fortgesetzt",
|
||||
"resumed_other": "{{count}} Monitore fortgesetzt",
|
||||
"alreadyPaused_one": "Der ausgewählte Monitor ist bereits pausiert",
|
||||
"alreadyPaused_other": "Alle ausgewählten Monitore sind bereits pausiert",
|
||||
"alreadyRunning_one": "Der ausgewählte Monitor wird bereits ausgeführt",
|
||||
"alreadyRunning_other": "Alle ausgewählten Monitore werden bereits ausgeführt"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket-URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture-Endpunkt",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Allgemeine Einstellungen",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "Verfolgen Sie die Ladezeit, Core Web Vitals und Optimierungswerte Ihrer Website.",
|
||||
"grpc": "Geben Sie den Hostnamen und Port des gRPC-Servers ein, optional einen Dienstnamen für den Health-Check, und fügen Sie einen Anzeigenamen hinzu.",
|
||||
"websocket": "Geben Sie die zu überwachende WebSocket-URL ein (z. B. wss://example.com/socket) und fügen Sie einen Anzeigenamen hinzu.",
|
||||
"hardware": "Überwachen Sie CPU-, Speicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur. Die URL des Capture-Endpunkts muss den vollständigen Pfad /api/v1/metrics enthalten (z. B. http://192.168.1.10:5001/api/v1/metrics). Fügen Sie für das Autorisierungsgeheimnis nur den Token-String ein — geben Sie 'Bearer' nicht mit an."
|
||||
"hardware": "Überwachen Sie CPU-, Arbeitsspeicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -14,23 +14,11 @@
|
||||
}
|
||||
},
|
||||
"appName": "Checkmate",
|
||||
"statusCode": {
|
||||
"down": "Down",
|
||||
"networkError": {
|
||||
"timeout": "The server didn't respond in time (request timed out).",
|
||||
"dns": "Couldn't resolve the hostname (DNS lookup failed).",
|
||||
"refused": "The server refused the connection (port closed or service not listening).",
|
||||
"tls": "TLS or SSL handshake failed (certificate or encryption issue).",
|
||||
"reset": "The connection was reset before a response was received.",
|
||||
"unknown": "No HTTP response was received from the target."
|
||||
}
|
||||
},
|
||||
"breadcrumbs": {
|
||||
"details": "Details",
|
||||
"home": "Home"
|
||||
},
|
||||
"buttons": {
|
||||
"selected": "selected",
|
||||
"addMember": "Add member",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
@@ -82,9 +70,7 @@
|
||||
},
|
||||
"labels": {
|
||||
"active": "Active",
|
||||
"running": "Running",
|
||||
"paused": "Paused",
|
||||
"idle": "Idle",
|
||||
"paused": "paused",
|
||||
"na": "N/A",
|
||||
"resolved": "Resolved",
|
||||
"responseTime": "Response time"
|
||||
@@ -426,20 +412,6 @@
|
||||
},
|
||||
"common": {
|
||||
"monitors": {
|
||||
"bulkEdit": {
|
||||
"selectAll": "Select All",
|
||||
"editButton": "Edit Notifications",
|
||||
"title": "Edit Notifications",
|
||||
"selectedText": "You are applying changes to {{count}} monitor(s).",
|
||||
"actionAdd": "Add",
|
||||
"actionAddDesc": "Adds the selected notifications to these monitors without removing existing ones.",
|
||||
"actionRemove": "Remove",
|
||||
"actionRemoveDesc": "Removes exclusively the selected notifications from these monitors.",
|
||||
"actionSet": "Replace all",
|
||||
"actionSetDesc": "Replaces all existing notifications. Leave empty to clear all notifications.",
|
||||
"selectLabel": "Notification Channels",
|
||||
"selectPlaceholder": "Select channels..."
|
||||
},
|
||||
"actions": {
|
||||
"configure": "Configure",
|
||||
"delete": "Delete",
|
||||
@@ -449,21 +421,7 @@
|
||||
"inviteMember": "Invite member",
|
||||
"openSite": "Open site",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"bulkSelected_one": "{{count}} monitor selected",
|
||||
"bulkSelected_other": "{{count}} monitors selected",
|
||||
"selectAll": "Select all monitors",
|
||||
"selectMonitor": "Select {{name}}"
|
||||
},
|
||||
"bulkPause": {
|
||||
"alreadyPaused_one": "The selected monitor is already paused",
|
||||
"alreadyPaused_other": "All selected monitors are already paused",
|
||||
"alreadyRunning_one": "The selected monitor is already running",
|
||||
"alreadyRunning_other": "All selected monitors are already running",
|
||||
"paused_one": "{{count}} monitor paused",
|
||||
"paused_other": "{{count}} monitors paused",
|
||||
"resumed_one": "{{count}} monitor resumed",
|
||||
"resumed_other": "{{count}} monitors resumed"
|
||||
"resume": "Resume"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Active for",
|
||||
@@ -490,8 +448,7 @@
|
||||
"optionPagespeed": "PageSpeed",
|
||||
"optionHardware": "Infrastructure",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket",
|
||||
"optionDns": "DNS"
|
||||
"optionWebSocket": "WebSocket"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -577,30 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"strategy": {
|
||||
"label": "Strategy",
|
||||
"desktop": "Desktop",
|
||||
"mobile": "Mobile"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture endpoint",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
},
|
||||
"dnsServer": {
|
||||
"label": "DNS server",
|
||||
"placeholder": "8.8.8.8"
|
||||
},
|
||||
"dnsRecordType": {
|
||||
"label": "DNS record type",
|
||||
"value": {
|
||||
"A": "A",
|
||||
"AAAA": "AAAA",
|
||||
"CNAME": "CNAME",
|
||||
"MX": "MX",
|
||||
"NS": "NS",
|
||||
"TXT": "TXT"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "General settings",
|
||||
@@ -613,8 +546,7 @@
|
||||
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
|
||||
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
|
||||
"websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.",
|
||||
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'.",
|
||||
"dns": "Monitor if a specific DNS server is responding."
|
||||
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
@@ -651,7 +583,6 @@
|
||||
"optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.",
|
||||
"optionPingDescription": "Use ICMP Ping to monitor if a server is online.",
|
||||
"optionPortDescription": "Monitor if a specific port on a server is open.",
|
||||
"optionDnsDescription": "Monitor if a specific DNS server is responding.",
|
||||
"title": "Type"
|
||||
},
|
||||
"thresholds": {
|
||||
@@ -952,7 +883,6 @@
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"state": "State",
|
||||
"timestamp": "Timestamp",
|
||||
"level": "Level",
|
||||
"service": "Service",
|
||||
@@ -1122,10 +1052,6 @@
|
||||
}
|
||||
},
|
||||
"pageSpeed": {
|
||||
"strategy": {
|
||||
"desktop": "Desktop",
|
||||
"mobile": "Mobile"
|
||||
},
|
||||
"charts": {
|
||||
"common": {
|
||||
"cls": "Cumulative Layout Shift (CLS)",
|
||||
@@ -1272,10 +1198,6 @@
|
||||
"label": "Email address - Used for authentication",
|
||||
"placeholder": "you@example.com"
|
||||
},
|
||||
"displayName": {
|
||||
"label": "Sender name (optional) - Friendly name shown in the From field",
|
||||
"placeholder": "Checkmate notifications"
|
||||
},
|
||||
"user": {
|
||||
"label": "Email user - Username for authentication, overrides email address if specified",
|
||||
"placeholder": "Leave empty if not required"
|
||||
|
||||
@@ -428,11 +428,7 @@
|
||||
"inviteMember": "Invitar miembro",
|
||||
"openSite": "Abrir sitio",
|
||||
"pause": "Pausar",
|
||||
"resume": "Reanudar",
|
||||
"selectAll": "Seleccionar todos los monitores",
|
||||
"selectMonitor": "Seleccionar {{name}}",
|
||||
"bulkSelected_one": "{{count}} monitor seleccionado",
|
||||
"bulkSelected_other": "{{count}} monitores seleccionados"
|
||||
"resume": "Reanudar"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Activo desde hace",
|
||||
@@ -460,16 +456,6 @@
|
||||
"optionHardware": "Infraestructura",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} monitor pausado",
|
||||
"paused_other": "{{count}} monitores pausados",
|
||||
"resumed_one": "{{count}} monitor reanudado",
|
||||
"resumed_other": "{{count}} monitores reanudados",
|
||||
"alreadyPaused_one": "El monitor seleccionado ya está pausado",
|
||||
"alreadyPaused_other": "Todos los monitores seleccionados ya están pausados",
|
||||
"alreadyRunning_one": "El monitor seleccionado ya se está ejecutando",
|
||||
"alreadyRunning_other": "Todos los monitores seleccionados ya se están ejecutando"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -555,10 +541,6 @@
|
||||
"wsUrl": {
|
||||
"label": "URL de WebSocket",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Endpoint de Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Configuración general",
|
||||
@@ -571,7 +553,7 @@
|
||||
"pagespeed": "Rastree el rendimiento de carga de página, Core Web Vitals y puntuaciones de optimización de su sitio web.",
|
||||
"grpc": "Ingrese el nombre de host y puerto del servidor gRPC, opcionalmente especifique un nombre de servicio para la verificación de salud, y agregue un nombre para mostrar.",
|
||||
"websocket": "Ingrese la URL de WebSocket a monitorear (p. ej., wss://example.com/socket) y agregue un nombre para mostrar.",
|
||||
"hardware": "Supervisa el uso de CPU, memoria, disco y temperatura de tu infraestructura. La URL del endpoint de Capture debe incluir la ruta completa /api/v1/metrics (p. ej., http://192.168.1.10:5001/api/v1/metrics). Para el secreto de autorización, pega solo la cadena del token — no incluyas 'Bearer'."
|
||||
"hardware": "Monitoree el uso de CPU, memoria, disco y temperatura de su infraestructura."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "Kutsu jäsen",
|
||||
"openSite": "Avaa sivusto",
|
||||
"pause": "Keskeytä",
|
||||
"resume": "Jatka",
|
||||
"selectAll": "Valitse kaikki näytöt",
|
||||
"selectMonitor": "Valitse {{name}}",
|
||||
"bulkSelected_one": "{{count}} näyttö valittu",
|
||||
"bulkSelected_other": "{{count}} näyttöä valittu"
|
||||
"resume": "Jatka"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktiivinen",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "Infrastruktuuri",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} näyttö keskeytetty",
|
||||
"paused_other": "{{count}} näyttöä keskeytetty",
|
||||
"resumed_one": "{{count}} näyttö jatkettu",
|
||||
"resumed_other": "{{count}} näyttöä jatkettu",
|
||||
"alreadyPaused_one": "Valittu näyttö on jo keskeytetty",
|
||||
"alreadyPaused_other": "Kaikki valitut näytöt on jo keskeytetty",
|
||||
"alreadyRunning_one": "Valittu näyttö on jo käynnissä",
|
||||
"alreadyRunning_other": "Kaikki valitut näytöt ovat jo käynnissä"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture-päätepiste",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Yleiset asetukset",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "Seuraa sivun latausnopeutta, Core Web Vitals -arvoja ja optimointipisteitä verkkosivustollasi.",
|
||||
"grpc": "Syötä gRPC-palvelimen isäntänimi ja portti, määritä halutessasi palvelun nimi terveystarkistusta varten ja lisää näyttönimi.",
|
||||
"websocket": "Syötä monitoroitava WebSocket URL (esim. wss://example.com/socket) ja lisää näyttönimi.",
|
||||
"hardware": "Seuraa infrastruktuurisi suorittimen, muistin, levyn käyttöä ja lämpötilaa. Capture-päätepisteen URL-osoitteen on sisällettävä koko polku /api/v1/metrics (esim. http://192.168.1.10:5001/api/v1/metrics). Liitä valtuutusavaimeen vain tokenin merkkijono — älä sisällytä sanaa 'Bearer'."
|
||||
"hardware": "Monitoroi infrastruktuurisi CPU:n, muistin ja levyn käyttöä sekä lämpötilaa."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "Inviter un membre",
|
||||
"openSite": "Ouvrir le site",
|
||||
"pause": "Pause",
|
||||
"resume": "Reprendre",
|
||||
"selectAll": "Sélectionner tous les moniteurs",
|
||||
"selectMonitor": "Sélectionner {{name}}",
|
||||
"bulkSelected_one": "{{count}} moniteur sélectionné",
|
||||
"bulkSelected_other": "{{count}} moniteurs sélectionnés"
|
||||
"resume": "Reprendre"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Actif depuis",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "Infrastructure",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} moniteur en pause",
|
||||
"paused_other": "{{count}} moniteurs en pause",
|
||||
"resumed_one": "{{count}} moniteur repris",
|
||||
"resumed_other": "{{count}} moniteurs repris",
|
||||
"alreadyPaused_one": "Le moniteur sélectionné est déjà en pause",
|
||||
"alreadyPaused_other": "Tous les moniteurs sélectionnés sont déjà en pause",
|
||||
"alreadyRunning_one": "Le moniteur sélectionné est déjà en cours d'exécution",
|
||||
"alreadyRunning_other": "Tous les moniteurs sélectionnés sont déjà en cours d'exécution"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "URL WebSocket",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Point de terminaison Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Paramètres généraux",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "Suivez les performances de chargement, les Core Web Vitals et les scores d'optimisation de votre site web.",
|
||||
"grpc": "Saisissez le nom d'hôte et le port du serveur gRPC, spécifiez éventuellement un nom de service pour le bilan de santé, et ajoutez un nom d'affichage.",
|
||||
"websocket": "Saisissez l'URL WebSocket à surveiller (ex. wss://example.com/socket) et ajoutez un nom d'affichage.",
|
||||
"hardware": "Surveillez l'utilisation du processeur, de la mémoire, du disque et la température de votre infrastructure. L'URL du point de terminaison Capture doit inclure le chemin complet /api/v1/metrics (par ex. http://192.168.1.10:5001/api/v1/metrics). Pour le secret d'autorisation, collez uniquement la chaîne du jeton — n'incluez pas 'Bearer'."
|
||||
"hardware": "Surveillez l'utilisation du CPU, de la mémoire, du disque et la température de votre infrastructure."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "メンバーを招待",
|
||||
"openSite": "サイトを開く",
|
||||
"pause": "一時停止",
|
||||
"resume": "再開",
|
||||
"selectAll": "すべてのモニターを選択",
|
||||
"selectMonitor": "{{name}} を選択",
|
||||
"bulkSelected_one": "{{count}} 個のモニターが選択されました",
|
||||
"bulkSelected_other": "{{count}} 個のモニターが選択されました"
|
||||
"resume": "再開"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "稼働期間",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "インフラストラクチャ",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} 個のモニターを一時停止しました",
|
||||
"paused_other": "{{count}} 個のモニターを一時停止しました",
|
||||
"resumed_one": "{{count}} 個のモニターを再開しました",
|
||||
"resumed_other": "{{count}} 個のモニターを再開しました",
|
||||
"alreadyPaused_one": "選択したモニターはすでに一時停止されています",
|
||||
"alreadyPaused_other": "選択したすべてのモニターはすでに一時停止されています",
|
||||
"alreadyRunning_one": "選択したモニターはすでに実行中です",
|
||||
"alreadyRunning_other": "選択したすべてのモニターはすでに実行中です"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture エンドポイント",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "一般設定",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。",
|
||||
"grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。",
|
||||
"websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。",
|
||||
"hardware": "インフラの CPU、メモリ、ディスク使用率、温度を監視します。Capture エンドポイント URL には完全なパス /api/v1/metrics を含める必要があります (例: http://192.168.1.10:5001/api/v1/metrics)。認証シークレットにはトークン文字列のみを貼り付けてください — 'Bearer' は含めないでください。"
|
||||
"hardware": "インフラストラクチャのCPU、メモリ、ディスク使用量、温度を監視します。"
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "Convidar membro",
|
||||
"openSite": "Abrir site",
|
||||
"pause": "Pausar",
|
||||
"resume": "Retomar",
|
||||
"selectAll": "Selecionar todos os monitores",
|
||||
"selectMonitor": "Selecionar {{name}}",
|
||||
"bulkSelected_one": "{{count}} monitor selecionado",
|
||||
"bulkSelected_other": "{{count}} monitores selecionados"
|
||||
"resume": "Retomar"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Ativo há",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "Infraestrutura",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} monitor pausado",
|
||||
"paused_other": "{{count}} monitores pausados",
|
||||
"resumed_one": "{{count}} monitor retomado",
|
||||
"resumed_other": "{{count}} monitores retomados",
|
||||
"alreadyPaused_one": "O monitor selecionado já está pausado",
|
||||
"alreadyPaused_other": "Todos os monitores selecionados já estão pausados",
|
||||
"alreadyRunning_one": "O monitor selecionado já está em execução",
|
||||
"alreadyRunning_other": "Todos os monitores selecionados já estão em execução"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "URL do WebSocket",
|
||||
"placeholder": "wss://exemplo.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Endpoint do Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Configurações gerais",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "Acompanhe o desempenho de carregamento da página, Core Web Vitals e pontuações de otimização do seu site.",
|
||||
"grpc": "Digite o hostname e a porta do servidor gRPC, opcionalmente especifique um nome de serviço para a verificação de saúde e adicione um nome de exibição.",
|
||||
"websocket": "Digite a URL do WebSocket para monitorar (ex.: wss://exemplo.com/socket) e adicione um nome de exibição.",
|
||||
"hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura. A URL do endpoint do Capture deve incluir o caminho completo /api/v1/metrics (por exemplo, http://192.168.1.10:5001/api/v1/metrics). Para a chave de autorização, cole apenas a string do token — não inclua 'Bearer'."
|
||||
"hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "Пригласить участника",
|
||||
"openSite": "Открыть сайт",
|
||||
"pause": "Пауза",
|
||||
"resume": "Продолжить",
|
||||
"selectAll": "Выбрать все мониторы",
|
||||
"selectMonitor": "Выбрать {{name}}",
|
||||
"bulkSelected_one": "Выбран {{count}} монитор",
|
||||
"bulkSelected_other": "Выбрано {{count}} мониторов"
|
||||
"resume": "Продолжить"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Активен",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "Инфраструктура",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} монитор приостановлен",
|
||||
"paused_other": "{{count}} мониторов приостановлено",
|
||||
"resumed_one": "{{count}} монитор возобновлен",
|
||||
"resumed_other": "{{count}} мониторов возобновлено",
|
||||
"alreadyPaused_one": "Выбранный монитор уже приостановлен",
|
||||
"alreadyPaused_other": "Все выбранные мониторы уже приостановлены",
|
||||
"alreadyRunning_one": "Выбранный монитор уже запущен",
|
||||
"alreadyRunning_other": "Все выбранные мониторы уже запущены"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Эндпоинт Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Основные настройки",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.",
|
||||
"grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.",
|
||||
"websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.",
|
||||
"hardware": "Отслеживайте использование ЦП, памяти, диска и температуру вашей инфраструктуры. URL эндпоинта Capture должен включать полный путь /api/v1/metrics (например, http://192.168.1.10:5001/api/v1/metrics). В секрете авторизации укажите только строку токена — не включайте 'Bearer'."
|
||||
"hardware": "Мониторинг CPU, памяти, дисков и температуры Вашей инфраструктуры."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "เชิญสมาชิก",
|
||||
"openSite": "เปิดเว็บไซต์",
|
||||
"pause": "หยุดชั่วคราว",
|
||||
"resume": "ดำเนินการต่อ",
|
||||
"selectAll": "เลือกจอภาพทั้งหมด",
|
||||
"selectMonitor": "เลือก {{name}}",
|
||||
"bulkSelected_one": "เลือกแล้ว {{count}} จอภาพ",
|
||||
"bulkSelected_other": "เลือกแล้ว {{count}} จอภาพ"
|
||||
"resume": "ดำเนินการต่อ"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "ทำงานมาแล้ว",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "โครงสร้างพื้นฐาน",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "หยุดจอภาพชั่วคราว {{count}} จอ",
|
||||
"paused_other": "หยุดจอภาพชั่วคราว {{count}} จอ",
|
||||
"resumed_one": "กลับมาทำงานต่อ {{count}} จอ",
|
||||
"resumed_other": "กลับมาทำงานต่อ {{count}} จอ",
|
||||
"alreadyPaused_one": "จอภาพที่เลือกถูกหยุดชั่วคราวแล้ว",
|
||||
"alreadyPaused_other": "จอภาพที่เลือกทั้งหมดถูกหยุดชั่วคราวแล้ว",
|
||||
"alreadyRunning_one": "จอภาพที่เลือกกำลังทำงานอยู่แล้ว",
|
||||
"alreadyRunning_other": "จอภาพที่เลือกทั้งหมดกำลังทำงานอยู่แล้ว"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "URL ของ WebSocket",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "ปลายทาง Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "ตั้งค่าทั่วไป",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์",
|
||||
"grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง",
|
||||
"websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง",
|
||||
"hardware": "ตรวจสอบการใช้ CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐานของคุณ URL ของปลายทาง Capture ต้องรวมเส้นทางแบบเต็ม /api/v1/metrics (ตัวอย่าง: http://192.168.1.10:5001/api/v1/metrics) สำหรับรหัสลับสำหรับยืนยันตัวตน ให้วางเฉพาะสตริงโทเค็น — อย่ารวมคำว่า 'Bearer'"
|
||||
"hardware": "ตรวจสอบการใช้งาน CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐาน"
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "Üye davet et",
|
||||
"openSite": "Siteyi aç",
|
||||
"pause": "Duraklat",
|
||||
"resume": "Devam et",
|
||||
"selectAll": "Tüm monitörleri seç",
|
||||
"selectMonitor": "{{name}}'i seç",
|
||||
"bulkSelected_one": "{{count}} monitör seçildi",
|
||||
"bulkSelected_other": "{{count}} monitör seçildi"
|
||||
"resume": "Devam et"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Aktif süresi",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "Altyapı",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} monitör duraklatıldı",
|
||||
"paused_other": "{{count}} monitör duraklatıldı",
|
||||
"resumed_one": "{{count}} monitör sürdürüldü",
|
||||
"resumed_other": "{{count}} monitör sürdürüldü",
|
||||
"alreadyPaused_one": "Seçili monitör zaten duraklatıldı",
|
||||
"alreadyPaused_other": "Seçili tüm monitörler zaten duraklatıldı",
|
||||
"alreadyRunning_one": "Seçili monitör zaten çalışıyor",
|
||||
"alreadyRunning_other": "Seçili tüm monitörler zaten çalışıyor"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture uç noktası",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Genel ayarlar",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "Web sitenizin sayfa yükleme performansını, Core Web Vitals metriklerini ve optimizasyon puanlarını takip edin.",
|
||||
"grpc": "gRPC sunucusunun ana bilgisayar adını ve portunu girin, isteğe bağlı olarak sağlık kontrolü için bir servis adı belirtin ve bir görünen ad ekleyin.",
|
||||
"websocket": "İzlenecek WebSocket URL'sini girin (örn. wss://example.com/socket) ve bir görünen ad ekleyin.",
|
||||
"hardware": "Altyapınızın CPU, bellek, disk kullanımını ve sıcaklığını izleyin. Capture uç nokta URL'si tam yolu /api/v1/metrics içermelidir (ör. http://192.168.1.10:5001/api/v1/metrics). Yetkilendirme anahtarına yalnızca token dizesini yapıştırın — 'Bearer' eklemeyin."
|
||||
"hardware": "Altyapınız için CPU, bellek, disk kullanımı ve sıcaklığı izleyin."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "Запросити учасника",
|
||||
"openSite": "Відкрити сайт",
|
||||
"pause": "Призупинити",
|
||||
"resume": "Відновити",
|
||||
"selectAll": "Вибрати всі монітори",
|
||||
"selectMonitor": "Вибрати {{name}}",
|
||||
"bulkSelected_one": "Вибрано {{count}} монітор",
|
||||
"bulkSelected_other": "Вибрано {{count}} моніторів"
|
||||
"resume": "Відновити"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Активний протягом",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "Інфраструктура",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "{{count}} монітор призупинено",
|
||||
"paused_other": "{{count}} моніторів призупинено",
|
||||
"resumed_one": "{{count}} монітор відновлено",
|
||||
"resumed_other": "{{count}} моніторів відновлено",
|
||||
"alreadyPaused_one": "Вибраний монітор уже призупинено",
|
||||
"alreadyPaused_other": "Всі вибрані монітори уже призупинено",
|
||||
"alreadyRunning_one": "Вибраний монітор уже запущено",
|
||||
"alreadyRunning_other": "Всі вибрані монітори уже запущено"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Точка доступу Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Загальні налаштування",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.",
|
||||
"grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.",
|
||||
"websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.",
|
||||
"hardware": "Відстежуйте використання ЦП, пам'яті, диска та температуру вашої інфраструктури. URL точки доступу Capture має містити повний шлях /api/v1/metrics (наприклад, http://192.168.1.10:5001/api/v1/metrics). У секрет авторизації вставте лише рядок токена — не додавайте 'Bearer'."
|
||||
"hardware": "Моніторинг CPU, пам'яті, використання диска та температури Вашої інфраструктури."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "Mời thành viên",
|
||||
"openSite": "Mở trang web",
|
||||
"pause": "Tạm dừng",
|
||||
"resume": "Tiếp tục",
|
||||
"selectAll": "Chọn tất cả màn hình",
|
||||
"selectMonitor": "Chọn {{name}}",
|
||||
"bulkSelected_one": "Đã chọn {{count}} màn hình",
|
||||
"bulkSelected_other": "Đã chọn {{count}} màn hình"
|
||||
"resume": "Tiếp tục"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "Hoạt động từ",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "Hạ tầng",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "Đã tạm dừng {{count}} màn hình",
|
||||
"paused_other": "Đã tạm dừng {{count}} màn hình",
|
||||
"resumed_one": "Đã tiếp tục {{count}} màn hình",
|
||||
"resumed_other": "Đã tiếp tục {{count}} màn hình",
|
||||
"alreadyPaused_one": "Màn hình đã chọn đã được tạm dừng",
|
||||
"alreadyPaused_other": "Tất cả các màn hình đã chọn đã được tạm dừng",
|
||||
"alreadyRunning_one": "Màn hình đã chọn đã đang chạy",
|
||||
"alreadyRunning_other": "Tất cả các màn hình đã chọn đã đang chạy"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "URL WebSocket",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Điểm cuối Capture",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "Cài đặt chung",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "Theo dõi hiệu suất tải trang, Core Web Vitals và điểm tối ưu hóa cho trang web của bạn.",
|
||||
"grpc": "Nhập tên máy chủ và cổng của máy chủ gRPC, tùy chọn chỉ định tên dịch vụ cho kiểm tra sức khỏe và thêm tên hiển thị.",
|
||||
"websocket": "Nhập URL WebSocket cần giám sát (vd: wss://example.com/socket) và thêm tên hiển thị.",
|
||||
"hardware": "Giám sát mức sử dụng CPU, bộ nhớ, ổ đĩa và nhiệt độ của hạ tầng. URL điểm cuối Capture phải bao gồm đường dẫn đầy đủ /api/v1/metrics (ví dụ: http://192.168.1.10:5001/api/v1/metrics). Đối với khóa xác thực, chỉ dán chuỗi token — không bao gồm 'Bearer'."
|
||||
"hardware": "Giám sát CPU, bộ nhớ, dung lượng ổ đĩa và nhiệt độ cho hạ tầng của bạn."
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "邀请成员",
|
||||
"openSite": "打开站点",
|
||||
"pause": "暂停",
|
||||
"resume": "恢复",
|
||||
"selectAll": "选择所有监视器",
|
||||
"selectMonitor": "选择 {{name}}",
|
||||
"bulkSelected_one": "已选择 {{count}} 个监视器",
|
||||
"bulkSelected_other": "已选择 {{count}} 个监视器"
|
||||
"resume": "恢复"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "已活跃",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "基础设施",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "已暂停 {{count}} 个监视器",
|
||||
"paused_other": "已暂停 {{count}} 个监视器",
|
||||
"resumed_one": "已恢复 {{count}} 个监视器",
|
||||
"resumed_other": "已恢复 {{count}} 个监视器",
|
||||
"alreadyPaused_one": "所选监视器已暂停",
|
||||
"alreadyPaused_other": "所有选定的监视器均已暂停",
|
||||
"alreadyRunning_one": "所选监视器已经在运行",
|
||||
"alreadyRunning_other": "所有选定的监视器均已在运行"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture 端点",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "常规设置",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。",
|
||||
"grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。",
|
||||
"websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。",
|
||||
"hardware": "监控基础设施的 CPU、内存、磁盘使用情况和温度。Capture 端点 URL 必须包含完整路径 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授权密钥只需粘贴令牌字符串——请勿包含 'Bearer'。"
|
||||
"hardware": "监控基础设施的 CPU、内存、磁盘使用率和温度。"
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -421,11 +421,7 @@
|
||||
"inviteMember": "邀請成員",
|
||||
"openSite": "開啟網站",
|
||||
"pause": "暫停",
|
||||
"resume": "恢復",
|
||||
"selectAll": "選擇所有監視器",
|
||||
"selectMonitor": "選擇 {{name}}",
|
||||
"bulkSelected_one": "已選擇 {{count}} 個監視器",
|
||||
"bulkSelected_other": "已選擇 {{count}} 個監視器"
|
||||
"resume": "恢復"
|
||||
},
|
||||
"statBoxes": {
|
||||
"activeFor": "已啟用",
|
||||
@@ -453,16 +449,6 @@
|
||||
"optionHardware": "基礎架構",
|
||||
"optionGrpc": "gRPC",
|
||||
"optionWebSocket": "WebSocket"
|
||||
},
|
||||
"bulkPause": {
|
||||
"paused_one": "已暫停 {{count}} 個監視器",
|
||||
"paused_other": "已暫停 {{count}} 個監視器",
|
||||
"resumed_one": "已恢復 {{count}} 個監視器",
|
||||
"resumed_other": "已恢復 {{count}} 個監視器",
|
||||
"alreadyPaused_one": "所選監視器已暫停",
|
||||
"alreadyPaused_other": "所有選定的監視器均已暫停",
|
||||
"alreadyRunning_one": "所選監視器已經在運行",
|
||||
"alreadyRunning_other": "所有選定的監視器均已在運行"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -548,10 +534,6 @@
|
||||
"wsUrl": {
|
||||
"label": "WebSocket URL",
|
||||
"placeholder": "wss://example.com/socket"
|
||||
},
|
||||
"captureUrl": {
|
||||
"label": "Capture 端點",
|
||||
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
|
||||
}
|
||||
},
|
||||
"title": "一般設定",
|
||||
@@ -564,7 +546,7 @@
|
||||
"pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。",
|
||||
"grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。",
|
||||
"websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。",
|
||||
"hardware": "監控基礎架構的 CPU、記憶體、磁碟使用情況和溫度。Capture 端點 URL 必須包含完整路徑 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授權密鑰僅貼上權杖字串——請勿包含 'Bearer'。"
|
||||
"hardware": "監控您基礎架構的 CPU、記憶體、磁碟使用量和溫度。"
|
||||
}
|
||||
},
|
||||
"ignoreTls": {
|
||||
|
||||
@@ -18,6 +18,8 @@ services:
|
||||
image: mongo:6.0
|
||||
restart: always
|
||||
command: ["mongod", "--quiet", "--bind_ip_all"]
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- ./mongo/data:/data/db
|
||||
healthcheck:
|
||||
|
||||
@@ -40,11 +40,6 @@ RUN npm run build
|
||||
|
||||
COPY --from=frontend-build /app/client/dist ./public
|
||||
|
||||
RUN chown -R node:node ./public \
|
||||
&& chown -R node:node ./dist \
|
||||
&& chown -R node:node ./scripts
|
||||
|
||||
|
||||
EXPOSE 52345
|
||||
|
||||
CMD ["sh", "-c", "./scripts/inject-vars.sh && node ./dist/index.js"]
|
||||
@@ -26,12 +26,8 @@ RUN cp -r src/templates dist/templates
|
||||
|
||||
COPY --from=frontend-build /app/client/dist ./public
|
||||
|
||||
RUN chmod +x ./scripts/inject-vars.sh \
|
||||
&& chown -R node:node ./public \
|
||||
&& chown -R node:node ./dist \
|
||||
&& chown -R node:node ./scripts
|
||||
|
||||
RUN chmod +x ./scripts/inject-vars.sh
|
||||
|
||||
EXPOSE 52345
|
||||
|
||||
CMD ["sh", "-c", "./scripts/inject-vars.sh && node ./dist/index.js"]
|
||||
CMD ./scripts/inject-vars.sh && node ./dist/index.js
|
||||
@@ -1,114 +0,0 @@
|
||||
# Checkmate API documentation
|
||||
|
||||
Public-facing API documentation that powers **https://checkmate.so/docs**.
|
||||
|
||||
The site is built with [Astro Starlight](https://starlight.astro.build/) — a static-site generator that produces a fast, modern docs site with zero JavaScript by default. Starlight is open source and self-hostable; there is no third-party account required to publish.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
docs/
|
||||
├── api-content/ ← markdown prose (this folder — edit here)
|
||||
│ ├── introduction.md
|
||||
│ ├── quickstart.md
|
||||
│ ├── authentication.md
|
||||
│ ├── conventions.md
|
||||
│ ├── errors.md
|
||||
│ ├── rate-limiting.md
|
||||
│ └── api-reference/
|
||||
│ └── overview.md
|
||||
└── api/ ← Astro Starlight project (build tool — leave alone unless changing the site)
|
||||
├── package.json
|
||||
├── astro.config.mjs
|
||||
├── src/content.config.ts
|
||||
└── src/styles/checkmate.css
|
||||
```
|
||||
|
||||
**Contributors editing prose only need to touch `docs/api-content/`**. Astro's content loader picks up the markdown automatically.
|
||||
|
||||
The endpoint reference (every URL + schema) is **auto-generated from [`server/openapi.json`](../../server/openapi.json)** via the `starlight-openapi` plugin. Adding or changing an endpoint means updating that JSON; the public docs pick it up on the next Vercel build.
|
||||
|
||||
## Local preview
|
||||
|
||||
```bash
|
||||
cd docs/api
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Visit http://localhost:4321/docs. Hot-reload works for markdown changes in `docs/api-content/`.
|
||||
|
||||
To produce the static site:
|
||||
|
||||
```bash
|
||||
npm run build # outputs docs/api/dist/
|
||||
npm run preview # serve the built output locally
|
||||
```
|
||||
|
||||
## Deploying to checkmate.so/docs
|
||||
|
||||
Astro Starlight builds a static site. Deploy on Vercel and reverse-proxy `checkmate.so/docs/*` to it.
|
||||
|
||||
### One-time Vercel setup
|
||||
|
||||
1. Go to https://vercel.com → New Project → import the `bluewave-labs/Checkmate` GitHub repo.
|
||||
2. **Root Directory**: set to `docs/api`.
|
||||
3. **Framework Preset**: Vercel auto-detects Astro from `vercel.json`.
|
||||
4. **Build & Output**: leave defaults (`npm run build` → `dist/`).
|
||||
5. **Production Branch**: `develop`.
|
||||
6. Deploy. Vercel returns a URL like `checkmate-docs.vercel.app`.
|
||||
7. Verify the site renders correctly.
|
||||
|
||||
The deployment auto-rebuilds on every push to `develop` and on every PR (as a preview deploy).
|
||||
|
||||
### Reverse-proxy /docs to Vercel
|
||||
|
||||
In whichever edge proxy fronts `checkmate.so`, route `/docs/*` to the Vercel deployment.
|
||||
|
||||
**Cloudflare Workers:**
|
||||
|
||||
```javascript
|
||||
addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
if (url.pathname === "/docs" || url.pathname.startsWith("/docs/")) {
|
||||
const target = new URL(url.pathname, "https://checkmate-docs.vercel.app");
|
||||
target.search = url.search;
|
||||
return event.respondWith(fetch(target.toString(), event.request));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**nginx:**
|
||||
|
||||
```nginx
|
||||
location /docs/ {
|
||||
proxy_pass https://checkmate-docs.vercel.app/docs/;
|
||||
proxy_set_header Host checkmate-docs.vercel.app;
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
```
|
||||
|
||||
The `base: "/docs"` setting in `astro.config.mjs` ensures every internal link and asset URL is prefixed correctly so the site works at `checkmate.so/docs/...` rather than the root.
|
||||
|
||||
## Updating the docs
|
||||
|
||||
| Task | What to edit |
|
||||
|---|---|
|
||||
| Fix a typo, clarify wording | `docs/api-content/*.md` |
|
||||
| Add a new narrative page | Add the file to `docs/api-content/`, then add a `slug` entry to `astro.config.mjs` → `sidebar` |
|
||||
| Fix something in the API reference | Edit `server/openapi.json` (or the route spec it's built from) — never edit the generated reference pages |
|
||||
| Change branding / colors | `docs/api/src/styles/checkmate.css` |
|
||||
| Change site config (title, logo, social) | `docs/api/astro.config.mjs` |
|
||||
|
||||
Two rules:
|
||||
|
||||
1. **Never hand-edit the API reference.** It's regenerated on every build. Fix the spec.
|
||||
2. **Test code samples against a real instance before merging.** Wrong code in docs is worse than missing code.
|
||||
|
||||
## Why Astro Starlight (and not Mintlify, Docusaurus, etc.)
|
||||
|
||||
- **No third-party account required.** Self-hostable on any static host.
|
||||
- **Best looking** of the modern docs frameworks per multiple 2026 reviews — Distr, among others, migrated away from Docusaurus specifically for Starlight's design.
|
||||
- **Zero JavaScript by default.** Pages are pure HTML — fast load, perfect Lighthouse scores.
|
||||
- **OpenAPI integration is first-class** via `starlight-openapi`.
|
||||
- **Markdown is the source of truth.** No proprietary formats. Easy to migrate later if needed.
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
title: API reference overview
|
||||
description: All endpoints, grouped by resource. Auto-generated from the OpenAPI spec.
|
||||
---
|
||||
|
||||
# API reference
|
||||
|
||||
The full machine-readable specification is at [`server/openapi.json`](https://github.com/bluewave-labs/Checkmate/blob/develop/server/openapi.json). Mintlify renders an interactive reference from it — each endpoint below is fully documented with request/response schemas, examples, and a built-in "Try it" form.
|
||||
|
||||
## Endpoint groups
|
||||
|
||||
| Group | Description |
|
||||
|---|---|
|
||||
| **auth** | Register, log in, password recovery, manage users |
|
||||
| **invite** | Send and redeem team invites |
|
||||
| **monitors** | Create, update, pause, and delete monitors |
|
||||
| **checks** | Read individual check results and team-wide summaries |
|
||||
| **incidents** | List, filter, and resolve downtime incidents |
|
||||
| **maintenance-window** | Schedule maintenance windows to silence checks |
|
||||
| **notifications** | Configure email, Slack, Discord, and webhook channels |
|
||||
| **status-page** | Manage public status pages |
|
||||
| **geo-checks** | Multi-region check results |
|
||||
| **logs** | Audit and operational logs |
|
||||
| **settings** | Global Checkmate settings |
|
||||
| **queue** | Background-job queue health (operational) |
|
||||
| **diagnostic** | System health and version info |
|
||||
|
||||
## Authentication
|
||||
|
||||
Every endpoint except the unauthenticated auth/recovery routes requires:
|
||||
|
||||
```
|
||||
Authorization: Bearer <jwt>
|
||||
```
|
||||
|
||||
See [Authentication](/api/authentication) for how to obtain a token.
|
||||
|
||||
## Conventions recap
|
||||
|
||||
- Base URL: `https://your-checkmate-host/api/v1`
|
||||
- Response envelope: `{ success, msg, data }`
|
||||
- IDs: 24-character MongoDB ObjectIds
|
||||
- Timestamps: ISO 8601 UTC
|
||||
- Durations: integers in milliseconds
|
||||
- Most resources are team-scoped — pass `teamId` where the path includes `/team/...`
|
||||
|
||||
See [Conventions](/api/conventions) for full details.
|
||||
@@ -1,137 +0,0 @@
|
||||
---
|
||||
title: Authentication
|
||||
description: Obtain a JWT from /auth/login and send it as a Bearer token on every subsequent request.
|
||||
---
|
||||
|
||||
# Authentication
|
||||
|
||||
Checkmate uses **JWT bearer tokens**. There are no API keys, no OAuth, and no client secrets. Every API user is a regular Checkmate user — the credentials you log in to the web UI with are the credentials you use to obtain a token.
|
||||
|
||||
## The flow
|
||||
|
||||
1. **Get a token** by calling `POST /auth/login` with your email and password.
|
||||
2. **Send the token** as `Authorization: Bearer <token>` on every subsequent request.
|
||||
3. **Refresh** by calling `POST /auth/login` again before the token expires.
|
||||
|
||||
## Obtaining a token
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-checkmate-host/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "you@example.com",
|
||||
"password": "YourPass1!"
|
||||
}'
|
||||
```
|
||||
|
||||
A successful response returns the token plus the authenticated user record:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"msg": "OK",
|
||||
"data": {
|
||||
"user": {
|
||||
"_id": "65f1c2a4d8b9e0123456789a",
|
||||
"firstName": "Ada",
|
||||
"lastName": "Lovelace",
|
||||
"email": "you@example.com",
|
||||
"role": ["admin"],
|
||||
"teamId": "65f1c2a4d8b9e01234567890",
|
||||
"createdAt": "2026-04-01T10:00:00.000Z",
|
||||
"updatedAt": "2026-04-15T14:30:00.000Z"
|
||||
},
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Store both `token` (for the `Authorization` header) and `user.teamId` (most resource endpoints are scoped by team).
|
||||
|
||||
## Using a token
|
||||
|
||||
Every authenticated request must include:
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
```bash
|
||||
curl https://your-checkmate-host/api/v1/monitors/team/<teamId> \
|
||||
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
```
|
||||
|
||||
```javascript
|
||||
const res = await fetch("https://your-checkmate-host/api/v1/monitors/team/<teamId>", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
```
|
||||
|
||||
## Token lifetime
|
||||
|
||||
Tokens expire after the duration configured by the operator via the `TOKEN_TTL` environment variable. The default is **99 days**.
|
||||
|
||||
There is no separate refresh-token endpoint. When a token is close to expiry — or after a 401 response — call `POST /auth/login` again to obtain a new one.
|
||||
|
||||
## Roles
|
||||
|
||||
The `user.role` claim is an array. Available roles:
|
||||
|
||||
| Role | Capability |
|
||||
|---|---|
|
||||
| `superadmin` | Full access to all teams and global settings. Can promote other users. |
|
||||
| `admin` | Full access within their team. |
|
||||
| `user` | Read access within their team. |
|
||||
| `demo` | Limited demo-mode access. |
|
||||
|
||||
A user can hold multiple roles simultaneously.
|
||||
|
||||
## Registering the first user
|
||||
|
||||
A fresh Checkmate instance has no users. The first call to `POST /auth/register` creates the first **superadmin** and provisions a team for them:
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-checkmate-host/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"firstName": "Ada",
|
||||
"lastName": "Lovelace",
|
||||
"email": "ada@example.com",
|
||||
"password": "Hunter2!",
|
||||
"role": ["superadmin"],
|
||||
"teamId": ""
|
||||
}'
|
||||
```
|
||||
|
||||
After the first user exists, additional users are usually created via the invite flow rather than `POST /auth/register`:
|
||||
|
||||
```
|
||||
POST /invite/send # superadmin/admin sends an invite
|
||||
POST /auth/register # invitee redeems the invite token
|
||||
```
|
||||
|
||||
You can check whether a superadmin already exists with:
|
||||
|
||||
```bash
|
||||
curl https://your-checkmate-host/api/v1/auth/users/superadmin
|
||||
```
|
||||
|
||||
```json
|
||||
{ "success": true, "data": true }
|
||||
```
|
||||
|
||||
## Password recovery
|
||||
|
||||
The password-recovery flow is three calls, all unauthenticated:
|
||||
|
||||
```
|
||||
POST /auth/recovery/request # user requests a recovery email
|
||||
POST /auth/recovery/validate # client validates the recovery token
|
||||
POST /auth/recovery/reset # user sets a new password
|
||||
```
|
||||
|
||||
## Logging out
|
||||
|
||||
JWTs are stateless — there is no server-side logout. Drop the token from your client storage. If you need to invalidate a token before its expiry (e.g. a compromised device), the operator must rotate the `JWT_SECRET` environment variable, which invalidates every outstanding token.
|
||||
|
||||
Next: [Conventions](/api/conventions).
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
title: Conventions
|
||||
description: Response envelope, request format, identifiers, pagination, and team scoping.
|
||||
---
|
||||
|
||||
# Conventions
|
||||
|
||||
A small number of conventions apply to every endpoint. Once you've internalized them, the per-resource reference is mostly self-explanatory.
|
||||
|
||||
## Request format
|
||||
|
||||
- **Content-Type**: `application/json` for all POST/PUT/PATCH bodies, except multipart uploads (logos, profile images), which use `multipart/form-data`.
|
||||
- **Charset**: UTF-8.
|
||||
- **Method semantics**:
|
||||
- `GET` — read, never mutates state.
|
||||
- `POST` — create, or action that doesn't fit GET/PATCH/DELETE.
|
||||
- `PATCH` — partial update.
|
||||
- `PUT` — full replace.
|
||||
- `DELETE` — delete.
|
||||
|
||||
## Response envelope
|
||||
|
||||
Every JSON response uses the same shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"msg": "OK",
|
||||
"data": { /* endpoint-specific payload */ }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `success` | boolean | `true` on 2xx, `false` on 4xx/5xx |
|
||||
| `msg` | string | Human-readable summary — safe to surface in UIs |
|
||||
| `data` | object \| array \| null | Endpoint-specific payload. Absent or `null` for `204 No Content`. |
|
||||
|
||||
Error responses use the same envelope with `success: false` and `data` typically omitted. See [Errors](/api/errors).
|
||||
|
||||
## Identifiers
|
||||
|
||||
- All resource IDs are **MongoDB ObjectIds** (24-character hex strings):
|
||||
```
|
||||
65f1c2a4d8b9e0123456789a
|
||||
```
|
||||
- Treat IDs as opaque strings. Do not attempt to parse the timestamp or counter components.
|
||||
|
||||
## Team scoping
|
||||
|
||||
Most resources belong to a **team**. After login, the user record contains a `teamId`; pass it as a path parameter wherever the endpoint name includes `/team/...`:
|
||||
|
||||
```
|
||||
GET /monitors/team/65f1c2a4d8b9e01234567890
|
||||
GET /incidents/team
|
||||
GET /checks/team
|
||||
```
|
||||
|
||||
Users only see resources belonging to their own team, except for `superadmin`, who can read across teams.
|
||||
|
||||
## Date and time
|
||||
|
||||
- All timestamps in JSON are **ISO 8601** strings in UTC:
|
||||
```
|
||||
"createdAt": "2026-04-01T10:00:00.000Z"
|
||||
```
|
||||
- Durations are typically integers in **milliseconds** (intervals, response times, time windows).
|
||||
|
||||
## Pagination
|
||||
|
||||
Endpoints that return collections accept these optional query parameters:
|
||||
|
||||
| Parameter | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `limit` | integer | endpoint-dependent | Maximum rows to return. |
|
||||
| `page` | integer | `0` | Zero-indexed page number. |
|
||||
| `sortBy` | string | endpoint-dependent | Field to sort by. |
|
||||
| `sortOrder` | `asc` \| `desc` | `desc` | Sort direction. |
|
||||
| `filter` | string | — | Endpoint-specific filter (e.g. status). |
|
||||
|
||||
When a collection supports pagination, the response `data` includes the rows plus the total count:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"msg": "OK",
|
||||
"data": {
|
||||
"monitors": [ /* … */ ],
|
||||
"filteredMonitors": [ /* … */ ],
|
||||
"summary": { /* counters */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Exact shapes vary per endpoint. See the [API reference](/api/reference) for each endpoint's response schema.
|
||||
|
||||
## File uploads
|
||||
|
||||
Two endpoints accept files:
|
||||
|
||||
- Status page logo: `POST /status-page` (multipart, field `logo`)
|
||||
- User profile picture: `POST /auth/register`, `PATCH /auth/user/:id` (multipart, field `profileImage`)
|
||||
|
||||
Use `multipart/form-data` and include the JSON fields as separate form parts.
|
||||
|
||||
## CORS
|
||||
|
||||
The server's CORS policy is configured via the `CLIENT_HOST` environment variable. Only requests whose `Origin` matches `CLIENT_HOST` are accepted, and credentials (cookies) are allowed.
|
||||
|
||||
If you're calling the API from a browser on a different origin, the operator must add that origin to `CLIENT_HOST`. Server-to-server calls (curl, your backend) are not subject to CORS.
|
||||
|
||||
## Versioning
|
||||
|
||||
The base path includes the API version:
|
||||
|
||||
```
|
||||
/api/v1/...
|
||||
```
|
||||
|
||||
Within `v1`, additive changes (new endpoints, new optional fields) ship without notice. Breaking changes (removed endpoints, changed field types) only ship under a new major version.
|
||||
|
||||
Next: [Errors](/api/errors).
|
||||
@@ -1,124 +0,0 @@
|
||||
---
|
||||
title: Errors
|
||||
description: HTTP status codes Checkmate returns, what they mean, and how to handle them.
|
||||
---
|
||||
|
||||
# Errors
|
||||
|
||||
Failure responses share the same envelope as successful ones, with `success: false` and `data` omitted (or `null`):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"msg": "Validation failed: email must be a valid email"
|
||||
}
|
||||
```
|
||||
|
||||
Always treat the **HTTP status code** as the source of truth for handling logic, and surface `msg` to humans.
|
||||
|
||||
## Status codes
|
||||
|
||||
| Status | Meaning | When you'll see it |
|
||||
|---|---|---|
|
||||
| `200 OK` | Request succeeded with a body | Most GETs, successful mutations that return the new resource |
|
||||
| `204 No Content` | Request succeeded with no body | DELETEs, successful actions like `/status-page/:url/unlock` |
|
||||
| `400 Bad Request` | Malformed request | Missing required path param, malformed JSON, invalid file upload |
|
||||
| `401 Unauthorized` | Missing or invalid JWT | Token expired, token missing, token signed with a different secret |
|
||||
| `403 Forbidden` | Authenticated, but not allowed | Accessing a resource that belongs to a different team, or insufficient role |
|
||||
| `404 Not Found` | Resource doesn't exist | Wrong ID, deleted resource, mistyped path |
|
||||
| `409 Conflict` | Operation would violate a constraint | Duplicate email on register, slug already taken on status page |
|
||||
| `413 Payload Too Large` | File upload exceeded limit | Logo or profile image too big |
|
||||
| `422 Unprocessable Entity` | Request was well-formed but failed validation | Zod / Joi schema rejected the body |
|
||||
| `429 Too Many Requests` | Rate limit exceeded | See [Rate limiting](/api/rate-limiting) |
|
||||
| `500 Internal Server Error` | Unexpected server error | A bug — report it with the request ID |
|
||||
|
||||
## Common shapes
|
||||
|
||||
### 401 — missing or invalid token
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"msg": "No auth token provided"
|
||||
}
|
||||
```
|
||||
|
||||
Re-authenticate by calling `POST /auth/login`.
|
||||
|
||||
### 422 — validation error
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"msg": "\"email\" must be a valid email"
|
||||
}
|
||||
```
|
||||
|
||||
The `msg` describes the first failing field. Re-send the request with the fix.
|
||||
|
||||
### 403 — forbidden
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"msg": "Forbidden"
|
||||
}
|
||||
```
|
||||
|
||||
You're authenticated but the resource isn't visible to you. Common cause: passing a `teamId` that isn't your team's.
|
||||
|
||||
### 429 — rate limited
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"msg": "Too many requests"
|
||||
}
|
||||
```
|
||||
|
||||
Inspect `RateLimit-*` response headers and back off. See [Rate limiting](/api/rate-limiting).
|
||||
|
||||
## Error handling pattern
|
||||
|
||||
Client code should branch on status code, not on `msg`:
|
||||
|
||||
```javascript
|
||||
const res = await fetch(url, opts);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
switch (res.status) {
|
||||
case 200:
|
||||
case 204:
|
||||
return body.data;
|
||||
|
||||
case 401:
|
||||
// Token expired or missing — re-authenticate, then retry once.
|
||||
await reauthenticate();
|
||||
return retry();
|
||||
|
||||
case 422:
|
||||
// Surface the validation error to the user.
|
||||
throw new ValidationError(body.msg);
|
||||
|
||||
case 429:
|
||||
// Back off using Retry-After if present.
|
||||
const retryAfter = Number(res.headers.get("Retry-After") ?? 60);
|
||||
await sleep(retryAfter * 1000);
|
||||
return retry();
|
||||
|
||||
default:
|
||||
throw new Error(`Checkmate API ${res.status}: ${body.msg ?? res.statusText}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
If you see a `500`, capture:
|
||||
|
||||
- The exact request (method, path, body — **redact secrets**)
|
||||
- The full response body and headers
|
||||
- A timestamp in UTC
|
||||
|
||||
Open an issue at https://github.com/bluewave-labs/Checkmate/issues with that information.
|
||||
|
||||
Next: [Rate limiting](/api/rate-limiting).
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
title: Introduction
|
||||
description: Use the Checkmate REST API to manage monitors, incidents, status pages, notifications, and users programmatically.
|
||||
---
|
||||
|
||||
# Checkmate API
|
||||
|
||||
Checkmate exposes a REST API that mirrors everything the web UI can do. Use it to provision monitors from infrastructure-as-code, build internal dashboards, automate incident workflows, or integrate Checkmate into your existing alerting stack.
|
||||
|
||||
The web UI you log in to is itself just a client of this same API.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://<your-checkmate-host>/api/v1
|
||||
```
|
||||
|
||||
Replace `<your-checkmate-host>` with the domain where you deployed Checkmate. For local development:
|
||||
|
||||
```
|
||||
http://localhost:52345/api/v1
|
||||
```
|
||||
|
||||
All endpoints are relative to this base. Every path on this page is shown without the `/api/v1` prefix — prepend it when making requests.
|
||||
|
||||
## API stability
|
||||
|
||||
The API is at version **`v1`** and is considered stable. Breaking changes will only ship under a new major version (`/api/v2`).
|
||||
|
||||
## What you can do
|
||||
|
||||
| Resource | What it covers |
|
||||
|---|---|
|
||||
| `monitors` | Create, update, pause, and delete monitors of any type (HTTP, Ping, Port, DNS, Docker, PageSpeed, gRPC, WebSocket, Game, Hardware) |
|
||||
| `checks` | Read individual check results and aggregated stats |
|
||||
| `incidents` | List, filter, and resolve downtime incidents |
|
||||
| `notifications` | Manage notification channels (email, Slack, Discord, webhooks) |
|
||||
| `status-page` | Configure public status pages |
|
||||
| `maintenance-window` | Schedule maintenance windows so checks don't fire incidents |
|
||||
| `geo-checks` | Multi-region check results |
|
||||
| `auth` | Register, log in, manage users |
|
||||
| `invite` | Invite teammates |
|
||||
| `logs` | Audit and operational logs |
|
||||
| `settings` | Tune global Checkmate settings |
|
||||
| `queue` | Inspect the background-job queue (operational) |
|
||||
| `diagnostic` | Health and system info |
|
||||
|
||||
## What you'll need
|
||||
|
||||
1. A running Checkmate instance you administer.
|
||||
2. A user account on that instance.
|
||||
3. A JWT obtained by calling `POST /auth/login` — see [Authentication](/api/authentication).
|
||||
|
||||
There is no separate "API key" concept in Checkmate. The same JWT that authenticates the web UI authenticates every API call.
|
||||
|
||||
## Response shape
|
||||
|
||||
Every endpoint returns the same envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"msg": "OK",
|
||||
"data": { /* endpoint-specific payload */ }
|
||||
}
|
||||
```
|
||||
|
||||
See [Conventions](/api/conventions) for the full contract and [Errors](/api/errors) for failure shapes.
|
||||
|
||||
## Quick example
|
||||
|
||||
```bash
|
||||
# 1. Log in to get a token
|
||||
TOKEN=$(curl -s -X POST https://your-checkmate-host/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"you@example.com","password":"YourPass1!"}' \
|
||||
| jq -r '.data.token')
|
||||
|
||||
# 2. List your team's monitors
|
||||
curl https://your-checkmate-host/api/v1/monitors/team/<teamId> \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
Next: [Authentication](/api/authentication).
|
||||
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user