Compare commits

..
Author SHA1 Message Date
Alexander Holliday 7ee6d342e9 Merge pull request #3606 from bluewave-labs/develop
develop -> master
2026-04-29 12:11:29 -07:00
Alexander Holliday 49d3737a93 Merge pull request #3604 from bluewave-labs/develop
develop -> master
2026-04-29 11:51:43 -07:00
Alexander Holliday 4402e4fbfa Merge pull request #3567 from bluewave-labs/develop
develop -> master
2026-04-23 11:26:21 -07:00
Alexander Holliday 721f77ed26 Merge pull request #3565 from bluewave-labs/develop
develop -> master
2026-04-23 10:15:31 -07:00
Alexander Holliday 34f29e5ae6 Merge pull request #3563 from bluewave-labs/develop
develop -> master
2026-04-23 09:52:58 -07:00
Alexander Holliday c2170267e8 Merge pull request #3394 from bluewave-labs/develop
develop -> master 3.5.1 release
2026-03-06 12:56:20 -08:00
Alexander Holliday 321ca59380 Merge pull request #3368 from bluewave-labs/develop
Develop -> master (3.5.0)
2026-03-04 10:41:02 -08:00
Alexander Holliday e3ecfe6ad8 Merge pull request #3323 from bluewave-labs/develop
develop -> master
2026-02-20 12:38:33 -08:00
Alexander Holliday f4664eec16 Merge pull request #3321 from bluewave-labs/develop
develop->master
2026-02-20 12:20:49 -08:00
Alexander Holliday 56f601e34b Merge pull request #3319 from bluewave-labs/develop
develop -> master
2026-02-20 12:07:29 -08:00
Alexander Holliday 1244ff6d93 Merge pull request #3212 from bluewave-labs/develop
develop -> master
2026-01-27 13:58:48 -08:00
Alexander Holliday c0ea804c98 Merge pull request #3209 from bluewave-labs/develop
develop -> master
2026-01-27 13:40:25 -08:00
Alexander Holliday c21cf29db6 Merge pull request #3207 from bluewave-labs/develop
develop -> master
2026-01-27 13:20:18 -08:00
Alexander Holliday 3d0da70ce6 Merge pull request #3204 from bluewave-labs/develop
develop -> master
2026-01-27 13:09:47 -08:00
Alexander Holliday feb061240b Merge pull request #3042 from bluewave-labs/develop
develp->master
2025-10-27 12:09:00 -07:00
Alexander Holliday a924227805 Merge pull request #2964 from bluewave-labs/develop
develop -> master
2025-09-18 13:40:45 -07:00
Alexander Holliday 2144d3f8c0 Merge pull request #2962 from bluewave-labs/develop
develop -> master
2025-09-18 12:04:51 -07:00
Alexander Holliday fcd6786149 Merge pull request #2936 from bluewave-labs/develop
develop -> master
2025-09-08 20:43:21 -07:00
Alexander Holliday ea968ebf3c Merge pull request #2933 from bluewave-labs/develop
develop -> master
2025-09-08 20:33:35 -07:00
Alexander Holliday 8a7b852974 Merge pull request #2929 from bluewave-labs/develop
develop -> master
2025-09-08 07:24:16 -07:00
Alexander Holliday c8d3e3b70c Merge pull request #2921 from bluewave-labs/develop
develop -> master
2025-09-04 15:46:30 -07:00
Alexander Holliday 7a90a6f9f4 Merge pull request #2915 from bluewave-labs/develop
develop->master
2025-09-01 10:33:09 -07:00
181 changed files with 6185 additions and 27492 deletions

No files matched your search

Executable → Regular
+2 -21
View File
@@ -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 ..
+2 -14
View File
@@ -159,26 +159,14 @@ When working on anything related to check scheduling, incident lifecycle, or not
## Code Conventions
### Coding conventions (mandatory)
Read `docs/coding-conventions.md` before touching any `.tsx` or `.ts` file. The doc covers both frontend and backend rules, all enforced in code review.
**Universal — rule 0:** look at peer files first and follow the established pattern. Don't sneak in novel shapes.
**Frontend (`client/src`):**
### Frontend conventions (mandatory for `client/src`)
Read `docs/frontend-conventions.md` before touching any `.tsx` file. Five rules, all enforced in code review:
1. Prefer MUI native props over `sx` (e.g. `color={…}`, `bgcolor={…}`, `mt={…}` — not `sx={{ color, bgcolor, mt }}`).
2. Use the full theme path for colors: `color={theme.palette.text.secondary}`, never `color="text.secondary"` (greppability).
3. No hardcoded literals — use `LAYOUT.*`, `typographyLevels.*`, `theme.shape.borderRadius`, `theme.palette.*`.
4. Use `useTheme()` inside components; don't `import { theme } from "@/Utils/Theme/Theme"`.
5. Pair runtime tuples with derived types: `const X = [...] as const; type X = (typeof X)[number]`.
**Backend (`server/src`):**
6. Layering: Controller → Service → Repository → Mongoose. No DB calls outside repositories.
7. Provider conventions: DI for stdlib clients, `SERVICE_NAME` + `TIMEOUT_MS` constants, `timeRequest()` helper, inline `setTimeout` race for timeout, outer try/catch into `AppError`.
8. Mongoose schema fields with closed value sets must declare `enum` reusing the same `types/*` const tuple.
9. Provider tests at `server/test/unit/providers/network/<name>.test.ts`, `from "@jest/globals"`, `testStatusProviderContract`, inline `setup()` per test (not `beforeEach`).
10. Centralize validation enums in `types/*` const tuples; never inline `z.enum([…])` more than once.
11. New entity fields must land in the validator's `*ResponseSchema` so the auto-generated OpenAPI spec sees them.
### Internationalization
All user-facing strings must use the translation function:
```javascript
+22 -8
View File
@@ -7,6 +7,7 @@
![](https://img.shields.io/github/languages/top/bluewave-labs/checkmate)
![](https://img.shields.io/github/issues/bluewave-labs/checkmate)
![](https://img.shields.io/github/issues-pr/bluewave-labs/checkmate)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9901/badge)](https://www.bestpractices.dev/projects/9901)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate)
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
@@ -15,10 +16,7 @@
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](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
+28 -43
View File
@@ -18,7 +18,7 @@
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
"@types/maplibre-gl": "^1.13.2",
"axios": "^1.15.2",
"axios": "^1.7.4",
"dayjs": "1.11.13",
"html2canvas": "^1.4.1",
"human-interval": "2.0.1",
@@ -59,7 +59,7 @@
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "^6.4.2"
"vite": "6.4.1"
}
},
"node_modules/@babel/code-frame": {
@@ -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
View File
@@ -27,7 +27,7 @@
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
"@types/maplibre-gl": "^1.13.2",
"axios": "^1.15.2",
"axios": "^1.7.4",
"dayjs": "1.11.13",
"html2canvas": "^1.4.1",
"human-interval": "2.0.1",
@@ -68,6 +68,6 @@
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "^6.4.2"
"vite": "6.4.1"
}
}
@@ -5,7 +5,7 @@ import { getResponseColor } from "@/Utils/DataUtils";
import { HeatmapResponseTimeTooltip } from "@/Components/common/charts/HeatmapResponseTimeTooltip";
import type { SxProps } from "@mui/material/styles";
import type { ResponsiveStyleValue } from "@mui/system";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface PlaceholderCheck {
status: "placeholder";
responseTime: 0;
@@ -32,11 +32,11 @@ export const HeatmapResponseTime = ({
if (!checks || checks.length === 0) return null;
const latestChecks = checks.slice(-MAX_RECENT_CHECKS);
const latestChecks = checks.slice(-25);
let data: Array<CheckSnapshot | PlaceholderCheck>;
if (latestChecks.length !== MAX_RECENT_CHECKS) {
const placeholders = Array(MAX_RECENT_CHECKS - latestChecks.length).fill({
if (latestChecks.length !== 25) {
const placeholders = Array(25 - latestChecks.length).fill({
status: "placeholder" as const,
});
data = [...latestChecks, ...placeholders];
@@ -44,13 +44,13 @@ export const HeatmapResponseTime = ({
data = latestChecks;
}
return (
<Box width={"100%"}>
<Box sx={{ width: "100%" }}>
<Box
width={"100%"}
display={"grid"}
gap={gap}
sx={{
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gap,
alignItems: "stretch",
}}
>
@@ -82,50 +82,50 @@ export const HeatmapResponseTime = ({
check={check}
>
<Box
display={"grid"}
sx={{
display: "grid",
gridTemplateRows: "auto auto",
}}
>
<Box
display={"flex"}
gap={theme.spacing(2)}
sx={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(2),
}}
>
<Box
width={"100%"}
sx={{
position: "relative",
width: "100%",
aspectRatio: "10",
}}
>
<Box
bgcolor={statusBg}
borderRadius={theme.shape.borderRadius}
sx={{
position: "absolute",
inset: 0,
bgcolor: statusBg,
borderRadius: theme.spacing(0.5),
...availabilityCellSx,
}}
/>
<Box
borderRadius={theme.shape.borderRadius}
sx={{
position: "absolute",
inset: 0,
pointerEvents: "none",
borderRadius: theme.spacing(0.5),
boxShadow: `inset 0 0 0 2px ${statusBorder}`,
}}
/>
</Box>
<Box
width={"100%"}
bgcolor={respBg}
borderRadius={theme.shape.borderRadius}
sx={{
width: "100%",
aspectRatio: "1 / 1",
bgcolor: respBg,
borderRadius: theme.spacing(0.5),
...responseCellSx,
}}
/>
@@ -7,7 +7,6 @@ import type { CheckSnapshot } from "@/Types/Check";
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface HistogramResponseTimeProps {
checks: CheckSnapshot[];
@@ -58,11 +57,8 @@ export const HistogramResponseTime = ({
if (!Array.isArray(checks) || checks.length === 0) return null;
const data =
checks.length !== MAX_RECENT_CHECKS
? [
...checks,
...Array(MAX_RECENT_CHECKS - checks.length).fill({ status: "placeholder" }),
]
checks.length !== 25
? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })]
: checks;
const chartHeight = typeof height === "number" ? `${height}px` : height;
@@ -90,17 +86,17 @@ export const HistogramResponseTime = ({
<Stack
direction={statsPosition === "left" ? "row-reverse" : "row"}
alignItems="center"
width={"100%"}
sx={{ width: "100%" }}
>
<Box sx={{ flex: 1 }}>
<Box
width={"100%"}
display={"grid"}
gap={gridGap}
height={chartHeight}
sx={{
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gridGap,
alignItems: "end",
height: chartHeight,
}}
>
{data.map((check, index) => {
@@ -112,23 +108,23 @@ export const HistogramResponseTime = ({
: theme.palette.error.light;
const bar = (
<Box
width={"100%"}
height={"100%"}
borderRadius={theme.shape.borderRadius}
bgcolor={theme.palette.action.hover}
sx={{
position: "relative",
width: "100%",
height: "100%",
borderRadius: theme.spacing(1),
bgcolor: theme.palette.action.hover,
overflow: "hidden",
}}
>
<Box
width={"100%"}
height={isPlaceholder ? 0 : heightPct}
bgcolor={barColor}
sx={{
position: "absolute",
bottom: 0,
left: 0,
width: "100%",
height: isPlaceholder ? 0 : heightPct,
bgcolor: barColor,
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
@@ -137,8 +133,8 @@ export const HistogramResponseTime = ({
return isPlaceholder ? (
<Box
height={"100%"}
key={index}
sx={{ height: "100%" }}
>
{bar}
</Box>
@@ -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>
-2
View File
@@ -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";
+1 -3
View File
@@ -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;
-94
View File
@@ -1,94 +0,0 @@
import { useState, useEffect } from "react";
import axios from "axios";
import { post } from "@/Utils/ApiClient";
import { useToast } from "@/Hooks/UseToast";
import { useTranslation } from "react-i18next";
import { logger } from "@/Utils/logger";
import type { Monitor } from "@/Types/Monitor";
interface ApiResponse {
success: boolean;
msg: string;
data: Monitor[];
}
interface UseBulkMonitorActionsReturn {
selectedRows: string[];
setSelectedRows: (rows: string[]) => void;
handleBulkPause: () => Promise<void>;
handleBulkResume: () => Promise<void>;
handleCancelSelection: () => void;
}
export const useBulkMonitorActions = (
refetch: () => void,
page?: number
): UseBulkMonitorActionsReturn => {
const [selectedRows, setSelectedRows] = useState<string[]>([]);
const { toastSuccess, toastError, toastInfo } = useToast();
const { t } = useTranslation();
// Clear selection when page changes
useEffect(() => {
setSelectedRows([]);
}, [page]);
const executeBulkAction = async (pause: boolean) => {
try {
const res = await post<ApiResponse>("/monitors/bulk/pause", {
monitorIds: selectedRows,
pause,
});
const affectedCount = res.data?.data?.length ?? 0;
if (affectedCount === 0) {
const key = pause
? "pages.common.monitors.bulkPause.alreadyPaused"
: "pages.common.monitors.bulkPause.alreadyRunning";
toastInfo(t(key, { count: selectedRows.length }));
} else {
const key = pause
? "pages.common.monitors.bulkPause.paused"
: "pages.common.monitors.bulkPause.resumed";
toastSuccess(t(key, { count: affectedCount }));
}
setSelectedRows([]);
refetch();
} catch (err: unknown) {
let errMsg = "An error occurred";
if (axios.isAxiosError(err)) {
errMsg = err.response?.data?.msg || err.message || errMsg;
} else if (err instanceof Error) {
errMsg = err.message;
}
logger.error("Bulk pause/resume failed", err instanceof Error ? err : undefined, {
pause,
});
toastError(errMsg);
}
};
const handleBulkPause = async () => {
await executeBulkAction(true);
};
const handleBulkResume = async () => {
await executeBulkAction(false);
};
const handleCancelSelection = () => {
setSelectedRows([]);
};
return {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
};
};
+2 -6
View File
@@ -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]);
};
-11
View File
@@ -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,
-1
View File
@@ -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,
+1 -2
View File
@@ -6,7 +6,6 @@ import {
DEFAULT_STATUS_PAGE_THEME_MODE,
} from "@/Types/StatusPage";
import type { Monitor } from "@/Types/Monitor";
import { resolveTimezone } from "@/Utils/TimeUtils";
interface UseStatusPageFormOptions {
data?: StatusPage | null;
@@ -31,7 +30,7 @@ export const useStatusPageForm = ({
const defaults: StatusPageFormData = {
companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(),
timezone: resolveTimezone(data?.timezone),
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
type: data?.type || ["uptime"],
color: data?.color || "#13715B",
monitors: data?.monitors || [],
@@ -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,
+3 -148
View File
@@ -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}
+25 -11
View File
@@ -10,7 +10,6 @@ import type { QueueData } from "@/Types/Queue";
import { Metrics } from "@/Pages/Logs/components/Metrics";
import { Button } from "@/Components/inputs";
import { EmptyState } from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
export const TabQueue = () => {
const theme = useTheme();
@@ -20,7 +19,7 @@ export const TabQueue = () => {
isLoading,
// error,
refetch,
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 1000 });
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 5000 });
const { post, loading: isFlushing } = usePost();
@@ -50,22 +49,32 @@ export const TabQueue = () => {
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
color="text.secondary"
>
{t("pages.logs.jobQueue")}
</Typography>
<Typography color={theme.palette.text.secondary}>
<Typography
sx={{
fontSize: 13,
color: theme.palette.text.secondary,
}}
>
<Trans
i18nKey="pages.logs.jobQueueExplainer"
components={{
highlight: (
<Box
component="span"
bgcolor={theme.palette.rowStatus.running}
color={theme.palette.text.primary}
px={SPACING.SM}
py={SPACING.XXS}
borderRadius={theme.shape.borderRadius}
sx={{
backgroundColor:
theme.palette.mode === "dark"
? "rgba(19, 113, 91, 0.18)"
: "#ECF7F2",
color: theme.palette.text.primary,
px: 1,
py: 0.25,
borderRadius: 0.5,
}}
/>
),
}}
@@ -78,11 +87,16 @@ export const TabQueue = () => {
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
color="text.secondary"
>
{t("pages.logs.failedJobs")}
</Typography>
<Typography color={theme.palette.text.secondary}>
<Typography
sx={{
fontSize: 13,
color: theme.palette.text.secondary,
}}
>
{t("pages.logs.failedJobsExplainer")}
</Typography>
</Stack>
+10 -41
View File
@@ -30,22 +30,6 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
};
const headers: Header<QueueJobWithId>[] = [
{
id: "state",
content: t("pages.logs.table.headers.state"),
render: (row) => {
if (row.lockedAt) {
return t("common.labels.running");
}
if (row.monitorActive === true) {
return t("common.labels.active");
}
if (row.monitorActive === false) {
return t("common.labels.paused");
}
return t("common.labels.idle");
},
},
{
id: "id",
content: t("common.table.headers.monitorId"),
@@ -63,10 +47,7 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
id: "url",
content: t("common.table.headers.url"),
render: (row) => (
<Typography
title={row.monitorUrl ?? ""}
sx={cellSx}
>
<Typography title={row.monitorUrl ?? ""} sx={cellSx}>
{row.monitorUrl}
</Typography>
),
@@ -79,15 +60,9 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
{
id: "interval",
content: t("common.table.headers.interval"),
render: (row) => {
let interval;
if (row.monitorId.toString().includes("geo")) {
interval = row.monitorGeoInterval ?? 0;
} else {
interval = row.repeat ?? 0;
}
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
},
render: (row) => (
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
),
},
{
id: "lastRun",
@@ -95,10 +70,7 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
render: (row) => {
const v = formatTimestamp(row.lastRunAt) ?? "-";
return (
<Typography
title={v}
sx={cellSx}
>
<Typography title={v} sx={cellSx}>
{v}
</Typography>
);
@@ -118,10 +90,7 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
render: (row) => {
const v = formatTimestamp(row.lockedAt) ?? "-";
return (
<Typography
title={v}
sx={cellSx}
>
<Typography title={v} sx={cellSx}>
{v}
</Typography>
);
@@ -129,16 +98,16 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
},
];
const isDark = theme.palette.mode === "dark";
const runningBg = isDark ? "rgba(19, 113, 91, 0.18)" : "#ECF7F2";
return (
<Table
headers={headers}
data={jobsWithId}
getRowSx={(row) => ({
...(row.lockedAt && {
"& td": { backgroundColor: theme.palette.rowStatus.running },
}),
...(row.monitorActive === false && {
"& td": { backgroundColor: theme.palette.rowStatus.paused },
"& td": { backgroundColor: runningBg },
}),
})}
/>
@@ -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;
},
},
{
+1 -2
View File
@@ -3,7 +3,6 @@ import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common";
import type { CheckSnapshot } from "@/Types/Check";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface DummyChartProps {
chartType: string;
@@ -11,7 +10,7 @@ interface DummyChartProps {
const generateDummyChecks = (): CheckSnapshot[] => {
const checks: CheckSnapshot[] = [];
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
for (let i = 0; i < 25; i++) {
const isUp = Math.random() > 0.1;
const responseTime = Math.floor(Math.random() * 80) + 20;
checks.push({
+40 -82
View File
@@ -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"
+3 -9
View File
@@ -32,7 +32,6 @@ import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom";
import { mutate } from "swr";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
import { ThemePicker } from "./Components/ThemePicker";
@@ -50,9 +49,6 @@ interface TimezoneOption {
name: string;
}
const buildStatusPageKey = (slug: string | null | undefined) =>
slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
const CreateStatusPage = () => {
const theme = useTheme();
const { t } = useTranslation();
@@ -63,7 +59,9 @@ const CreateStatusPage = () => {
// Fetch existing status page data when configuring
const { data: statusPageData, isLoading: isLoadingStatusPage } =
useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url));
useGet<StatusPageResponse>(
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
);
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
const monitors = monitorsResponse ?? [];
@@ -183,10 +181,6 @@ const CreateStatusPage = () => {
}
if (result) {
await mutate(buildStatusPageKey(data.url));
if (!isCreate && url !== data.url) {
await mutate(buildStatusPageKey(url));
}
navigate(`/status/${data.url}`);
}
};
+24 -48
View File
@@ -8,56 +8,35 @@ import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useLocation, useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import { resolveStatusPageTheme } from "@/Types/StatusPage";
import {
PUBLIC_STATUS_PAGE_PREFIX,
type StatusPage,
type StatusPageResponse,
type StatusPageTheme,
} from "@/Types/StatusPage";
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import {
BaseStatusPage,
type ThemeConfig,
} from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import { BrowserFrame } from "@/Pages/StatusPage/Status/themes/BrowserFrame";
import { refinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
import { RefinedHeader } from "@/Pages/StatusPage/Status/themes/refined/RefinedHeader";
import { RefinedHero } from "@/Pages/StatusPage/Status/themes/refined/RefinedHero";
import { modernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
import { ModernHeader } from "@/Pages/StatusPage/Status/themes/modern/ModernHeader";
import { ModernHero } from "@/Pages/StatusPage/Status/themes/modern/ModernHero";
import { boldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
import { BoldHeader } from "@/Pages/StatusPage/Status/themes/bold/BoldHeader";
import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero";
import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader";
import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero";
import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "./themes/StatusPageThemeProvider";
import { RefinedStatusPage } from "./themes/refined/RefinedStatusPage";
import { ModernStatusPage } from "./themes/modern/ModernStatusPage";
import { BoldStatusPage } from "./themes/bold/BoldStatusPage";
import { EditorialStatusPage } from "./themes/editorial/EditorialStatusPage";
import { BrowserFrame } from "./themes/BrowserFrame";
const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
refined: {
createStyles: refinedStyles,
HeaderSlot: RefinedHeader,
HeroSlot: RefinedHero,
},
modern: {
createStyles: modernStyles,
HeaderSlot: ModernHeader,
HeroSlot: ModernHero,
overallStatusOptions: { iconSize: 20 },
},
bold: {
createStyles: boldStyles,
HeaderSlot: BoldHeader,
HeroSlot: BoldHero,
overallStatusOptions: { iconSize: 18 },
},
editorial: {
createStyles: editorialStyles,
HeaderSlot: EditorialHeader,
HeroSlot: EditorialHero,
overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" },
},
type ThemedRendererProps = {
statusPage: StatusPage;
monitors: (Monitor & { checks?: Monitor["recentChecks"] })[];
};
const THEMED_RENDERERS: Record<
StatusPageTheme,
React.ComponentType<ThemedRendererProps>
> = {
refined: RefinedStatusPage,
modern: ModernStatusPage,
bold: BoldStatusPage,
editorial: EditorialStatusPage,
};
const StatusPageView = () => {
@@ -111,12 +90,11 @@ const StatusPageView = () => {
);
}
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const ThemedRenderer = THEMED_RENDERERS[resolveStatusPageTheme(statusPage.theme)];
const themedRenderer = (
<BaseStatusPage
<ThemedRenderer
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
);
@@ -126,7 +104,6 @@ const StatusPageView = () => {
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
paintBody
>
{themedRenderer}
@@ -150,7 +127,6 @@ const StatusPageView = () => {
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
transparent
>
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
@@ -13,7 +13,6 @@ import {
type StatusPageTheme,
type StatusPageThemeMode,
} from "@/Types/StatusPage";
import { resolveTimezone } from "@/Utils/TimeUtils";
import { themeTokens, type StatusPageThemeTokens } from "./tokens";
type ResolvedMode = "light" | "dark";
@@ -22,7 +21,6 @@ interface StatusPageThemeContextValue {
theme: StatusPageTheme;
mode: ResolvedMode;
tokens: StatusPageThemeTokens;
timezone: string;
}
const StatusPageThemeContext = createContext<StatusPageThemeContextValue | null>(null);
@@ -35,7 +33,6 @@ const resolveSystemMode = (): ResolvedMode => {
interface Props {
theme?: StatusPageTheme;
themeMode?: StatusPageThemeMode;
timezone?: string;
/**
* When true, paint the document body with the theme background so it
* covers beyond the provider's wrapper (useful on the public route
@@ -57,14 +54,12 @@ interface Props {
export const StatusPageThemeProvider = ({
theme,
themeMode,
timezone,
paintBody = false,
transparent = false,
children,
}: Props) => {
const resolvedTheme = resolveStatusPageTheme(theme);
const resolvedThemeMode = resolveStatusPageThemeMode(themeMode);
const resolvedTimezone = resolveTimezone(timezone);
const [systemMode, setSystemMode] = useState<ResolvedMode>(resolveSystemMode);
@@ -112,13 +107,8 @@ export const StatusPageThemeProvider = ({
const tokens = themeTokens[resolvedTheme][resolvedMode];
const value = useMemo(
() => ({
theme: resolvedTheme,
mode: resolvedMode,
tokens,
timezone: resolvedTimezone,
}),
[resolvedTheme, resolvedMode, tokens, resolvedTimezone]
() => ({ theme: resolvedTheme, mode: resolvedMode, tokens }),
[resolvedTheme, resolvedMode, tokens]
);
return (
@@ -146,7 +136,6 @@ export const useStatusPageTheme = (): StatusPageThemeContextValue => {
theme: DEFAULT_STATUS_PAGE_THEME,
mode: resolveSystemMode(),
tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()],
timezone: resolveTimezone(),
};
}
return ctx;
@@ -1,20 +0,0 @@
import Box from "@mui/material/Box";
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
export const BoldHeader = ({ statusPage, logoSrc, styles }: SlotProps<BoldStyles>) => (
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoConic}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
);
@@ -1,32 +0,0 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
export const BoldHero = ({ overall, monitorCount, styles }: SlotProps<BoldStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box
component="h1"
sx={styles.heroTitle}
>
<Box
component="span"
sx={styles.heroCheck(overall.tone)}
>
{overall.icon}
</Box>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</Box>
);
};
@@ -0,0 +1,207 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
monitorBadgeTone,
monoFirstChar,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { boldStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
export const BoldStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens, mode } = useStatusPageTheme();
const styles = useMemo(() => boldStyles(tokens, mode === "dark"), [tokens, mode]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t, { iconSize: 18 });
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoConic}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
</Box>
<Box sx={styles.hero}>
<Box
component="h1"
sx={styles.heroTitle}
>
<Box
component="span"
sx={styles.heroCheck(overall.tone)}
>
{overall.icon}
</Box>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
)}
<Stack
component="ul"
sx={styles.monitorList}
>
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
return (
<Box
component="li"
key={monitor.id}
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
sxApi={{
containerSx: styles.infra,
emptySx: styles.infraEmpty,
gaugeSx: styles.gauge,
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
sx={styles.footer}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -2,7 +2,6 @@ import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type BoldBarKind = "up" | "down" | "empty";
@@ -298,7 +297,7 @@ export const boldStyles = (
heatmap: {
padding: "0 28px 22px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
height: 48,
},
@@ -314,7 +313,7 @@ export const boldStyles = (
histogram: {
padding: "0 28px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
alignItems: "flex-end",
height: 48,
@@ -1,36 +0,0 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
export const EditorialHeader = ({
statusPage,
logoSrc,
styles,
}: SlotProps<EditorialStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.brandWrap}>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
)}
<Box
component="h2"
sx={styles.brandEyebrow}
>
{statusPage.companyName}
</Box>
<Box
component="h1"
sx={styles.brandTitle}
>
{t("pages.statusPages.editorial.reportTitle")}
</Box>
</Box>
);
};
@@ -1,46 +0,0 @@
import { useMemo } from "react";
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
export const EditorialHero = ({
overall,
monitorCount,
styles,
}: SlotProps<EditorialStyles>) => {
const { t } = useTranslation();
const todayLabel = useMemo(
() =>
new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
}),
[]
);
return (
<>
<Box
component="p"
sx={styles.statusLine}
>
<Box
component="span"
sx={styles.statusDot(overall.tone)}
/>
{overall.message}
</Box>
<Box
component="p"
sx={styles.dateline}
>
{todayLabel} ·{" "}
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</>
);
};
@@ -0,0 +1,224 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
monitorBadgeTone,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { editorialStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
export const EditorialStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens } = useStatusPageTheme();
const styles = useMemo(() => editorialStyles(tokens), [tokens]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const todayLabel = useMemo(
() =>
new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
}),
[]
);
const overall = resolveOverallStatus(monitors, t, {
allUpKey: "pages.statusPages.editorial.allUp",
});
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brandWrap}>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
)}
<Box
component="h2"
sx={styles.brandEyebrow}
>
{statusPage.companyName}
</Box>
<Box
component="h1"
sx={styles.brandTitle}
>
{t("pages.statusPages.editorial.reportTitle")}
</Box>
</Box>
</Box>
<Box
component="p"
sx={styles.statusLine}
>
<Box
component="span"
sx={styles.statusDot(overall.tone)}
/>
{overall.message}
</Box>
<Box
component="p"
sx={styles.dateline}
>
{todayLabel} ·{" "}
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
)}
<Stack
component="ul"
sx={styles.monitorList}
>
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
return (
<Box
component="li"
key={monitor.id}
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
sxApi={{
containerSx: styles.infra,
emptySx: styles.infraEmpty,
gaugeSx: styles.gauge,
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
sx={styles.footer}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -6,7 +6,6 @@ import {
MONO_STACK,
SERIF_STACK,
} from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type EditorialBarKind = "up" | "down" | "empty";
@@ -51,10 +50,7 @@ export interface EditorialStyles {
footer: SxProps<Theme>;
}
export const editorialStyles = (
tokens: StatusPageThemeTokens,
_isDark: boolean
): EditorialStyles => {
export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles => {
const heatCellBg: Record<EditorialHeatCell, string> = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
@@ -249,7 +245,7 @@ export const editorialStyles = (
heatmap: {
mt: "16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "2px",
height: 40,
},
@@ -261,7 +257,7 @@ export const editorialStyles = (
histogram: {
mt: "16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "2px",
alignItems: "flex-end",
height: 40,
@@ -1,24 +0,0 @@
import Box from "@mui/material/Box";
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
export const ModernHeader = ({
statusPage,
logoSrc,
styles,
}: SlotProps<ModernStyles>) => (
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoGrad}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
);
@@ -1,34 +0,0 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
export const ModernHero = ({
overall,
monitorCount,
styles,
}: SlotProps<ModernStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box sx={styles.pulse(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
);
};
@@ -2,125 +2,80 @@ import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { SxProps, Theme } from "@mui/material/styles";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
ThemedHeatmap,
type HeatCellKind,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
import {
ThemedHistogram,
type BarKind,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
import {
ThemedInfrastructure,
type GaugeFillLevel,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure";
import {
type OverallStatus,
type OverallTone,
monitorBadgeTone,
monoFirstChar,
resolveOverallStatus,
statusBadgeKey,
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { modernStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
export interface BaseStyles {
page: SxProps<Theme>;
top: SxProps<Theme>;
chartSwitchWrap: SxProps<Theme>;
chartSwitch: SxProps<Theme>;
chartSwitchButton: (active: boolean) => SxProps<Theme>;
monitorList: SxProps<Theme>;
card: SxProps<Theme>;
cardRow: SxProps<Theme>;
cardLeft: SxProps<Theme>;
monitorName: SxProps<Theme>;
monitorMeta: SxProps<Theme>;
pill: SxProps<Theme>;
pillHardware: SxProps<Theme>;
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
gauge: SxProps<Theme>;
gaugeLabel: SxProps<Theme>;
gaugeValue: SxProps<Theme>;
gaugeBar: SxProps<Theme>;
gaugeFill: (level: GaugeFillLevel, widthPct: number) => SxProps<Theme>;
gaugeSub: SxProps<Theme>;
footer: SxProps<Theme>;
}
export interface SlotProps<S extends BaseStyles = BaseStyles> {
statusPage: StatusPage;
logoSrc: string | null;
overall: OverallStatus;
monitorCount: number;
styles: S;
}
export interface ThemeConfig<S extends BaseStyles = BaseStyles> {
createStyles: (tokens: StatusPageThemeTokens, isDark: boolean) => S;
HeaderSlot: React.ComponentType<SlotProps<S>>;
HeroSlot: React.ComponentType<SlotProps<S>>;
overallStatusOptions?: { iconSize?: number; allUpKey?: string };
}
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
config: ThemeConfig<any>;
}
export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
export const ModernStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens, mode } = useStatusPageTheme();
const styles = useMemo(
() => config.createStyles(tokens, mode === "dark"),
[config, tokens, mode]
);
const styles = useMemo(() => modernStyles(tokens, mode === "dark"), [tokens, mode]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions);
const overall = resolveOverallStatus(monitors, t, { iconSize: 20 });
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
const { HeaderSlot, HeroSlot } = config;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<HeaderSlot
statusPage={statusPage}
logoSrc={logoSrc}
overall={overall}
monitorCount={monitors.length}
styles={styles}
/>
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoGrad}>{monoFirstChar(statusPage.companyName)}</Box>
)}
{statusPage.companyName}
</Box>
</Box>
<HeroSlot
statusPage={statusPage}
logoSrc={logoSrc}
overall={overall}
monitorCount={monitors.length}
styles={styles}
/>
<Box sx={styles.hero}>
<Box sx={styles.pulse(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
@@ -3,7 +3,6 @@ import { keyframes } from "@mui/system";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type ModernBarKind = "up" | "down" | "empty";
@@ -321,7 +320,7 @@ export const modernStyles = (
heatmap: {
padding: "0 24px 20px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
height: 46,
},
@@ -336,7 +335,7 @@ export const modernStyles = (
histogram: {
padding: "0 24px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
alignItems: "flex-end",
height: 46,
@@ -1,29 +0,0 @@
import Box from "@mui/material/Box";
import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
export const RefinedHeader = ({
statusPage,
logoSrc,
styles,
}: SlotProps<RefinedStyles>) => (
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoMono}>{monoFirstChar(statusPage.companyName)}</Box>
)}
<Box
component="span"
sx={styles.company}
>
{statusPage.companyName}
</Box>
</Box>
);
@@ -1,34 +0,0 @@
import Box from "@mui/material/Box";
import { useTranslation } from "react-i18next";
import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
export const RefinedHero = ({
overall,
monitorCount,
styles,
}: SlotProps<RefinedStyles>) => {
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box sx={styles.statusDot(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
);
};
@@ -0,0 +1,210 @@
import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { ThemedHeatmap } from "../shared/ThemedHeatmap";
import { ThemedHistogram } from "../shared/ThemedHistogram";
import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
import {
monitorBadgeTone,
monoFirstChar,
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
import { refinedStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
interface Props {
statusPage: StatusPage;
monitors: StatusPageMonitor[];
}
export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens } = useStatusPageTheme();
const styles = useMemo(() => refinedStyles(tokens), [tokens]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t);
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
<Box sx={styles.page}>
<Box
component="header"
sx={styles.top}
>
<Box sx={styles.brand}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
sx={styles.logoImg}
/>
) : (
<Box sx={styles.logoMono}>{monoFirstChar(statusPage.companyName)}</Box>
)}
<Box
component="span"
sx={styles.company}
>
{statusPage.companyName}
</Box>
</Box>
</Box>
<Box sx={styles.hero}>
<Box sx={styles.statusDot(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</Box>
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
{statusPage.showCharts && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
)}
<Stack
component="ul"
sx={styles.monitorList}
>
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
return (
<Box
component="li"
key={monitor.id}
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box
component="span"
sx={styles.badge(badgeTone)}
>
{t(statusBadgeKey[monitor.status])}
</Box>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
sxApi={{
containerSx: styles.infra,
emptySx: styles.infraEmpty,
gaugeSx: styles.gauge,
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
sx={styles.footer}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -6,7 +6,6 @@ import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type RefinedBarKind = "up" | "down" | "empty";
export type RefinedGaugeFill = "ok" | "warm" | "hot";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
export interface RefinedStyles {
page: SxProps<Theme>;
@@ -63,10 +62,7 @@ const pillBase = {
fontWeight: 600,
};
export const refinedStyles = (
tokens: StatusPageThemeTokens,
_isDark: boolean
): RefinedStyles => {
export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => {
const heatCellBg: Record<RefinedHeatCell, string> = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
@@ -263,7 +259,7 @@ export const refinedStyles = (
heatmap: {
padding: "0 20px 16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
height: 42,
},
@@ -278,7 +274,7 @@ export const refinedStyles = (
histogram: {
padding: "0 20px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gridTemplateColumns: "repeat(25, 1fr)",
gap: "3px",
alignItems: "flex-end",
height: 42,
@@ -4,12 +4,12 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import type { RootState } from "@/store";
import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
const CELLS = MAX_RECENT_CHECKS;
const CELLS = 25;
export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty";
@@ -29,7 +29,7 @@ const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const source = checks.slice(-CELLS);
const padded: (CheckSnapshot | null)[] = [
@@ -67,7 +67,11 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)}
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
uiTimezone
)}
</Typography>
</Stack>
);
@@ -5,11 +5,12 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import type { RootState } from "@/store";
import type { CheckSnapshot } from "@/Types/Check";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { useStatusPageTheme } from "../StatusPageThemeProvider";
const CELLS = MAX_RECENT_CHECKS;
const CELLS = 25;
const MIN_HEIGHT_PCT = 6;
export type BarKind = "up" | "down" | "empty";
@@ -33,7 +34,7 @@ export const ThemedHistogram = ({
statsGap = 1,
}: Props) => {
const { t } = useTranslation();
const { timezone } = useStatusPageTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const { padded, max, avg, peak } = useMemo(() => {
const source = checks.slice(-CELLS);
@@ -82,7 +83,7 @@ export const ThemedHistogram = ({
{formatDateWithTz(
check.createdAt,
"ddd, MMMM D, YYYY, HH:mm A",
timezone
uiTimezone
)}
</Typography>
</Stack>
@@ -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"
+6 -75
View File
@@ -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>
);
};
+1 -1
View File
@@ -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;
-13
View File
@@ -3,8 +3,6 @@ import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
export type { GeoContinent } from "@/Types/GeoCheck";
export const MAX_RECENT_CHECKS = 50;
export const MonitorTypes = [
"http",
"ping",
@@ -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;
-3
View File
@@ -23,8 +23,6 @@ export interface QueueJobSummary {
monitorUrl: string | null;
monitorType: MonitorType | null;
monitorInterval: number | null;
monitorGeoInterval: number | null;
monitorActive: boolean | null;
active: boolean;
lockedAt: number | null;
runCount: number;
@@ -34,7 +32,6 @@ export interface QueueJobSummary {
lastFinishedAt: number | null;
lastRunTook: number | null;
lastFailedAt: number | null;
repeat: number | null;
}
export interface QueueData {
-1
View File
@@ -12,7 +12,6 @@ export interface Settings {
systemEmailHost?: string;
systemEmailPort?: number;
systemEmailAddress?: string;
systemEmailDisplayName?: string;
systemEmailUser?: string;
systemEmailConnectionHost?: string;
systemEmailTLSServername?: string;
-1
View File
@@ -10,7 +10,6 @@ export const getMonitorPath = (type: MonitorType): string => {
game: "uptime",
grpc: "uptime",
websocket: "uptime",
dns: "uptime",
unknown: "uptime",
docker: "uptime",
hardware: "infrastructure",
-13
View File
@@ -1,5 +1,3 @@
import { alpha } from "@mui/material/styles";
const typographyBase = 13;
export const typographyLevels = {
@@ -19,9 +17,6 @@ export const colors = {
gray850: "#1c1c21",
brandGreen: "#13715B",
brandGreenLight: "#4DAF94",
green200: "#ECF7F2",
yellow200: "#FFF4E5",
orange500: "#FFA500",
};
export const lightPalette = {
@@ -34,10 +29,6 @@ export const lightPalette = {
sidebar: {
accent: colors.brandGreen,
},
rowStatus: {
running: colors.green200,
paused: colors.yellow200,
},
};
export const darkPalette = {
@@ -54,8 +45,4 @@ export const darkPalette = {
sidebar: {
accent: colors.brandGreenLight,
},
rowStatus: {
running: alpha(colors.brandGreen, 0.18),
paused: alpha(colors.orange500, 0.18),
},
};
-2
View File
@@ -15,11 +15,9 @@ declare module "@mui/material/styles" {
}
interface Palette {
sidebar: { accent: string };
rowStatus: { running: string; paused: string };
}
interface PaletteOptions {
sidebar?: { accent: string };
rowStatus?: { running: string; paused: string };
}
}
-1
View File
@@ -1,5 +1,4 @@
export const SPACING = {
XXS: 0.25,
XS: 0.5,
SM: 1,
MD: 1.5,
+3 -24
View File
@@ -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) => {
-76
View File
@@ -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";
};
+25 -27
View File
@@ -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>;
-26
View File
@@ -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>;
-5
View File
@@ -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()))
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "دعوة عضو",
"openSite": "فتح الموقع",
"pause": "إيقاف مؤقت",
"resume": "استئناف",
"selectAll": "تحديد جميع الشاشات",
"selectMonitor": "تحديد {{name}}",
"bulkSelected_one": "{{count}} شاشة محددة",
"bulkSelected_other": "{{count}} شاشات محددة"
"resume": "استئناف"
},
"statBoxes": {
"activeFor": "نشط منذ",
@@ -453,16 +449,6 @@
"optionHardware": "البنية التحتية",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "تم إيقاف {{count}} شاشة مؤقتاً",
"paused_other": "تم إيقاف {{count}} شاشات مؤقتاً",
"resumed_one": "تم استئناف {{count}} شاشة",
"resumed_other": "تم استئناف {{count}} شاشات",
"alreadyPaused_one": "الشاشة المحددة متوقفة مؤقتاً بالفعل",
"alreadyPaused_other": "جميع الشاشات المحددة متوقفة مؤقتاً بالفعل",
"alreadyRunning_one": "الشاشة المحددة قيد التشغيل بالفعل",
"alreadyRunning_other": "جميع الشاشات المحددة قيد التشغيل بالفعل"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "رابط WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "نقطة نهاية Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "الإعدادات العامة",
@@ -564,7 +546,7 @@
"pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.",
"grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.",
"websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.",
"hardware": "راقب استخدام وحدة المعالجة المركزية والذاكرة والقرص ودرجة الحرارة لبنيتك التحتية. يجب أن يتضمن عنوان URL لنقطة نهاية Capture المسار الكامل ‎/api/v1/metrics‎ (مثال: ‎http://192.168.1.10:5001/api/v1/metrics‎). بالنسبة لمفتاح التفويض، الصق سلسلة الرمز فقط — لا تُضمّن كلمة 'Bearer'."
"hardware": "مراقبة استخدام CPU والذاكرة والقرص ودرجة الحرارة للبنية التحتية الخاصة بك."
}
},
"ignoreTls": {
File diff suppressed because it is too large. Load diff
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Pozvat člena",
"openSite": "Otevřít stránku",
"pause": "Pozastavit",
"resume": "Obnovit",
"selectAll": "Vybrat všechny monitory",
"selectMonitor": "Vybrat {{name}}",
"bulkSelected_one": "Vybrán {{count}} monitor",
"bulkSelected_other": "Vybráno {{count}} monitorů"
"resume": "Obnovit"
},
"statBoxes": {
"activeFor": "Aktivní po dobu",
@@ -453,16 +449,6 @@
"optionHardware": "Infrastruktura",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitor pozastaven",
"paused_other": "{{count}} monitorů pozastaveno",
"resumed_one": "{{count}} monitor obnoven",
"resumed_other": "{{count}} monitorů obnoveno",
"alreadyPaused_one": "Vybraný monitor je již pozastaven",
"alreadyPaused_other": "Všechny vybrané monitory jsou již pozastaveny",
"alreadyRunning_one": "Vybraný monitor již běží",
"alreadyRunning_other": "Všechny vybrané monitory již běží"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Endpoint Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Obecná nastavení",
@@ -564,7 +546,7 @@
"pagespeed": "Sledujte výkon načítání stránky, metriky Core Web Vitals a hodnocení optimalizace vašeho webu.",
"grpc": "Zadejte název hostitele a port gRPC serveru, volitelně zadejte název služby pro kontrolu stavu a přidejte zobrazovaný název.",
"websocket": "Zadejte WebSocket URL ke sledování (např. wss://example.com/socket) a přidejte zobrazovaný název.",
"hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury. URL endpointu Capture musí obsahovat celou cestu /api/v1/metrics (např. http://192.168.1.10:5001/api/v1/metrics). Do autorizačního klíče vložte pouze řetězec tokenu — nezahrnujte 'Bearer'."
"hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Mitglied einladen",
"openSite": "Seite öffnen",
"pause": "Pausieren",
"resume": "Fortsetzen",
"selectAll": "Alle Monitore auswählen",
"selectMonitor": "{{name}} auswählen",
"bulkSelected_one": "{{count}} Monitor ausgewählt",
"bulkSelected_other": "{{count}} Monitore ausgewählt"
"resume": "Fortsetzen"
},
"statBoxes": {
"activeFor": "Aktiv seit",
@@ -453,16 +449,6 @@
"optionHardware": "Infrastruktur",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} Monitor pausiert",
"paused_other": "{{count}} Monitore pausiert",
"resumed_one": "{{count}} Monitor fortgesetzt",
"resumed_other": "{{count}} Monitore fortgesetzt",
"alreadyPaused_one": "Der ausgewählte Monitor ist bereits pausiert",
"alreadyPaused_other": "Alle ausgewählten Monitore sind bereits pausiert",
"alreadyRunning_one": "Der ausgewählte Monitor wird bereits ausgeführt",
"alreadyRunning_other": "Alle ausgewählten Monitore werden bereits ausgeführt"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket-URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture-Endpunkt",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Allgemeine Einstellungen",
@@ -564,7 +546,7 @@
"pagespeed": "Verfolgen Sie die Ladezeit, Core Web Vitals und Optimierungswerte Ihrer Website.",
"grpc": "Geben Sie den Hostnamen und Port des gRPC-Servers ein, optional einen Dienstnamen für den Health-Check, und fügen Sie einen Anzeigenamen hinzu.",
"websocket": "Geben Sie die zu überwachende WebSocket-URL ein (z. B. wss://example.com/socket) und fügen Sie einen Anzeigenamen hinzu.",
"hardware": "Überwachen Sie CPU-, Speicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur. Die URL des Capture-Endpunkts muss den vollständigen Pfad /api/v1/metrics enthalten (z. B. http://192.168.1.10:5001/api/v1/metrics). Fügen Sie für das Autorisierungsgeheimnis nur den Token-String ein — geben Sie 'Bearer' nicht mit an."
"hardware": "Überwachen Sie CPU-, Arbeitsspeicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur."
}
},
"ignoreTls": {
+4 -82
View File
@@ -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"
+2 -20
View File
@@ -428,11 +428,7 @@
"inviteMember": "Invitar miembro",
"openSite": "Abrir sitio",
"pause": "Pausar",
"resume": "Reanudar",
"selectAll": "Seleccionar todos los monitores",
"selectMonitor": "Seleccionar {{name}}",
"bulkSelected_one": "{{count}} monitor seleccionado",
"bulkSelected_other": "{{count}} monitores seleccionados"
"resume": "Reanudar"
},
"statBoxes": {
"activeFor": "Activo desde hace",
@@ -460,16 +456,6 @@
"optionHardware": "Infraestructura",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitor pausado",
"paused_other": "{{count}} monitores pausados",
"resumed_one": "{{count}} monitor reanudado",
"resumed_other": "{{count}} monitores reanudados",
"alreadyPaused_one": "El monitor seleccionado ya está pausado",
"alreadyPaused_other": "Todos los monitores seleccionados ya están pausados",
"alreadyRunning_one": "El monitor seleccionado ya se está ejecutando",
"alreadyRunning_other": "Todos los monitores seleccionados ya se están ejecutando"
}
}
},
@@ -555,10 +541,6 @@
"wsUrl": {
"label": "URL de WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Endpoint de Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Configuración general",
@@ -571,7 +553,7 @@
"pagespeed": "Rastree el rendimiento de carga de página, Core Web Vitals y puntuaciones de optimización de su sitio web.",
"grpc": "Ingrese el nombre de host y puerto del servidor gRPC, opcionalmente especifique un nombre de servicio para la verificación de salud, y agregue un nombre para mostrar.",
"websocket": "Ingrese la URL de WebSocket a monitorear (p. ej., wss://example.com/socket) y agregue un nombre para mostrar.",
"hardware": "Supervisa el uso de CPU, memoria, disco y temperatura de tu infraestructura. La URL del endpoint de Capture debe incluir la ruta completa /api/v1/metrics (p. ej., http://192.168.1.10:5001/api/v1/metrics). Para el secreto de autorización, pega solo la cadena del token — no incluyas 'Bearer'."
"hardware": "Monitoree el uso de CPU, memoria, disco y temperatura de su infraestructura."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Kutsu jäsen",
"openSite": "Avaa sivusto",
"pause": "Keskeytä",
"resume": "Jatka",
"selectAll": "Valitse kaikki näytöt",
"selectMonitor": "Valitse {{name}}",
"bulkSelected_one": "{{count}} näyttö valittu",
"bulkSelected_other": "{{count}} näyttöä valittu"
"resume": "Jatka"
},
"statBoxes": {
"activeFor": "Aktiivinen",
@@ -453,16 +449,6 @@
"optionHardware": "Infrastruktuuri",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} näyttö keskeytetty",
"paused_other": "{{count}} näyttöä keskeytetty",
"resumed_one": "{{count}} näyttö jatkettu",
"resumed_other": "{{count}} näyttöä jatkettu",
"alreadyPaused_one": "Valittu näyttö on jo keskeytetty",
"alreadyPaused_other": "Kaikki valitut näytöt on jo keskeytetty",
"alreadyRunning_one": "Valittu näyttö on jo käynnissä",
"alreadyRunning_other": "Kaikki valitut näytöt ovat jo käynnissä"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture-päätepiste",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Yleiset asetukset",
@@ -564,7 +546,7 @@
"pagespeed": "Seuraa sivun latausnopeutta, Core Web Vitals -arvoja ja optimointipisteitä verkkosivustollasi.",
"grpc": "Syötä gRPC-palvelimen isäntänimi ja portti, määritä halutessasi palvelun nimi terveystarkistusta varten ja lisää näyttönimi.",
"websocket": "Syötä monitoroitava WebSocket URL (esim. wss://example.com/socket) ja lisää näyttönimi.",
"hardware": "Seuraa infrastruktuurisi suorittimen, muistin, levyn käyttöä ja lämpötilaa. Capture-päätepisteen URL-osoitteen on sisällettävä koko polku /api/v1/metrics (esim. http://192.168.1.10:5001/api/v1/metrics). Liitä valtuutusavaimeen vain tokenin merkkijono — älä sisällytä sanaa 'Bearer'."
"hardware": "Monitoroi infrastruktuurisi CPU:n, muistin ja levyn käyttöä sekä lämpötilaa."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Inviter un membre",
"openSite": "Ouvrir le site",
"pause": "Pause",
"resume": "Reprendre",
"selectAll": "Sélectionner tous les moniteurs",
"selectMonitor": "Sélectionner {{name}}",
"bulkSelected_one": "{{count}} moniteur sélectionné",
"bulkSelected_other": "{{count}} moniteurs sélectionnés"
"resume": "Reprendre"
},
"statBoxes": {
"activeFor": "Actif depuis",
@@ -453,16 +449,6 @@
"optionHardware": "Infrastructure",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} moniteur en pause",
"paused_other": "{{count}} moniteurs en pause",
"resumed_one": "{{count}} moniteur repris",
"resumed_other": "{{count}} moniteurs repris",
"alreadyPaused_one": "Le moniteur sélectionné est déjà en pause",
"alreadyPaused_other": "Tous les moniteurs sélectionnés sont déjà en pause",
"alreadyRunning_one": "Le moniteur sélectionné est déjà en cours d'exécution",
"alreadyRunning_other": "Tous les moniteurs sélectionnés sont déjà en cours d'exécution"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "URL WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Point de terminaison Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Paramètres généraux",
@@ -564,7 +546,7 @@
"pagespeed": "Suivez les performances de chargement, les Core Web Vitals et les scores d'optimisation de votre site web.",
"grpc": "Saisissez le nom d'hôte et le port du serveur gRPC, spécifiez éventuellement un nom de service pour le bilan de santé, et ajoutez un nom d'affichage.",
"websocket": "Saisissez l'URL WebSocket à surveiller (ex. wss://example.com/socket) et ajoutez un nom d'affichage.",
"hardware": "Surveillez l'utilisation du processeur, de la mémoire, du disque et la température de votre infrastructure. L'URL du point de terminaison Capture doit inclure le chemin complet /api/v1/metrics (par ex. http://192.168.1.10:5001/api/v1/metrics). Pour le secret d'autorisation, collez uniquement la chaîne du jeton — n'incluez pas 'Bearer'."
"hardware": "Surveillez l'utilisation du CPU, de la mémoire, du disque et la température de votre infrastructure."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "メンバーを招待",
"openSite": "サイトを開く",
"pause": "一時停止",
"resume": "再開",
"selectAll": "すべてのモニターを選択",
"selectMonitor": "{{name}} を選択",
"bulkSelected_one": "{{count}} 個のモニターが選択されました",
"bulkSelected_other": "{{count}} 個のモニターが選択されました"
"resume": "再開"
},
"statBoxes": {
"activeFor": "稼働期間",
@@ -453,16 +449,6 @@
"optionHardware": "インフラストラクチャ",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} 個のモニターを一時停止しました",
"paused_other": "{{count}} 個のモニターを一時停止しました",
"resumed_one": "{{count}} 個のモニターを再開しました",
"resumed_other": "{{count}} 個のモニターを再開しました",
"alreadyPaused_one": "選択したモニターはすでに一時停止されています",
"alreadyPaused_other": "選択したすべてのモニターはすでに一時停止されています",
"alreadyRunning_one": "選択したモニターはすでに実行中です",
"alreadyRunning_other": "選択したすべてのモニターはすでに実行中です"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture エンドポイント",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "一般設定",
@@ -564,7 +546,7 @@
"pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。",
"grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。",
"websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。",
"hardware": "インフラの CPU、メモリ、ディスク使用率、温度を監視します。Capture エンドポイント URL には完全なパス /api/v1/metrics を含める必要があります (例: http://192.168.1.10:5001/api/v1/metrics)。認証シークレットにはトークン文字列のみを貼り付けてください — 'Bearer' は含めないでください。"
"hardware": "インフラストラクチャのCPU、メモリ、ディスク使用量、温度を監視します。"
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Convidar membro",
"openSite": "Abrir site",
"pause": "Pausar",
"resume": "Retomar",
"selectAll": "Selecionar todos os monitores",
"selectMonitor": "Selecionar {{name}}",
"bulkSelected_one": "{{count}} monitor selecionado",
"bulkSelected_other": "{{count}} monitores selecionados"
"resume": "Retomar"
},
"statBoxes": {
"activeFor": "Ativo há",
@@ -453,16 +449,6 @@
"optionHardware": "Infraestrutura",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitor pausado",
"paused_other": "{{count}} monitores pausados",
"resumed_one": "{{count}} monitor retomado",
"resumed_other": "{{count}} monitores retomados",
"alreadyPaused_one": "O monitor selecionado já está pausado",
"alreadyPaused_other": "Todos os monitores selecionados já estão pausados",
"alreadyRunning_one": "O monitor selecionado já está em execução",
"alreadyRunning_other": "Todos os monitores selecionados já estão em execução"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "URL do WebSocket",
"placeholder": "wss://exemplo.com/socket"
},
"captureUrl": {
"label": "Endpoint do Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Configurações gerais",
@@ -564,7 +546,7 @@
"pagespeed": "Acompanhe o desempenho de carregamento da página, Core Web Vitals e pontuações de otimização do seu site.",
"grpc": "Digite o hostname e a porta do servidor gRPC, opcionalmente especifique um nome de serviço para a verificação de saúde e adicione um nome de exibição.",
"websocket": "Digite a URL do WebSocket para monitorar (ex.: wss://exemplo.com/socket) e adicione um nome de exibição.",
"hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura. A URL do endpoint do Capture deve incluir o caminho completo /api/v1/metrics (por exemplo, http://192.168.1.10:5001/api/v1/metrics). Para a chave de autorização, cole apenas a string do token — não inclua 'Bearer'."
"hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Пригласить участника",
"openSite": "Открыть сайт",
"pause": "Пауза",
"resume": "Продолжить",
"selectAll": "Выбрать все мониторы",
"selectMonitor": "Выбрать {{name}}",
"bulkSelected_one": "Выбран {{count}} монитор",
"bulkSelected_other": "Выбрано {{count}} мониторов"
"resume": "Продолжить"
},
"statBoxes": {
"activeFor": "Активен",
@@ -453,16 +449,6 @@
"optionHardware": "Инфраструктура",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} монитор приостановлен",
"paused_other": "{{count}} мониторов приостановлено",
"resumed_one": "{{count}} монитор возобновлен",
"resumed_other": "{{count}} мониторов возобновлено",
"alreadyPaused_one": "Выбранный монитор уже приостановлен",
"alreadyPaused_other": "Все выбранные мониторы уже приостановлены",
"alreadyRunning_one": "Выбранный монитор уже запущен",
"alreadyRunning_other": "Все выбранные мониторы уже запущены"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Эндпоинт Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Основные настройки",
@@ -564,7 +546,7 @@
"pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.",
"grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.",
"websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.",
"hardware": "Отслеживайте использование ЦП, памяти, диска и температуру вашей инфраструктуры. URL эндпоинта Capture должен включать полный путь /api/v1/metrics (например, http://192.168.1.10:5001/api/v1/metrics). В секрете авторизации укажите только строку токена — не включайте 'Bearer'."
"hardware": "Мониторинг CPU, памяти, дисков и температуры Вашей инфраструктуры."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "เชิญสมาชิก",
"openSite": "เปิดเว็บไซต์",
"pause": "หยุดชั่วคราว",
"resume": "ดำเนินการต่อ",
"selectAll": "เลือกจอภาพทั้งหมด",
"selectMonitor": "เลือก {{name}}",
"bulkSelected_one": "เลือกแล้ว {{count}} จอภาพ",
"bulkSelected_other": "เลือกแล้ว {{count}} จอภาพ"
"resume": "ดำเนินการต่อ"
},
"statBoxes": {
"activeFor": "ทำงานมาแล้ว",
@@ -453,16 +449,6 @@
"optionHardware": "โครงสร้างพื้นฐาน",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "หยุดจอภาพชั่วคราว {{count}} จอ",
"paused_other": "หยุดจอภาพชั่วคราว {{count}} จอ",
"resumed_one": "กลับมาทำงานต่อ {{count}} จอ",
"resumed_other": "กลับมาทำงานต่อ {{count}} จอ",
"alreadyPaused_one": "จอภาพที่เลือกถูกหยุดชั่วคราวแล้ว",
"alreadyPaused_other": "จอภาพที่เลือกทั้งหมดถูกหยุดชั่วคราวแล้ว",
"alreadyRunning_one": "จอภาพที่เลือกกำลังทำงานอยู่แล้ว",
"alreadyRunning_other": "จอภาพที่เลือกทั้งหมดกำลังทำงานอยู่แล้ว"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "URL ของ WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "ปลายทาง Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "ตั้งค่าทั่วไป",
@@ -564,7 +546,7 @@
"pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์",
"grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง",
"websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง",
"hardware": "ตรวจสอบการใช้ CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐานของคุณ URL ของปลายทาง Capture ต้องรวมเส้นทางแบบเต็ม /api/v1/metrics (ตัวอย่าง: http://192.168.1.10:5001/api/v1/metrics) สำหรับรหัสลับสำหรับยืนยันตัวตน ให้วางเฉพาะสตริงโทเค็น — อย่ารวมคำว่า 'Bearer'"
"hardware": "ตรวจสอบการใช้งาน CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐาน"
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Üye davet et",
"openSite": "Siteyi aç",
"pause": "Duraklat",
"resume": "Devam et",
"selectAll": "Tüm monitörleri seç",
"selectMonitor": "{{name}}'i seç",
"bulkSelected_one": "{{count}} monitör seçildi",
"bulkSelected_other": "{{count}} monitör seçildi"
"resume": "Devam et"
},
"statBoxes": {
"activeFor": "Aktif süresi",
@@ -453,16 +449,6 @@
"optionHardware": "Altyapı",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitör duraklatıldı",
"paused_other": "{{count}} monitör duraklatıldı",
"resumed_one": "{{count}} monitör sürdürüldü",
"resumed_other": "{{count}} monitör sürdürüldü",
"alreadyPaused_one": "Seçili monitör zaten duraklatıldı",
"alreadyPaused_other": "Seçili tüm monitörler zaten duraklatıldı",
"alreadyRunning_one": "Seçili monitör zaten çalışıyor",
"alreadyRunning_other": "Seçili tüm monitörler zaten çalışıyor"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture uç noktası",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Genel ayarlar",
@@ -564,7 +546,7 @@
"pagespeed": "Web sitenizin sayfa yükleme performansını, Core Web Vitals metriklerini ve optimizasyon puanlarını takip edin.",
"grpc": "gRPC sunucusunun ana bilgisayar adını ve portunu girin, isteğe bağlı olarak sağlık kontrolü için bir servis adı belirtin ve bir görünen ad ekleyin.",
"websocket": "İzlenecek WebSocket URL'sini girin (örn. wss://example.com/socket) ve bir görünen ad ekleyin.",
"hardware": "Altyapınızın CPU, bellek, disk kullanımını ve sıcaklığını izleyin. Capture uç nokta URL'si tam yolu /api/v1/metrics içermelidir (ör. http://192.168.1.10:5001/api/v1/metrics). Yetkilendirme anahtarına yalnızca token dizesini yapıştırın — 'Bearer' eklemeyin."
"hardware": "Altyapınız için CPU, bellek, disk kullanımı ve sıcaklığı izleyin."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Запросити учасника",
"openSite": "Відкрити сайт",
"pause": "Призупинити",
"resume": "Відновити",
"selectAll": "Вибрати всі монітори",
"selectMonitor": "Вибрати {{name}}",
"bulkSelected_one": "Вибрано {{count}} монітор",
"bulkSelected_other": "Вибрано {{count}} моніторів"
"resume": "Відновити"
},
"statBoxes": {
"activeFor": "Активний протягом",
@@ -453,16 +449,6 @@
"optionHardware": "Інфраструктура",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} монітор призупинено",
"paused_other": "{{count}} моніторів призупинено",
"resumed_one": "{{count}} монітор відновлено",
"resumed_other": "{{count}} моніторів відновлено",
"alreadyPaused_one": "Вибраний монітор уже призупинено",
"alreadyPaused_other": "Всі вибрані монітори уже призупинено",
"alreadyRunning_one": "Вибраний монітор уже запущено",
"alreadyRunning_other": "Всі вибрані монітори уже запущено"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Точка доступу Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Загальні налаштування",
@@ -564,7 +546,7 @@
"pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.",
"grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.",
"websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.",
"hardware": "Відстежуйте використання ЦП, пам'яті, диска та температуру вашої інфраструктури. URL точки доступу Capture має містити повний шлях /api/v1/metrics (наприклад, http://192.168.1.10:5001/api/v1/metrics). У секрет авторизації вставте лише рядок токена — не додавайте 'Bearer'."
"hardware": "Моніторинг CPU, пам'яті, використання диска та температури Вашої інфраструктури."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "Mời thành viên",
"openSite": "Mở trang web",
"pause": "Tạm dừng",
"resume": "Tiếp tục",
"selectAll": "Chọn tất cả màn hình",
"selectMonitor": "Chọn {{name}}",
"bulkSelected_one": "Đã chọn {{count}} màn hình",
"bulkSelected_other": "Đã chọn {{count}} màn hình"
"resume": "Tiếp tục"
},
"statBoxes": {
"activeFor": "Hoạt động từ",
@@ -453,16 +449,6 @@
"optionHardware": "Hạ tầng",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "Đã tạm dừng {{count}} màn hình",
"paused_other": "Đã tạm dừng {{count}} màn hình",
"resumed_one": "Đã tiếp tục {{count}} màn hình",
"resumed_other": "Đã tiếp tục {{count}} màn hình",
"alreadyPaused_one": "Màn hình đã chọn đã được tạm dừng",
"alreadyPaused_other": "Tất cả các màn hình đã chọn đã được tạm dừng",
"alreadyRunning_one": "Màn hình đã chọn đã đang chạy",
"alreadyRunning_other": "Tất cả các màn hình đã chọn đã đang chạy"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "URL WebSocket",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Điểm cuối Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "Cài đặt chung",
@@ -564,7 +546,7 @@
"pagespeed": "Theo dõi hiệu suất tải trang, Core Web Vitals và điểm tối ưu hóa cho trang web của bạn.",
"grpc": "Nhập tên máy chủ và cổng của máy chủ gRPC, tùy chọn chỉ định tên dịch vụ cho kiểm tra sức khỏe và thêm tên hiển thị.",
"websocket": "Nhập URL WebSocket cần giám sát (vd: wss://example.com/socket) và thêm tên hiển thị.",
"hardware": "Giám sát mức sử dụng CPU, bộ nhớ, ổ đĩa và nhiệt độ của hạ tầng. URL điểm cuối Capture phải bao gồm đường dẫn đầy đủ /api/v1/metrics (ví dụ: http://192.168.1.10:5001/api/v1/metrics). Đối với khóa xác thực, chỉ dán chuỗi token — không bao gồm 'Bearer'."
"hardware": "Giám sát CPU, bộ nhớ, dung lượng ổ đĩa và nhiệt độ cho hạ tầng của bạn."
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "邀请成员",
"openSite": "打开站点",
"pause": "暂停",
"resume": "恢复",
"selectAll": "选择所有监视器",
"selectMonitor": "选择 {{name}}",
"bulkSelected_one": "已选择 {{count}} 个监视器",
"bulkSelected_other": "已选择 {{count}} 个监视器"
"resume": "恢复"
},
"statBoxes": {
"activeFor": "已活跃",
@@ -453,16 +449,6 @@
"optionHardware": "基础设施",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "已暂停 {{count}} 个监视器",
"paused_other": "已暂停 {{count}} 个监视器",
"resumed_one": "已恢复 {{count}} 个监视器",
"resumed_other": "已恢复 {{count}} 个监视器",
"alreadyPaused_one": "所选监视器已暂停",
"alreadyPaused_other": "所有选定的监视器均已暂停",
"alreadyRunning_one": "所选监视器已经在运行",
"alreadyRunning_other": "所有选定的监视器均已在运行"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture 端点",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "常规设置",
@@ -564,7 +546,7 @@
"pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。",
"grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。",
"websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。",
"hardware": "监控基础设施的 CPU、内存、磁盘使用情况和温度。Capture 端点 URL 必须包含完整路径 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授权密钥只需粘贴令牌字符串——请勿包含 'Bearer'。"
"hardware": "监控基础设施的 CPU、内存、磁盘使用率和温度。"
}
},
"ignoreTls": {
+2 -20
View File
@@ -421,11 +421,7 @@
"inviteMember": "邀請成員",
"openSite": "開啟網站",
"pause": "暫停",
"resume": "恢復",
"selectAll": "選擇所有監視器",
"selectMonitor": "選擇 {{name}}",
"bulkSelected_one": "已選擇 {{count}} 個監視器",
"bulkSelected_other": "已選擇 {{count}} 個監視器"
"resume": "恢復"
},
"statBoxes": {
"activeFor": "已啟用",
@@ -453,16 +449,6 @@
"optionHardware": "基礎架構",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "已暫停 {{count}} 個監視器",
"paused_other": "已暫停 {{count}} 個監視器",
"resumed_one": "已恢復 {{count}} 個監視器",
"resumed_other": "已恢復 {{count}} 個監視器",
"alreadyPaused_one": "所選監視器已暫停",
"alreadyPaused_other": "所有選定的監視器均已暫停",
"alreadyRunning_one": "所選監視器已經在運行",
"alreadyRunning_other": "所有選定的監視器均已在運行"
}
}
},
@@ -548,10 +534,6 @@
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
},
"captureUrl": {
"label": "Capture 端點",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
}
},
"title": "一般設定",
@@ -564,7 +546,7 @@
"pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。",
"grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。",
"websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。",
"hardware": "監控基礎架構的 CPU、記憶體、磁碟使用情況和溫度。Capture 端點 URL 必須包含完整路徑 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授權密鑰僅貼上權杖字串——請勿包含 'Bearer'。"
"hardware": "監控您基礎架構的 CPU、記憶體、磁碟使用量和溫度。"
}
},
"ignoreTls": {
+2
View File
@@ -18,6 +18,8 @@ services:
image: mongo:6.0
restart: always
command: ["mongod", "--quiet", "--bind_ip_all"]
ports:
- "27017:27017"
volumes:
- ./mongo/data:/data/db
healthcheck:
-5
View File
@@ -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"]
+2 -6
View File
@@ -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
-114
View File
@@ -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.
-137
View File
@@ -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).
-122
View File
@@ -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).
-124
View File
@@ -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).
-84
View File
@@ -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