Compare commits

..
Author SHA1 Message Date
gorkem-bwl f85903adc9 chore(docs): trigger Vercel rebuild 2026-05-22 14:30:52 -04:00
gorkem-bwl a7895cce32 fix(docs): serve docs site at root of Vercel domain
Astro's base option was set to /docs to anticipate the reverse-proxy
URL at checkmate.so/docs. Vercel serves the dist folder at the root,
which made every page 404 because Astro had baked /docs/ into every
internal link and asset URL.

## Changes
- Remove base: "/docs" from astro.config.mjs — site now serves at root
- Update Releases nav link to /releases
- Update README's reverse-proxy snippet to strip the /docs prefix
  before forwarding to Vercel

## Final URL behaviour
- Direct: https://<vercel-domain>/                  → Introduction
- Direct: https://<vercel-domain>/releases/         → Releases
- Proxy:  https://checkmate.so/docs/                → strip /docs → Introduction
- Proxy:  https://checkmate.so/docs/releases/       → strip /docs → Releases
2026-05-22 14:26:51 -04:00
gorkem-bwl 8e4e9dd87c fix(docs): make the build self-contained for Vercel
Vercel's "Root Directory" setting limits the upload to docs/api/, so
the build cannot read sibling paths like ../../server/openapi.json or
../api-content/. A small prebuild script copies both into a vendored
folder inside the project tree (gitignored).

## Changes
- Add scripts/sync-sources.mjs to copy /docs/api-content/ and
  /server/openapi.json into docs/api/src/vendored/ before dev/build
- Wire predev/prebuild npm scripts so the sync runs automatically
- Update astro.config.mjs and src/content.config.ts to read from the
  vendored paths
- Gitignore src/vendored/ — contributors still edit the originals
2026-05-22 14:20:10 -04:00
gorkem-bwl 0d25973efb fix(docs): make the Starlight build pass
## Changes
- Bump @astrojs/starlight to ^0.32.0 so starlight-openapi 0.13's plugin
  contract (top-level setup hook) is supported
- Replace the no-arg docsLoader call with Astro's glob loader pointing
  at ../api-content so prose stays outside the Astro project tree
- Rename introduction.md → index.md and update the sidebar slug so the
  /docs/ root resolves instead of returning 404
- Commit package-lock.json so Vercel installs deterministically

## Verification
- npm run build produces 96 pages (7 narrative + 88 OpenAPI operations + 1
  releases timeline)
- /docs/, /docs/releases/, /docs/api-reference/operations/authlogin/ and
  every other route return 200 locally
- 14 release cards render with the brand-green "Latest" badge on v3.7.1
2026-05-22 14:04:52 -04:00
gorkem-bwl e69f40fdd0 docs(api): add releases timeline page
## Changes
- Add a Releases tab to the top navigation via a custom Starlight
  Header override (HeaderLinks.astro)
- Add ReleaseCard.astro — Vercel/Linear-style entry with sticky date
  + version on the left, headline and feature bullets on the right
- Curate 14 release entries spanning v1.0 (Sept 2024) through v3.7.1
  (April 2026), each focused on the most important user-facing
  features rather than every commit
- Newest release carries a "Latest" badge in the brand color
- Mobile layout collapses the two-column timeline to a single column

## Benefits
- Visitors can see at a glance what shipped recently and what
  Checkmate has matured into over time
- The data file (src/data/releases.ts) is the only thing that needs
  editing when a new version ships — no template or styling churn
- Built on the same Starlight site as the API docs, so look and feel
  stay consistent
2026-05-22 10:25:06 -04:00
gorkem-bwl 9aa2594df7 docs(api): add public API documentation site (Astro Starlight)
## Changes
- Add narrative API documentation in docs/api-content/ (introduction,
  quickstart, authentication, conventions, errors, rate limiting)
- Scaffold Astro Starlight site at docs/api/ that consumes prose from
  the sibling api-content folder and auto-generates the endpoint
  reference from server/openapi.json via starlight-openapi
- Wire Checkmate brand color (#13715B) into Starlight via custom CSS
- Add vercel.json so the docs site deploys cleanly as an Astro project
  with root directory docs/api/

## Benefits
- Public API docs ready to publish at checkmate.so/docs
- Endpoint reference stays in sync with the OpenAPI spec automatically
- Zero third-party SaaS dependency: site is fully self-hosted on Vercel
- Contributors edit plain markdown; no Astro knowledge needed for prose
2026-05-22 10:21:10 -04:00
ashu130698 9027707c92 chore: clean up deprecated FloatingActionBar component 2026-05-14 16:24:51 +05:30
ashu130698 9e69d863df fix(ui): remove unused SPACING import in monitors index 2026-05-14 15:54:55 +05:30
ashu130698 c740fb1aca fix(ui): resolve PR feedback regarding type casts and hardcoded spacing 2026-05-14 15:50:56 +05:30
ashu130698 3c05b9df98 fix(i18n): migrate and insert missing translation keys for bulk edit notifications 2026-05-14 15:49:16 +05:30
ashu130698 d5d79a1eda fix(ux): replace buggy margin-based centering with reliable flexbox wrapper to permanently center floating action bars 2026-05-14 15:49:16 +05:30
ashu130698 2097bc477a fix(ux): replace overwritten transform on floating action bars to fix overflow centering on mobile devices 2026-05-14 15:49:16 +05:30
ashu130698 744d8c8dcf style: fix code format issues 2026-05-14 15:49:16 +05:30
ashu130698 7a5ee6ba05 fix(monitors): address PR feedback on mobile card views and modal selection state 2026-05-14 15:48:55 +05:30
ashu130698 eeee6db94e fix(monitors): add missing page prop to MonitorTable 2026-05-14 15:46:55 +05:30
ashu130698 656c04d0e4 fix(monitors): PR feedback -layout rhythm, pagination memory leak, error alerts, and mobile cards 2026-05-14 15:46:33 +05:30
ashu130698 010ef9661f fix: corrected TypeScript notification property name 2026-05-14 15:45:30 +05:30
ashu130698 ff0ef928a6 feat: added bulk edit notification UI to uptime monitors 2026-05-14 15:45:11 +05:30
ashu130698 eb29a725c2 feat: add table row selection and floating action bar MVP 2026-05-14 15:41:50 +05:30
478 changed files with 16851 additions and 21396 deletions

No files matched your search

+12 -16
View File
@@ -68,23 +68,19 @@ VITE_APP_LOG_LEVEL="debug"
### Backend Layers
```
server/src/
├── api/ # HTTP layer
│ ├── controllers/ # Route handlers (authController, monitorController, etc.)
│ ├── middleware/ # verifyJWT, rateLimiter, sanitization, responseHandler
│ ├── routes/ # API route definitions
│ └── validation/ # Zod request-payload schemas
├── domain/ # Business logic + data access, one folder per entity
│ ├── monitors/ # e.g. monitor.service.ts, monitor.repository.ts, monitor.type.ts
│ ├── checks/
│ ├── incidents/
│ └── … # users, teams, status-pages, notifications, etc.
├── service/ # Cross-cutting services (e.g. infrastructure/)
├── config/ # App config wiring (controllers, routes, services, envValidation)
├── controllers/ # Route handlers (authController, monitorController, etc.)
├── service/ # Business logic
│ ├── business/ # Core monitoring logic
│ ├── infrastructure/ # Server/system utilities
│ └── system/ # App-level settings
├── db/
│ └── migration/ # Database migrations (run on startup)
├── templates/ # Email/notification templates
├── types/ # Shared const tuples + derived types
└── utils/ # Shared utilities
│ ├── models/ # Mongoose schemas (Monitor, Check, Incident, User, etc.)
│ ├── migration/ # Database migrations (run on startup)
│ └── modules/ # Database-specific modules
├── middleware/v1/ # verifyJWT, rateLimiter, sanitization, responseHandler
├── routes/v1/ # API route definitions
├── validation/ # Joi input validation schemas
└── repositories/ # Data access layer
```
### Frontend Structure
-1
View File
@@ -151,7 +151,6 @@ LOG_LEVEL="debug"
- `ORIGIN`: Origin for CORS purposes
- `TOKEN_TTL`: Token time to live (in vercel/ms format)
- `LOG_LEVEL`: Debug level (debug, info, warn, error)
- `QUEUE_TYPE`: Job queue implementation (`superSimpleQueue` or `lessSimpleQueue`, optional, default: `superSimpleQueue`)
Start the backend server:
+172
View File
@@ -0,0 +1,172 @@
<p align=center> <a href="https://trendshift.io/repositories/12443" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12443" alt="bluewave-labs%2Fcheckmate | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a></p>
![](https://img.shields.io/github/license/bluewave-labs/checkmate)
![](https://img.shields.io/github/repo-size/bluewave-labs/checkmate)
![](https://img.shields.io/github/commit-activity/m/bluewave-labs/checkmate)
![](https://img.shields.io/github/last-commit/bluewave-labs/checkmate)
![](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 Mejores Prácticas](https://www.bestpractices.dev/projects/9901/badge)](https://www.bestpractices.dev/projects/9901)
[![Pregunta en 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>
<p align="center"><strong>Una aplicación de código abierto para monitoreo de infraestructura y tiempo de actividad</strong></p>
<img width="1660" alt="image" src="https://github.com/user-attachments/assets/b748f36d-a271-4965-ad0a-18bf153bbee7" />
Este repositorio contiene tanto el frontend como el backend de Checkmate, una herramienta de monitoreo de código abierto y autoalojada para rastrear hardware de servidores, tiempo de actividad, tiempos de respuesta e incidentes en tiempo real con visualizaciones atractivas. Checkmate revisa regularmente si un servidor o sitio web es accesible y funciona de manera óptima, proporcionando alertas e informes en tiempo real sobre la disponibilidad, el tiempo de inactividad y el tiempo de respuesta de los servicios monitoreados.
Checkmate también tiene un agente llamado [Capture](https://github.com/bluewave-labs/capture), para recuperar datos de servidores remotos. Aunque Capture no es obligatorio para ejecutar Checkmate, proporciona información adicional sobre el estado de la CPU, RAM, disco y temperatura de tus servidores.
Checkmate ha sido probado con más de 1000 monitores activos sin problemas ni cuellos de botella de rendimiento.
**Si deseas patrocinar una función, [visita este enlace](https://checkmate.so/sponsored-features).**
## 📚 Tabla de contenidos
- [📦 Demo](#demo)
- [🔗 Guía del usuario](#guía-del-usuario)
- [🛠️ Instalación](#instalación)
- [🏁 Traducciones](#traducciones)
- [🚀 Rendimiento](#rendimiento)
- [💚 Preguntas e ideas](#preguntas-e-ideas)
- [🧩 Características](#características)
- [🏗️ Capturas de pantalla](#capturas-de-pantalla)
- [🏗️ Tecnologías](#tecnologías)
- [🔗 Enlaces útiles](#enlaces-útiles)
- [🤝 Contribuciones](#contribuciones)
- [💰 Patrocinadores](#patrocinadores)
...
**[Texto truncado para mantener la longitud del mensaje manejable]**
## Demo
Puedes ver la última versión de [Checkmate](https://checkmate-demo.bluewavelabs.ca/) en acción. El usuario es uptimedemo@demo.com y la contraseña es Demouser1! (ten en cuenta que actualizamos el servidor de demostración de vez en cuando, así que si no funciona para ti, por favor contáctanos en el canal de Discusiones).
## Guía del usuario
Las instrucciones de uso se pueden encontrar [aquí](https://checkmate.so/docs). Todavía está en desarrollo y parte de la información puede estar desactualizada ya que continuamente añadimos funciones cada semana. ¡Ten por seguro que estamos haciendo lo mejor posible! :)
## Instalación
Consulta las instrucciones de instalación en el [portal de documentación de Checkmate](https://checkmate.so/docs/getting-started/installation).
Alternativamente, también puedes usar [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) o [Pikapods](https://www.pikapods.com/) para desplegar rápidamente una instancia de Checkmate. Si deseas monitorear tu infraestructura de servidores, necesitarás el agente [Capture](https://github.com/bluewave-labs/capture). El repositorio de Capture también contiene las instrucciones de instalación.
## Traducciones
Si deseas usar Checkmate en tu idioma, por favor [ve a esta página](https://poeditor.com/join/project/lRUoGZFCsJ) y regístrate para el idioma al que te gustaría traducir Checkmate.
## Rendimiento
Gracias a extensas optimizaciones, Checkmate opera con un uso de memoria excepcionalmente bajo, requiriendo recursos mínimos de memoria y CPU. Aquí está el uso de memoria de una instancia de Node.js ejecutándose en un servidor que monitorea 323 servidores cada minuto:
![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9)
También puedes ver el consumo de memoria de MongoDB y Redis en el mismo servidor (398Mb y 15Mb) para la misma cantidad de servidores:
![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751)
## Preguntas e Ideas
Si tienes alguna pregunta, sugerencia o comentario, tienes varias opciones:
- [Canal de Discord](https://discord.gg/NAb6H3UTjK)
- [Discusiones en GitHub](https://github.com/bluewave-labs/checkmate/discussions)
- [Grupo en Reddit](https://www.reddit.com/r/CheckmateMonitoring/)
¡No dudes en hacer preguntas o compartir tus ideas, nos encantaría saber de ti!
## Características
- Completamente de código abierto, desplegable en tus propios servidores o dispositivos personales (por ejemplo, Raspberry Pi 4 o 5)
- Monitoreo de sitios web
- Monitoreo de velocidad de carga
- Monitoreo de infraestructura (memoria, uso de disco, rendimiento de CPU, etc) - requiere el agente [Capture](https://github.com/bluewave-labs/capture)
- Monitoreo de Docker
- Monitoreo con ping
- Monitoreo de certificados SSL
- Monitoreo de puertos
- Incidentes en una sola vista
- Páginas de estado
- Notificaciones por correo, Webhooks, Discord, Telegram, Slack
- Mantenimiento programado
- Monitoreo de consultas JSON
- Soporte para múltiples idiomas
**Hoja de ruta a corto plazo:** ([Milestone 2.2](https://github.com/bluewave-labs/Checkmate/milestone/8))
- Mejores notificaciones
- Monitoreo de red
- ...y algunas funciones más
## Capturas de pantalla
<p>
<img width="1628" alt="image" src="https://github.com/user-attachments/assets/2eff6464-0738-4a32-9312-26e1e8e86275" />
</p>
<p>
<img width="1656" alt="image" src="https://github.com/user-attachments/assets/616c3563-c2a7-4ee4-af6c-7e6068955d1a" />
</p>
<p>
<img width="1652" alt="image" src="https://github.com/user-attachments/assets/7912d7cf-0d0e-4f26-aa5c-2ad7170b5c99" />
</p>
<p>
<img width="1652" alt="image" src="https://github.com/user-attachments/assets/08c2c6ac-3a2f-44d1-a229-d1746a3f9d16" />
</p>
## Tecnologías
- [ReactJs](https://react.dev/)
- [MUI (framework de React)](https://mui.com/)
- [Node.js](https://nodejs.org/en)
- [MongoDB](https://mongodb.com)
- [Recharts](https://recharts.org)
- ¡Y muchos otros componentes de código abierto!
## Enlaces útiles
- Si deseas apoyarnos, por favor considera darle una ⭐ y haz clic en "watch".
- ¿Tienes una pregunta o sugerencia para la hoja de ruta? Revisa nuestro [canal de Discord](https://discord.gg/NAb6H3UTjK) o el foro de [Discusiones](https://github.com/bluewave-labs/checkmate/discussions).
- ¿Quieres saber cuándo hay una nueva versión? Usa [Newreleases](https://newreleases.io/), un servicio gratuito para seguir lanzamientos.
- Mira un video de instalación y uso de Checkmate [aquí](https://www.youtube.com/watch?v=GfFOc0xHIwY)
## Contribuciones
Somos [Alex](http://github.com/ajhollid) (líder de equipo), [Vishnu](http://github.com/vishnusn77), [Mohadeseh](http://github.com/mohicody), [Gorkem](http://github.com/gorkem-bwl/), [Owaise](http://github.com/Owaiseimdad), [Aryaman](https://github.com/Br0wnHammer) y [Mert](https://github.com/mertssmnoglu), ayudando a personas y empresas a monitorear su infraestructura y servidores.
Nos enorgullecemos de construir conexiones fuertes con contribuyentes de todos los niveles. A pesar de ser un proyecto joven, Checkmate ya ha ganado más de 7000 estrellas y ha atraído a más de 90 contribuyentes de todo el mundo.
Nuestro repositorio ha sido marcado con estrella por empleados de **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes y NEC**, ¡así que no te detengas — participa, contribuye y aprende con nosotros!
Cómo contribuir:
0. Dale una estrella al repositorio :)
1. Revisa la [guía para contribuidores](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). Se anima a los nuevos a revisar las etiquetas `good-first-issue`.
2. Consulta la [estructura del proyecto](https://deepwiki.com/bluewave-labs/Checkmate#system-architecture) y la [visión general](https://deepwiki.com/bluewave-labs/Checkmate#overview).
3. Lee una estructura detallada de [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) si deseas profundizar en la arquitectura.
4. Abre un issue si crees que has encontrado un error.
5. Revisa los issues con la etiqueta `good-first-issue` si eres nuevo.
6. Haz un pull request para añadir nuevas funciones, mejoras o correcciones.
<a href="https://github.com/bluewave-labs/checkmate/graphs/contributors">
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
</a>
[![Historial de estrellas](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/bluewave-uptime&Date)
## Patrocinadores
Gracias a [Gitbook](https://gitbook.io/) por darnos una cuenta gratuita para su plataforma de documentación, y a [Poeditor](https://poeditor.com/) por proporcionarnos una cuenta gratuita para servicios de traducción. Si deseas patrocinar Checkmate, por favor envía un correo a hello@bluewavelabs.ca
Si deseas patrocinar una función, [visita esta página](https://checkmate.so/sponsored-features).
También puedes revisar otros proyectos de BlueWave orientados a desarrolladores y contribuidores:
- [VerifyWise](https://github.com/bluewave-labs/verifywise), la primera plataforma de gobernanza de IA de código abierto.
- [DataRoom](https://github.com/bluewave-labs/bluewave-dataroom), una aplicación de intercambio de archivos seguro, también conocida como dataroom.
- [Guidefox](https://github.com/bluewave-labs/guidefox), una app que ayuda a nuevos usuarios a aprender a usar tu producto mediante pistas, tours, popups y banners.
+1 -31
View File
@@ -1,25 +1,5 @@
<p align=center> <a href="https://trendshift.io/repositories/12443" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12443" alt="bluewave-labs%2Fcheckmate | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a></p>
<p align="center">
🇺🇸 <a href="README.md">English</a> |
🇸🇦 <a href="docs/translations/README.ar.md">العربية</a> |
🇪🇸 <a href="docs/translations/README.ca.md">Català</a> |
🇨🇿 <a href="docs/translations/README.cs.md">Čeština</a> |
🇩🇪 <a href="docs/translations/README.de.md">Deutsch</a> |
🇪🇸 <a href="docs/translations/README.es.md">Español</a> |
🇫🇮 <a href="docs/translations/README.fi.md">Suomi</a> |
🇫🇷 <a href="docs/translations/README.fr.md">Français</a> |
🇯🇵 <a href="docs/translations/README.ja.md">日本語</a> |
🇧🇷 <a href="docs/translations/README.pt-BR.md">Português (Brasil)</a> |
🇷🇺 <a href="docs/translations/README.ru.md">Русский</a> |
🇹🇭 <a href="docs/translations/README.th.md">ไทย</a> |
🇹🇷 <a href="docs/translations/README.tr.md">Türkçe</a> |
🇺🇦 <a href="docs/translations/README.uk.md">Українська</a> |
🇻🇳 <a href="docs/translations/README.vi.md">Tiếng Việt</a> |
🇨🇳 <a href="docs/translations/README.zh-CN.md">简体中文</a> |
🇹🇼 <a href="docs/translations/README.zh-TW.md">繁體中文</a>
</p>
![](https://img.shields.io/github/license/bluewave-labs/checkmate)
![](https://img.shields.io/github/repo-size/bluewave-labs/checkmate)
![](https://img.shields.io/github/commit-activity/m/bluewave-labs/checkmate)
@@ -60,14 +40,12 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
- [🤝 Contributing](#contributing)
<a id="demo"></a>
## Demo
You can see the latest build of [Checkmate](https://demo.checkmate.so/) in action.
The username is demouser@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
<a id="users-guide"></a>
## User's guide
Usage instructions can be found [here](https://checkmate.so/docs).
@@ -76,7 +54,6 @@ Usage instructions can be found [here](https://checkmate.so/docs).
- [Docker](https://www.docker.com/) installed
- [Git](https://git-scm.com/) installed
<a id="installation"></a>
## Installation
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
@@ -89,7 +66,6 @@ If you need to monitor internal HTTPS endpoints with certificates from private C
For more documentation, see the [docs directory](./docs/).
<a id="performance"></a>
## Performance
Thanks to extensive optimizations, Checkmate operates with an exceptionally small memory footprint, requiring minimal memory and CPU resources. Here’s the memory usage of a Node.js instance running on a server that monitors 323 servers every minute:
@@ -100,7 +76,6 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751)
<a id="questions--ideas"></a>
## Questions & Ideas
If you have any questions, suggestions or comments, you have several options:
@@ -110,7 +85,6 @@ If you have any questions, suggestions or comments, you have several options:
Feel free to ask questions or share your ideas - we'd love to hear from you!
<a id="features"></a>
## Features
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
@@ -135,7 +109,6 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
5. Upon a state change: an incident is either created or resolved, depending on the monitor's current status
6. Notifications are triggered based on configuration
<a id="screenshots"></a>
## Screenshots
<p>
@@ -153,7 +126,6 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
<a id="tech-stack"></a>
## Tech stack
- [ReactJs](https://react.dev/)
@@ -163,7 +135,6 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
- [Recharts](https://recharts.org)
- Lots of other open source components!
<a id="a-few-links"></a>
## A few links
- If you would like to support us, please consider giving it a ⭐ and click on "watch".
@@ -171,7 +142,6 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
- Need a ping when there's a new release? Use [Newreleases](https://newreleases.io/), a free service to track releases.
- Watch a Checkmate [installation and usage video](https://www.youtube.com/watch?v=GfFOc0xHIwY)
<a id="contributing"></a>
## Contributing
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: checkmate-chart
description: A Helm chart for Checkmate App
type: application
version: 0.2.0
appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1)
version: 0.1.0
appVersion: "2.3"
+12 -142
View File
@@ -19,11 +19,12 @@ cd checkmate/charts/helm/checkmate
### 2. Customize values.yaml
Edit `values.yaml` to update:
- `client.ingress.host` and `api.ingress.host` with your domain names
- `api.protocol` (usually http or https)
- `client.ingress.host` and `server.ingress.host` with your domain names
- `server.protocol` (usually http or https)
- **If upgrading**: Migrate persistence settings from flat structure to nested:
- Old: `persistence.mongodbSize` → New: `persistence.mongo.size`
- Add: `persistence.mongo.storageClass` (leave empty for default)
- Old: `persistence.redisSize` → New: `persistence.redis.size`
- Add: `persistence.mongo.storageClass` and `persistence.redis.storageClass` (leave empty for default)
- Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values
- **For TLS/HTTPS**: Configure ingress TLS settings (see section below)
@@ -31,7 +32,7 @@ Edit `values.yaml` to update:
```bash
helm install checkmate ./charts/helm/checkmate
```
This will deploy the client, api, and MongoDB components.
This will deploy the client, server, MongoDB, and Redis components.
### 4. Verify the deployment
Check pods and services:
@@ -42,137 +43,6 @@ kubectl get svc
Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts.
## Upgrading from a previous release
This release renames the `server.*` value block to `api.*` and drops the (unused) bundled Redis.
Read this before running `helm upgrade` on an existing install.
### `server.*` → `api.*` (values rename)
The value block formerly named `server` is now `api` (`server.image` → `api.image`,
`server.ingress.host` → `api.ingress.host`, `server.protocol` → `api.protocol`, `server.resources`,
`server.affinity`, `server.tolerations`, `server.ingress.*`, etc.).
**Your existing `server.*` overrides keep working** — the chart merges any legacy `server.*` values on
top of the `api.*` defaults (legacy wins where set), so an unchanged values file upgrades cleanly. The
shim is transitional; migrate your overrides to `api.*` at your convenience and drop the `server:`
block. If you set **both** `server.*` and `api.*` for the same field, the legacy `server.*` value wins —
don't mix them.
### Resource renames cause a replace, not a rename
The Kubernetes objects are renamed alongside the values:
| Old object | New object |
| --- | --- |
| Deployment/Service `checkmate-server` | `checkmate-api` |
| Ingress `checkmate-server-ingress` | `checkmate-api-ingress` |
| ConfigMap `checkmate-server-nginx-cm` | `checkmate-client-nginx-cm` |
Helm deletes the old objects and creates the new ones on upgrade. Expect a brief API restart and a
**new ClusterIP** for the API Service — update anything outside the chart that referenced the
`checkmate-server` Service by name. MongoDB data (its PVC) is untouched.
### Redis removed
The bundled Redis StatefulSet/Service and the `redis.*` / `persistence.redis.*` values are gone — the
scheduler runs in-process and never used them. Default installs (`redis.enabled: false`) are
unaffected. If you had explicitly set `redis.enabled: true`, **the Redis StatefulSet and its PVC are
deleted on upgrade** — back up anything you stored there first. Leftover `redis:` / `persistence.redis:`
keys in your values file are harmless (ignored).
### New secret defaults
`NODE_ENV: production`, `LOG_LEVEL: info`, and `TOKEN_TTL: 99d` are now part of the default `secrets`
block and merge into your install unless you override them. Set them explicitly if you relied on
different values (e.g. a non-production `NODE_ENV`).
### Image tags follow the chart's `appVersion`
Each tier's `image` value is now the repository **without a tag**; the tag defaults to the chart's
`appVersion`, so the client, api, worker, and mongodb images all move together when you upgrade the
chart. You can still pin per tier:
```yaml
api:
image: ghcr.io/bluewave-labs/checkmate-backend # repo only
tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag
```
- `<tier>.tag` overrides the version for that tier. The `worker` tier inherits `api.tag` (and
`api.image`) unless you set `worker.tag` / `worker.image`.
- If you put a **full `repo:tag`** in `image` (e.g. a legacy `server.image: …:v3.2.0` override), it is
used verbatim and the `tag` field is ignored — so existing pinned values keep working unchanged.
**Prefer a pinned tag over `latest`.** With the default `imagePullPolicy: IfNotPresent`, `latest` is
cached per node and never refreshed, and `helm rollback` can't move a floating tag — pin a version (or
let it default to `appVersion`) for reproducible, rollback-able deploys.
## Scaling: the worker tier & autoscaling
By default the chart runs a single all-in-one API pod that both schedules **and** processes monitoring
jobs (`worker.enabled: false`). For larger deployments you can split processing onto a dedicated,
horizontally-scalable worker tier.
### Topology
| `worker.enabled` | API (`checkmate-api`) | Worker (`checkmate-worker`) |
| --- | --- | --- |
| `false` (default) | schedules **and** processes jobs (`QUEUE_PRIMARY_PROCESSES=true`) | not deployed |
| `true` | schedules + serves the API only (`QUEUE_PRIMARY_PROCESSES=false`, derived) | processes all jobs |
`QUEUE_PRIMARY_PROCESSES` is **derived** from `worker.enabled` — you never set it directly, so the API
and worker tier can never both process (or both ignore) the queue.
### Enabling the worker tier
```yaml
worker:
enabled: true
replicas: 2 # used only when autoscaling.enabled = false
```
The worker takes **no inbound traffic** (no Service): the kubelet probes pod IPs directly on
`HEALTH_PORT` (`/livez`, `/readyz`), and graceful shutdown drains in-flight jobs before exit.
> **Invariant:** `worker.terminationGracePeriodSeconds` (default `30`) **must stay greater than the
> server drain timeout (25s)**. Lower it and Kubernetes SIGKILLs workers mid-drain, losing in-flight
> checks.
> **Caveat:** with the worker tier enabled the API no longer processes jobs, so if **all** workers are
> down nothing processes. The `autoscaling.minReplicaCount: 1` floor plus liveness restarts mitigate
> this; full failover to the API would need leader election (not supported).
### Autoscaling with KEDA
Backlog-driven autoscaling is **off by default** and requires the
[KEDA operator](https://keda.sh/) installed in-cluster. Enable it with:
```yaml
worker:
enabled: true
autoscaling:
enabled: true
minReplicaCount: 1 # processing floor — never 0, or checks silently stall
maxReplicaCount: 10
backlogPerReplica: 50 # target due-job backlog per worker
mongo:
dbName: uptime_db # MUST match the database in DB_CONNECTION_STRING
```
A KEDA `ScaledObject` queries MongoDB directly for the due-check backlog and scales the worker
Deployment between `minReplicaCount` and `maxReplicaCount`. When autoscaling is on, the Deployment
omits `replicas` so it doesn't fight the HPA KEDA creates. Make sure `autoscaling.mongo.dbName`
matches the database name in your `DB_CONNECTION_STRING`, or the scaler counts the wrong DB (always 0
backlog → never scales up).
#### Fallback: Prometheus-adapter HPA (not shipped)
If your KEDA/Mongo versions reject the `$$NOW`/`$toLong` query, or you already run Prometheus, scrape
the worker's `/metrics` (`checkmate_worker_due_backlog`), expose it via prometheus-adapter, and target
it with a standard `HorizontalPodAutoscaler`. The manifest is left to the operator; the app computes
`now` in code on this path, so the query coercion is unnecessary.
## Enabling TLS/HTTPS with cert-manager
If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers.
@@ -198,7 +68,7 @@ client:
enabled: true
secretName: checkmate-client-tls
api:
server:
protocol: https
ingress:
enabled: true
@@ -208,7 +78,7 @@ api:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: true
secretName: checkmate-api-tls
secretName: checkmate-server-tls
```
### Alternative: Using --set flags
@@ -218,13 +88,13 @@ You can also enable TLS during installation using Helm's `--set` flags:
```bash
helm install checkmate ./charts/helm/checkmate \
--set client.protocol=https \
--set api.protocol=https \
--set server.protocol=https \
--set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
--set client.ingress.tls.enabled=true \
--set client.ingress.tls.secretName=checkmate-client-tls \
--set api.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
--set api.ingress.tls.enabled=true \
--set api.ingress.tls.secretName=checkmate-api-tls
--set server.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
--set server.ingress.tls.enabled=true \
--set server.ingress.tls.secretName=checkmate-server-tls
```
### Verification
@@ -237,7 +107,7 @@ kubectl get certificates
# Check certificate details
kubectl describe certificate checkmate-client-tls
kubectl describe certificate checkmate-api-tls
kubectl describe certificate checkmate-server-tls
# Verify the secrets were created
kubectl get secrets | grep checkmate-tls
@@ -1,35 +0,0 @@
{{- /*
Back-compat shim: the `server.*` value block was renamed to `api.*`. Existing installs that
still pass a legacy `server:` block keep working — this returns the effective API values with any
legacy `server.*` overrides merged on top of the `api.*` defaults (legacy wins where set).
New installs have no `server:` key, so this is a no-op deepCopy of `.Values.api`. Consume it as:
{{- $api := include "checkmate.api" . | fromYaml -}}
then reference `$api.*` instead of `.Values.api.*`.
*/}}
{{- define "checkmate.api" -}}
{{- $api := deepCopy .Values.api -}}
{{- with .Values.server -}}
{{- $api = mergeOverwrite $api (deepCopy .) -}}
{{- end -}}
{{- $api | toYaml -}}
{{- end -}}
{{- /*
Resolve a full image reference from a repo + optional tag. `appVersion` is the single source of
truth for tags: bumping Chart.appVersion moves every tier at once. Call as:
{{ include "checkmate.image" (dict "image" $repo "tag" $tag "root" $) }}
If `image` already carries a tag (a ":" in its final path segment) it is used verbatim — this keeps
legacy `server.image: repo:tag` overrides working and lets anyone pin a full ref (e.g. tag "latest").
A ":" only in the registry segment (a registry port like "reg:5000/x") is ignored, so a tag is still
appended. Otherwise the tag is `tag` if set, else the chart's appVersion.
*/}}
{{- define "checkmate.image" -}}
{{- $lastSegment := .image | splitList "/" | last -}}
{{- if contains ":" $lastSegment -}}
{{- .image -}}
{{- else -}}
{{- printf "%s:%s" .image (.tag | default .root.Chart.AppVersion) -}}
{{- end -}}
{{- end -}}
@@ -1,65 +0,0 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-api
spec:
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
selector:
matchLabels:
app: checkmate-api
template:
metadata:
labels:
app: checkmate-api
spec:
{{- with $api.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with $api.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: api
image: {{ include "checkmate.image" (dict "image" $api.image "tag" $api.tag "root" $) }}
imagePullPolicy: {{ $api.imagePullPolicy }}
ports:
- name: http
containerPort: {{ $api.port }}
envFrom:
- secretRef:
name: checkmate-secrets
# Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive.
# NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the API is
# also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5.
livenessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $api.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ $api.probes.liveness.periodSeconds }}
timeoutSeconds: {{ $api.probes.liveness.timeoutSeconds }}
failureThreshold: {{ $api.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $api.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ $api.probes.readiness.periodSeconds }}
timeoutSeconds: {{ $api.probes.readiness.timeoutSeconds }}
failureThreshold: {{ $api.probes.readiness.failureThreshold }}
{{- with $api.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
env:
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
- name: QUEUE_MODE
value: "primary"
# Derived: when a worker tier is deployed the API only schedules (false); otherwise it is
# all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs.
- name: QUEUE_PRIMARY_PROCESSES
value: {{ not .Values.worker.enabled | quote }}
@@ -1,10 +0,0 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: v1
kind: Service
metadata:
name: checkmate-api
spec:
selector:
app: checkmate-api
ports:
- port: {{ $api.port }}
@@ -1,4 +1,3 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -23,13 +22,12 @@ spec:
{{- end }}
containers:
- name: client
image: {{ include "checkmate.image" (dict "image" .Values.client.image "tag" .Values.client.tag "root" $) }}
imagePullPolicy: {{ .Values.client.imagePullPolicy }}
image: {{ .Values.client.image }}
ports:
- containerPort: {{ .Values.client.port }}
env:
- name: UPTIME_APP_API_BASE_URL
value: "{{ $api.protocol }}://{{ $api.ingress.host }}/api/v1"
value: "{{ .Values.server.protocol }}://{{ .Values.server.ingress.host }}/api/v1"
- name: UPTIME_APP_CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
volumeMounts:
@@ -43,4 +41,4 @@ spec:
volumes:
- name: config-volume
configMap:
name: checkmate-client-nginx-cm
name: checkmate-server-nginx-cm
@@ -22,8 +22,7 @@ spec:
{{- end }}
containers:
- name: mongodb
image: {{ include "checkmate.image" (dict "image" .Values.mongodb.image "tag" .Values.mongodb.tag "root" $) }}
imagePullPolicy: {{ .Values.mongodb.imagePullPolicy }}
image: {{ .Values.mongodb.image }}
ports:
- containerPort: {{ .Values.mongodb.port }}
command: ["mongod", "--quiet", "--bind_ip_all"]
@@ -38,9 +37,7 @@ spec:
- metadata:
name: checkmate-mongo-persistent-storage
spec:
{{- with .Values.persistence.mongo.storageClass }}
storageClassName: {{ . | quote }} {{/* omitted when empty so the cluster's default StorageClass applies */}}
{{- end }}
storageClassName: {{ .Values.persistence.mongo.storageClass | quote }}
accessModes: ["ReadWriteOnce"]
resources:
requests:
+14 -20
View File
@@ -1,15 +1,14 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if eq .Values.client.ingress.host "change_me" }}
{{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
{{- end }}
{{- if eq $api.ingress.host "change_me" }}
{{- fail "api.ingress.host (or legacy server.ingress.host) must be overridden and not set to 'change_me'" }}
{{- if eq .Values.server.ingress.host "change_me" }}
{{- fail "server.ingress.host must be overridden and not set to 'change_me'" }}
{{- end }}
{{- $apiProtocol := $api.protocol }}
{{- if not (or (eq $apiProtocol "http") (eq $apiProtocol "https")) }}
{{- fail "api.protocol must be either 'http' or 'https'" }}
{{- $serverProtocol := .Values.server.protocol }}
{{- if not (or (eq $serverProtocol "http") (eq $serverProtocol "https")) }}
{{- fail "server.protocol must be either 'http' or 'https'" }}
{{- end }}
{{- $clientProtocol := .Values.client.protocol }}
@@ -22,33 +21,28 @@
{{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
{{- end }}
{{- if and $api.ingress.tls.enabled (ne $apiProtocol "https") }}
{{- fail "api.ingress.tls.enabled is true but api.protocol is not 'https'. Set api.protocol: https to ensure correct API base URL." }}
{{- if and .Values.server.ingress.tls.enabled (ne $serverProtocol "https") }}
{{- fail "server.ingress.tls.enabled is true but server.protocol is not 'https'. Set server.protocol: https to ensure correct API base URL." }}
{{- end }}
{{/* If client runs on https, API must also be https to avoid mixed content */}}
{{- if and (eq $clientProtocol "https") (ne $apiProtocol "https") }}
{{- fail "client.protocol is 'https' but api.protocol is not. Set api.protocol: https to prevent browser mixed-content issues." }}
{{- if and (eq $clientProtocol "https") (ne $serverProtocol "https") }}
{{- fail "client.protocol is 'https' but server.protocol is not. Set server.protocol: https to prevent browser mixed-content issues." }}
{{- end }}
{{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}}
{{- $cAnn := .Values.client.ingress.annotations | default dict }}
{{- $aAnn := $api.ingress.annotations | default dict }}
{{- $sAnn := .Values.server.ingress.annotations | default dict }}
{{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }}
{{- $apiHasIssuer := or (hasKey $aAnn "cert-manager.io/cluster-issuer") (hasKey $aAnn "cert-manager.io/issuer") }}
{{- $serverHasIssuer := or (hasKey $sAnn "cert-manager.io/cluster-issuer") (hasKey $sAnn "cert-manager.io/issuer") }}
{{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }}
{{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- end }}
{{- if and $api.ingress.tls.enabled (not $apiHasIssuer) }}
{{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- if and .Values.server.ingress.tls.enabled (not $serverHasIssuer) }}
{{- fail "server.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- end }}
{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}}
{{- if and .Values.worker.enabled (le (int .Values.worker.terminationGracePeriodSeconds) 25) }}
{{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }}
{{- end }}
{{/* Secret name can be omitted; we default to <release>-client|api-tls in templates */}}
{{/* Secret name can be omitted; we default to <release>-client|server-tls in templates */}}
@@ -0,0 +1,11 @@
{{- if .Values.redis.enabled }}
apiVersion: v1
kind: Service
metadata:
name: checkmate-redis
spec:
selector:
app: checkmate-redis
ports:
- port: {{ .Values.redis.port }}
{{- end }}
@@ -0,0 +1,33 @@
{{- if .Values.redis.enabled }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: checkmate-redis
spec:
replicas: 1
selector:
matchLabels:
app: checkmate-redis
template:
metadata:
labels:
app: checkmate-redis
spec:
containers:
- name: redis
image: {{ .Values.redis.image }}
ports:
- containerPort: {{ .Values.redis.port }}
volumeMounts:
- name: redis-data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: checkmate-redis-persistent-storage
spec:
storageClassName: {{ .Values.persistence.redis.storageClass | quote }}
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: {{ .Values.persistence.redis.size | default "1Gi" | quote }}
{{- end }}
@@ -4,14 +4,6 @@
{{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }}
{{- end }}
{{- /*
The bundled Mongo service ("checkmate-mongodb") lives in .Release.Namespace. App pods reach it
by bare name, but the KEDA operator runs in its own namespace and a bare name is NXDOMAIN there,
so worker autoscaling fails to query the backlog. Qualify the in-cluster host to its FQDN here so
the one connection string resolves from every namespace. External/already-qualified hosts are not
matched (the char after the host name is "." for an FQDN, which the pattern excludes).
*/}}
{{- $mongoFqdn := printf "checkmate-mongodb.%s.svc.cluster.local" .Release.Namespace }}
apiVersion: v1
kind: Secret
metadata:
@@ -19,9 +11,5 @@ metadata:
type: Opaque
stringData:
{{- range $key, $value := $secrets }}
{{- if eq $key "DB_CONNECTION_STRING" }}
{{ $key }}: {{ regexReplaceAll "(//|@)checkmate-mongodb([:/,]|$)" $value (printf "${1}%s${2}" $mongoFqdn) | quote }}
{{- else }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,37 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-server
spec:
replicas: 1
selector:
matchLabels:
app: checkmate-server
template:
metadata:
labels:
app: checkmate-server
spec:
{{- with .Values.server.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with .Values.server.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: server
image: {{ .Values.server.image }}
ports:
- containerPort: {{ .Values.server.port }}
envFrom:
- secretRef:
name: checkmate-secrets
{{- with .Values.server.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
env:
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
@@ -1,12 +1,11 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if $api.ingress.enabled }}
{{- if .Values.server.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkmate-api-ingress
{{- if $api.ingress.annotations }}
name: checkmate-server-ingress
{{- if .Values.server.ingress.annotations }}
annotations:
{{- range $key, $value := $api.ingress.annotations }}
{{- range $key, $value := .Values.server.ingress.annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
@@ -18,22 +17,22 @@ metadata:
#nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
#nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}}
spec:
ingressClassName: {{ $api.ingress.className }}
{{- if $api.ingress.tls.enabled }}
ingressClassName: {{ .Values.server.ingress.className }}
{{- if .Values.server.ingress.tls.enabled }}
tls:
- hosts:
- {{ $api.ingress.host }}
secretName: {{ default (printf "%s-api-tls" .Release.Name) $api.ingress.tls.secretName }}
- {{ .Values.server.ingress.host }}
secretName: {{ default (printf "%s-server-tls" .Release.Name) .Values.server.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ $api.ingress.host }}
- host: {{ .Values.server.ingress.host }}
http:
paths:
- path: /api/v1
pathType: Prefix
backend:
service:
name: checkmate-api
name: checkmate-server
port:
number: {{ $api.port }}
number: {{ .Values.server.port }}
{{- end }}
@@ -1,8 +1,7 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: v1
kind: ConfigMap
kind: ConfigMap
metadata:
name: checkmate-client-nginx-cm
name: checkmate-server-nginx-cm
data:
default.conf: |
server {
@@ -23,7 +22,7 @@ data:
}
# location /api/ {
# proxy_pass http://{{ $api.ingress.host }}:5000/api/;
# proxy_pass http://{{ .Values.server.ingress.host }}:5000/api/;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
@@ -32,7 +31,7 @@ data:
# }
location /api-docs/ {
proxy_pass http://{{ $api.ingress.host }}:5000/api-docs/;
proxy_pass http://{{ .Values.server.ingress.host }}:5000/api-docs/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Service
metadata:
name: checkmate-server
spec:
selector:
app: checkmate-server
ports:
- port: {{ .Values.server.port }}
@@ -1,66 +0,0 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if .Values.worker.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-worker
spec:
{{- if not .Values.worker.autoscaling.enabled }}
replicas: {{ .Values.worker.replicas }} # omitted when autoscaling owns the replica count
{{- end }}
selector:
matchLabels:
app: checkmate-worker
template:
metadata:
labels:
app: checkmate-worker
spec:
terminationGracePeriodSeconds: {{ .Values.worker.terminationGracePeriodSeconds }}
{{- with .Values.worker.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with .Values.worker.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: worker
image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $api.image) "tag" (.Values.worker.tag | default $api.tag) "root" $) }}
imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $api.imagePullPolicy }}
ports:
- name: health
containerPort: {{ .Values.worker.healthPort }}
envFrom:
- secretRef:
name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv)
env:
# Secret default CLIENT_HOST is the non-URL "change_me"; override like the API so validateEnv passes.
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
- name: QUEUE_MODE
value: "worker"
- name: HEALTH_PORT
value: {{ .Values.worker.healthPort | quote }}
livenessProbe:
httpGet:
path: /livez
port: health
initialDelaySeconds: {{ .Values.worker.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.worker.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.worker.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.worker.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: /readyz
port: health
initialDelaySeconds: {{ .Values.worker.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.worker.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.worker.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.worker.probes.readiness.failureThreshold }}
{{- with .Values.worker.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
{{- end }}
@@ -1,30 +0,0 @@
{{- if and .Values.worker.enabled .Values.worker.autoscaling.enabled }}
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: checkmate-worker
spec:
scaleTargetRef:
name: checkmate-worker
minReplicaCount: {{ .Values.worker.autoscaling.minReplicaCount }} # floor: never 0 — a cold gap stops checks
maxReplicaCount: {{ .Values.worker.autoscaling.maxReplicaCount }}
cooldownPeriod: {{ .Values.worker.autoscaling.cooldownPeriod }}
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: {{ .Values.worker.autoscaling.stabilizationWindowSeconds }}
triggers:
- type: mongodb
metadata:
dbName: {{ .Values.worker.autoscaling.mongo.dbName | quote }}
collection: {{ .Values.worker.autoscaling.mongo.collection | quote }}
# The Phase 2 countDueBacklog predicate, time-relativized with $$NOW (epoch-ms via $toLong):
query: >-
{ "type": "check", "isActive": true,
"$expr": { "$lte": ["$nextScheduledAt", { "$toLong": "$$NOW" }] },
"$or": [ { "lockedUntil": null },
{ "$expr": { "$lt": ["$lockedUntil", { "$toLong": "$$NOW" }] } } ] }
queryValue: {{ .Values.worker.autoscaling.backlogPerReplica | quote }}
connectionStringFromEnv: DB_CONNECTION_STRING # resolved from the worker's checkmate-secrets
{{- end }}
+27 -65
View File
@@ -1,7 +1,5 @@
client:
image: ghcr.io/bluewave-labs/checkmate-client
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
image: ghcr.io/bluewave-labs/checkmate-client:v3.2.0
port: 80
protocol: http
ingress:
@@ -19,24 +17,10 @@ client:
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
api:
image: ghcr.io/bluewave-labs/checkmate-backend
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
server:
image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0
port: 52345
protocol: http
# Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up).
probes:
liveness:
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readiness:
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
ingress:
enabled: true
host: change_me
@@ -47,62 +31,40 @@ api:
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
enabled: false
# secretName: {{ .Release.Name }}-api-tls # Optional; defaults to <release>-api-tls if omitted
# Note: when enabling TLS, also set api.protocol: https and add
# secretName: {{ .Release.Name }}-server-tls # Optional; defaults to <release>-server-tls if omitted
# Note: when enabling TLS, also set server.protocol: https and add
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
mongodb:
image: ghcr.io/bluewave-labs/checkmate-mongo
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 27017
worker:
# Off by default; existing installs are unchanged. When true, a dedicated worker processes all
# jobs and the API stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the
# API is all-in-one and processes jobs itself.
redis:
enabled: false
image: "" # defaults to api.image when empty, same as API image
tag: "" # defaults to api.tag (-> Chart.appVersion) when empty
imagePullPolicy: "" # defaults to api.imagePullPolicy when empty
replicas: 1 # used only when autoscaling.enabled = false
healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics
terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain
resources: {}
affinity: {}
tolerations: {}
probes:
liveness: # /livez — failureThreshold>=3 so one slow tick never restarts mid-job
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readiness: # /readyz — flips to 503 on drain
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
autoscaling:
enabled: false # requires the KEDA operator installed in-cluster
minReplicaCount: 1 # processing floor — never 0 (a cold gap silently stops checks)
maxReplicaCount: 10
backlogPerReplica: 50 # KEDA queryValue: target due-backlog per worker
cooldownPeriod: 300 # seconds before scaling an idle target down
stabilizationWindowSeconds: 300 # damp the interval-boundary herd
mongo:
dbName: uptime_db # must match DB_CONNECTION_STRING's database
collection: jobs
image: redis:7.2
port: 6379
mongodb:
image: ghcr.io/bluewave-labs/checkmate-mongo:v3.2.0
port: 27017
secrets:
JWT_SECRET: change_me
DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db
# REFRESH_TOKEN_SECRET: change_me
# SYSTEM_EMAIL_ADDRESS: test@example.com
# SYSTEM_EMAIL_PASSWORD: change_me
# SYSTEM_EMAIL_HOST: smtp.example.com
# SYSTEM_EMAIL_PORT: "587"
# PAGESPEED_API_KEY: change_me
DB_CONNECTION_STRING: mongodb://checkmate-mongodb.namespace.svc:27017/uptime_db
CLIENT_HOST: change_me
NODE_ENV: production
LOG_LEVEL: info
TOKEN_TTL: 99d
# REDIS_HOST: redis
# REDIS_PORT: "6379"
# DB_TYPE: MongoDB
# TOKEN_TTL: 99d
# REFRESH_TOKEN_TTL: 99d
persistence:
mongo:
size: 5Gi
storageClass: ""
redis:
size: 1Gi
storageClass: ""
+4 -256
View File
@@ -1,14 +1,13 @@
{
"name": "client",
"version": "3.9.1",
"version": "3.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.9.1",
"version": "3.7.1",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@hello-pangea/dnd": "^18.0.0",
@@ -58,7 +57,6 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.6",
"lint-staged": "^16.2.7",
"mongoose": "9.6.2",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "^6.4.2"
@@ -86,7 +84,6 @@
"node_modules/@babel/core": {
"version": "7.29.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -304,25 +301,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "4.2.0",
"license": "MIT",
@@ -376,7 +354,6 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -414,7 +391,6 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1131,16 +1107,6 @@
"supercluster": "^8.0.1"
}
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.11.tgz",
"integrity": "sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==",
"dev": true,
"license": "MIT",
"dependencies": {
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@mui/core-downloads-tracker": {
"version": "7.3.7",
"license": "MIT",
@@ -1194,7 +1160,6 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1299,7 +1264,6 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2340,7 +2304,6 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2356,7 +2319,6 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2390,23 +2352,6 @@
"version": "0.0.6",
"license": "MIT"
},
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/whatwg-url": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz",
"integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/webidl-conversions": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
@@ -2447,7 +2392,6 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -2728,7 +2672,6 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3063,7 +3006,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3078,16 +3020,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bson": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz",
"integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/bytewise": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz",
@@ -3505,8 +3437,7 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3901,7 +3832,6 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4697,7 +4627,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5293,16 +5222,6 @@
"node": ">=4.0"
}
},
"node_modules/kareem": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/kareem/-/kareem-3.3.0.tgz",
"integrity": "sha512-kpSuLD3/7RenBnjnJdOHXCKC8dTd1JzeOiJhN0necWWci6cC+qX+VuwPnMVgb+a4+KNJSfgqahpnfWaeDXCimw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
@@ -5493,7 +5412,6 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -5533,13 +5451,6 @@
"node": ">= 0.4"
}
},
"node_modules/memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"dev": true,
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -5629,109 +5540,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mongodb": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.2.0.tgz",
"integrity": "sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.3.0",
"bson": "^7.2.0",
"mongodb-connection-string-url": "^7.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@aws-sdk/credential-providers": "^3.806.0",
"@mongodb-js/zstd": "^7.0.0",
"gcp-metadata": "^7.0.1",
"kerberos": "^7.0.0",
"mongodb-client-encryption": ">=7.0.0 <7.1.0",
"snappy": "^7.3.2",
"socks": "^2.8.6"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-providers": {
"optional": true
},
"@mongodb-js/zstd": {
"optional": true
},
"gcp-metadata": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"snappy": {
"optional": true
},
"socks": {
"optional": true
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz",
"integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/whatwg-url": "^13.0.0",
"whatwg-url": "^14.1.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/mongoose": {
"version": "9.6.2",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.6.2.tgz",
"integrity": "sha512-7m8HntjkoRnwEmuPC0kdlwcZXJOQf4twumFj+PNzg/anqqZE2Er7hQslqyzy07mP3JcFjoTSgH5765PyqOXsxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"kareem": "3.3.0",
"mongodb": "~7.2",
"mpath": "0.9.0",
"mquery": "6.0.0",
"ms": "2.1.3",
"sift": "17.1.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mongoose"
}
},
"node_modules/mpath": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
"integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/mquery": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz",
"integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
@@ -6270,7 +6078,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6282,7 +6089,6 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6482,8 +6288,7 @@
},
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6633,7 +6438,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -6909,13 +6713,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sift": {
"version": "17.1.3",
"resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz",
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
"dev": true,
"license": "MIT"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -7026,16 +6823,6 @@
"node": ">=0.10.0"
}
},
"node_modules/sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"memory-pager": "^1.0.2"
}
},
"node_modules/split-string": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
@@ -7349,19 +7136,6 @@
"node": ">=8.0"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ts-api-utils": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
@@ -7475,7 +7249,6 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7611,7 +7384,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -7700,30 +7472,6 @@
"node": ">=0.10.0"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"dev": true,
+2 -4
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "3.9.1",
"version": "3.7.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,10 +14,9 @@
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
"**/*": "prettier --write"
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@hello-pangea/dnd": "^18.0.0",
@@ -67,7 +66,6 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.6",
"lint-staged": "^16.2.7",
"mongoose": "9.6.2",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "^6.4.2"
@@ -35,7 +35,7 @@ const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats
}
const responseTimes = validChecks.map((check) => check.originalResponseTime);
const max = Math.round(Math.max(...responseTimes));
const max = Math.max(...responseTimes);
const avg = Math.round(
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
);
@@ -73,20 +73,14 @@ export const HistogramResponseTime = ({
<Stack
justifyContent="center"
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
width={110}
minWidth={70}
pr={statsPosition === "left" ? theme.spacing(8) : 0}
pl={statsPosition === "right" ? theme.spacing(8) : 0}
>
<Typography
variant="body2"
noWrap
>
<Typography variant="body2">
{t("common.charts.histogram.avg", { value: stats.avg })}
</Typography>
<Typography
variant="body2"
noWrap
>
<Typography variant="body2">
{t("common.charts.histogram.max", { value: stats.max })}
</Typography>
</Stack>
+13 -21
View File
@@ -1,36 +1,30 @@
import { BaseChart } from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import Box, { type BoxProps } from "@mui/material/Box";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { forwardRef, useMemo, useState, useEffect } from "react";
import { useMemo, useState, useEffect } from "react";
import { getInfraGaugeColor } from "@/Utils/MonitorUtils";
const MINIMUM_VALUE = 0;
const MAXIMUM_VALUE = 100;
interface GaugeProps extends Omit<BoxProps, "color"> {
export const Gauge = ({
isLoading = false,
progress = 0,
radius = 70,
strokeWidth = 15,
precision = 1,
unit = "%",
}: {
isLoading?: boolean;
progress?: number;
radius?: number;
strokeWidth?: number;
precision?: number;
unit?: string;
}
export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
{
isLoading = false,
progress = 0,
radius = 70,
strokeWidth = 15,
precision = 1,
unit = "%",
...rest
},
ref
) {
}) => {
const theme = useTheme();
const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE));
@@ -57,19 +51,17 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
if (isLoading) {
return null;
return;
}
return (
<Box
ref={ref}
display={"inline-block"}
position={"relative"}
width={radius}
height={radius}
bgcolor={theme.palette.background.paper}
borderRadius={"50%"}
{...rest}
>
<svg
viewBox={`0 0 ${totalSize} ${totalSize}`}
@@ -113,7 +105,7 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
</Typography>
</Box>
);
});
};
export const DetailGauge = ({
title,
@@ -93,20 +93,3 @@ export const ValueLabel = ({ value, text }: { value: ValueType; text: string })
</BaseBox>
);
};
export const ColoredLabel = ({ text, color }: { text: string; color: string }) => {
const theme = useTheme();
return (
<BaseBox
sx={{
width: "fit-content",
padding: theme.spacing(1, 3),
color: color,
borderColor: color,
}}
>
<Typography>{text}</Typography>
</BaseBox>
);
};
@@ -1,42 +0,0 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { useTheme, alpha } from "@mui/material/styles";
import { LAYOUT } from "@/Utils/Theme/constants";
interface StepProgressProps {
steps: number;
current: number;
}
export const StepProgress = ({ steps, current }: StepProgressProps) => {
const theme = useTheme();
const activeBg = `linear-gradient(135deg, ${theme.palette.primary.light} 0%, ${theme.palette.primary.main} 100%)`;
const completedBg = `linear-gradient(135deg, ${alpha(theme.palette.primary.light, 0.5)} 0%, ${alpha(theme.palette.primary.main, 0.5)} 100%)`;
const getSegmentBackground = (index: number) => {
if (index < current) return completedBg;
if (index === current) return activeBg;
return theme.palette.divider;
};
return (
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
width="100%"
>
{Array.from({ length: steps }).map((_, index) => (
<Box
key={index}
flex={1}
height={theme.spacing(LAYOUT.XXS)}
borderRadius={theme.shape.borderRadius}
sx={{
background: getSegmentBackground(index),
transition: "background 0.2s ease",
}}
/>
))}
</Stack>
);
};
@@ -142,7 +142,7 @@ export function DataTable<
>
<Typography
component="div"
color={theme.palette.text.primary}
color={theme.palette.text.secondary}
>
{header.mobileLabel ?? header.content}
</Typography>
@@ -6,11 +6,7 @@ import type { TooltipProps } from "@mui/material/Tooltip";
type StyledTooltipProps = TooltipProps;
export const Tooltip = ({
placement = "top",
slotProps,
...props
}: StyledTooltipProps) => {
export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) => {
const theme = useTheme();
return (
@@ -31,7 +27,6 @@ export const Tooltip = ({
boxShadow: theme.shadows[6],
},
},
...slotProps,
}}
>
{props.children}
@@ -27,4 +27,3 @@ export * from "./OfflineBanner";
export * from "./Avatar";
export * from "./StatusCodeLabel";
export * from "./StrategyBadge";
export * from "./StepProgress";
+19 -25
View File
@@ -14,19 +14,17 @@ type AutoCompleteInputProps = Omit<
renderInput?: AutocompleteProps<any, boolean, boolean, boolean>["renderInput"];
fieldLabel?: string;
required?: boolean;
renderOptionContent?: (option: any) => React.ReactNode;
};
export const AutoCompleteInput = ({
fieldLabel,
required,
renderInput,
renderOptionContent,
...props
}: AutoCompleteInputProps) => {
const theme = useTheme();
const multiple = props.multiple;
const { renderOption: customRenderOption, ...autocompleteProps } = props;
const defaultRenderInput = (params: any) => (
<TextField
{...params}
@@ -36,7 +34,7 @@ export const AutoCompleteInput = ({
const autocomplete = (
<Autocomplete
{...autocompleteProps}
{...props}
disableCloseOnSelect={!!multiple}
popupIcon={
<ChevronDown
@@ -48,28 +46,24 @@ export const AutoCompleteInput = ({
renderInput={renderInput || defaultRenderInput}
getOptionKey={(option) => option.id}
renderTags={() => null}
renderOption={
customRenderOption ||
((props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<ListItem
key={key}
{...optionProps}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<ListItem
key={key}
{...optionProps}
>
<Stack
direction={"row"}
alignItems={"center"}
gap={theme.spacing(2)}
>
<Stack
direction={"row"}
alignItems={"center"}
gap={theme.spacing(2)}
width="100%"
>
{multiple && <Checkbox checked={selected} />}
{renderOptionContent ? renderOptionContent(option) : option.name}
</Stack>
</ListItem>
);
})
}
{multiple && <Checkbox checked={selected} />}
{option.name}
</Stack>
</ListItem>
);
}}
sx={{
"&.MuiAutocomplete-root .MuiAutocomplete-input": {
padding: `0 ${theme.spacing(5)}`,
+2 -8
View File
@@ -1,7 +1,7 @@
import { forwardRef } from "react";
import Checkbox from "@mui/material/Checkbox";
import type { CheckboxProps } from "@mui/material/Checkbox";
import { Square, SquareCheck, SquareMinus } from "lucide-react";
import { Square, SquareCheck } from "lucide-react";
import { useTheme } from "@mui/material/styles";
type CheckboxInputProps = CheckboxProps & {
@@ -34,15 +34,9 @@ export const CheckboxInput = forwardRef<HTMLInputElement, CheckboxInputProps>(
strokeWidth={1.5}
/>
}
indeterminateIcon={
<SquareMinus
size={16}
strokeWidth={1.5}
/>
}
sx={{
color: theme.palette.text.secondary,
"&.Mui-checked, &.MuiCheckbox-indeterminate": {
"&.Mui-checked": {
color: theme.palette.primary.main,
},
"&:hover": { backgroundColor: "transparent" },
+1 -1
View File
@@ -22,7 +22,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
width: "100%",
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: props.multiline ? "auto" : 34,
height: 34,
fontSize: typographyLevels.base,
overflow: "hidden",
},
@@ -0,0 +1,138 @@
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>
);
};
@@ -1,11 +1,9 @@
import Stack from "@mui/material/Stack";
import { Select, Autocomplete, Button, TextField } from "@/Components/inputs";
import { ColoredLabel } from "@/Components/design-elements";
import { Select, Button } from "@/Components/inputs";
import MenuItem from "@mui/material/MenuItem";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { MonitorType } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
@@ -30,34 +28,22 @@ export const ControlsFilter = ({
setSelectedStatus,
selectedState,
setSelectedState,
tagOptions,
selectedTags,
setSelectedTags,
onClearFilters,
}: {
showTypes?: boolean;
selectedTypes?: MonitorType[];
setSelectedTypes?: React.Dispatch<React.SetStateAction<MonitorType[]>>;
selectedStatus?: string;
setSelectedStatus?: React.Dispatch<React.SetStateAction<string>>;
selectedState?: string;
setSelectedState?: React.Dispatch<React.SetStateAction<string>>;
tagOptions?: Tag[];
selectedTags?: string[];
setSelectedTags?: React.Dispatch<React.SetStateAction<string[]>>;
selectedStatus: string;
setSelectedStatus: React.Dispatch<React.SetStateAction<string>>;
selectedState: string;
setSelectedState: React.Dispatch<React.SetStateAction<string>>;
onClearFilters: () => void;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isFilterActive =
(selectedTypes?.length ?? 0) > 0 ||
Boolean(selectedStatus) ||
Boolean(selectedState) ||
(selectedTags?.length ?? 0) > 0;
const selectedTagObjects = (tagOptions ?? []).filter((tag) =>
(selectedTags ?? []).includes(tag.id)
);
(selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== "";
return (
<Stack
direction={isSmall ? "column" : "row"}
@@ -80,68 +66,34 @@ export const ControlsFilter = ({
))}
</Select>
)}
{setSelectedStatus && (
<Select
placeholder="Status"
value={selectedStatus ?? ""}
onChange={(e) => setSelectedStatus(e.target.value)}
>
{statuses.map((status) => (
<MenuItem
key={status}
value={status}
>
<Typography textTransform={"capitalize"}>{status}</Typography>
</MenuItem>
))}
</Select>
)}
{setSelectedState && (
<Select
placeholder="State"
value={selectedState ?? ""}
onChange={(e) => setSelectedState(e.target.value)}
>
{states.map((state) => (
<MenuItem
key={state}
value={state}
>
<Typography textTransform={"capitalize"}>{state}</Typography>
</MenuItem>
))}
</Select>
)}
{tagOptions && setSelectedTags && (
<Autocomplete
multiple
options={tagOptions}
value={selectedTagObjects}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: Tag[]) => {
setSelectedTags(newValue.map((tag) => tag.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
renderOptionContent={(option) => (
<ColoredLabel
text={option.name}
color={option.color}
/>
)}
renderInput={(params) => (
<TextField
{...params}
placeholder={t("common.table.headers.tags")}
/>
)}
slotProps={{
popper: {
sx: { width: "fit-content !important", minWidth: 280 },
},
}}
sx={{ minWidth: 180 }}
/>
)}
<Select
placeholder="Status"
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
>
{statuses.map((status) => (
<MenuItem
key={status}
value={status}
>
<Typography textTransform={"capitalize"}>{status}</Typography>
</MenuItem>
))}
</Select>
<Select
placeholder="State"
value={selectedState}
onChange={(e) => setSelectedState(e.target.value)}
>
{states.map((state) => (
<MenuItem
key={state}
value={state}
>
<Typography textTransform={"capitalize"}>{state}</Typography>
</MenuItem>
))}
</Select>
{isFilterActive && (
<Button
variant="contained"
+1
View File
@@ -15,3 +15,4 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar";
export * from "./BulkEditNotificationsModal";
-6
View File
@@ -17,7 +17,6 @@ import {
User,
Lock,
Users,
Tag,
} from "lucide-react";
export const getMenu = (t: Function) => {
@@ -42,11 +41,6 @@ export const getMenu = (t: Function) => {
path: "notifications",
icon: <Icon icon={Bell} />,
},
{
name: t("components.sidebar.menu.tags"),
path: "tags",
icon: <Icon icon={Tag} />,
},
{
name: t("components.sidebar.menu.checks"),
path: "checks",
+1 -3
View File
@@ -20,8 +20,6 @@ export const useToast = () => {
showToast(msg, { ...opts, type: "error" });
const toastInfo = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "info" });
const toastWarning = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "warning" });
return { showToast, toastSuccess, toastError, toastInfo, toastWarning };
return { showToast, toastSuccess, toastError, toastInfo };
};
+3 -14
View File
@@ -9,10 +9,7 @@ import type { Monitor } from "@/Types/Monitor";
interface ApiResponse {
success: boolean;
msg: string;
data: {
monitors: Monitor[];
failedCount: number;
};
data: Monitor[];
}
interface UseBulkMonitorActionsReturn {
@@ -28,7 +25,7 @@ export const useBulkMonitorActions = (
page?: number
): UseBulkMonitorActionsReturn => {
const [selectedRows, setSelectedRows] = useState<string[]>([]);
const { toastSuccess, toastError, toastInfo, toastWarning } = useToast();
const { toastSuccess, toastError, toastInfo } = useToast();
const { t } = useTranslation();
// Clear selection when page changes
@@ -43,21 +40,13 @@ export const useBulkMonitorActions = (
pause,
});
const affectedCount = res.data?.data?.monitors?.length ?? 0;
const failedCount = res.data?.data?.failedCount ?? 0;
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 if (failedCount > 0) {
toastWarning(
t("pages.common.monitors.bulkPause.partialFailure", {
successCount: affectedCount,
failedCount,
})
);
} else {
const key = pause
? "pages.common.monitors.bulkPause.paused"
+111 -130
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor";
import { DefaultPageSpeedStrategy } from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions {
@@ -13,147 +13,128 @@ const getBaseDefaults = (data?: Monitor | null) => ({
description: data?.description || "",
interval: data?.interval || 60000,
notifications: data?.notifications || [],
tags: data?.tags || [],
statusWindowSize: data?.statusWindowSize || 5,
statusWindowThreshold: data?.statusWindowThreshold || 60,
});
// Geo-check defaults — only http and ping support geo checks.
const getGeoCheckDefaults = (data?: Monitor | null) => ({
geoCheckEnabled: data?.geoCheckEnabled ?? false,
geoCheckLocations: data?.geoCheckLocations || [],
geoCheckInterval: data?.geoCheckInterval || 300000,
});
export const getMonitorDefaults = (
type: MonitorType,
data: Monitor | null = null
): MonitorFormData => {
const base = getBaseDefaults(data);
let defaults: MonitorFormData;
switch (type) {
case "http":
defaults = {
...base,
...getGeoCheckDefaults(data),
type: "http",
url: data?.url || "",
method: data?.method ?? DefaultHttpMethod,
ignoreTlsErrors: data?.ignoreTlsErrors || false,
useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "",
customUpCodes: data?.customUpCodes || [],
};
break;
case "ping":
defaults = {
...base,
...getGeoCheckDefaults(data),
type: "ping",
url: data?.url || "",
};
break;
case "port":
defaults = {
...base,
type: "port",
url: data?.url || "",
port: data?.port || 80,
};
break;
case "docker":
defaults = {
...base,
type: "docker",
url: data?.url || "",
};
break;
case "game":
defaults = {
...base,
type: "game",
url: data?.url || "",
port: data?.port || 27015,
gameId: data?.gameId || "",
};
break;
case "grpc":
defaults = {
...base,
type: "grpc",
url: data?.url || "",
port: data?.port || 50051,
grpcServiceName: data?.grpcServiceName || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
case "pagespeed":
defaults = {
...base,
type: "pagespeed",
url: data?.url || "",
strategy: data?.strategy ?? DefaultPageSpeedStrategy,
};
break;
case "hardware":
defaults = {
...base,
type: "hardware",
url: data?.url || "",
secret: data?.secret || "",
cpuAlertThreshold: data?.cpuAlertThreshold ?? 100,
memoryAlertThreshold: data?.memoryAlertThreshold ?? 100,
diskAlertThreshold: data?.diskAlertThreshold ?? 100,
tempAlertThreshold: data?.tempAlertThreshold ?? 100,
selectedDisks: data?.selectedDisks || [],
};
break;
case "websocket":
defaults = {
...base,
type: "websocket",
url: data?.url || "",
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,
...getGeoCheckDefaults(data),
type: "http",
url: "",
method: DefaultHttpMethod,
ignoreTlsErrors: false,
useAdvancedMatching: false,
matchMethod: "",
expectedValue: "",
jsonPath: "",
customUpCodes: [],
};
}
return defaults;
};
export const useMonitorForm = ({
data = null,
defaultType = "http",
}: UseMonitorFormOptions = {}) => {
return useMemo(() => {
const type = data?.type || defaultType;
return { schema: monitorSchema, defaults: getMonitorDefaults(type, data) };
const base = getBaseDefaults(data);
let defaults: MonitorFormData;
switch (type) {
case "http":
defaults = {
...base,
type: "http",
url: data?.url || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "",
};
break;
case "ping":
defaults = {
...base,
type: "ping",
url: data?.url || "",
};
break;
case "port":
defaults = {
...base,
type: "port",
url: data?.url || "",
port: data?.port || 80,
};
break;
case "docker":
defaults = {
...base,
type: "docker",
url: data?.url || "",
};
break;
case "game":
defaults = {
...base,
type: "game",
url: data?.url || "",
port: data?.port || 27015,
gameId: data?.gameId || "",
};
break;
case "grpc":
defaults = {
...base,
type: "grpc",
url: data?.url || "",
port: data?.port || 50051,
grpcServiceName: data?.grpcServiceName || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
case "pagespeed":
defaults = {
...base,
type: "pagespeed",
url: data?.url || "",
strategy: data?.strategy ?? DefaultPageSpeedStrategy,
};
break;
case "hardware":
defaults = {
...base,
type: "hardware",
url: data?.url || "",
secret: data?.secret || "",
cpuAlertThreshold: data?.cpuAlertThreshold ?? 100,
memoryAlertThreshold: data?.memoryAlertThreshold ?? 100,
diskAlertThreshold: data?.diskAlertThreshold ?? 100,
tempAlertThreshold: data?.tempAlertThreshold ?? 100,
selectedDisks: data?.selectedDisks || [],
};
break;
case "websocket":
defaults = {
...base,
type: "websocket",
url: data?.url || "",
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,
type: "http",
url: "",
ignoreTlsErrors: false,
useAdvancedMatching: false,
matchMethod: "",
expectedValue: "",
jsonPath: "",
};
}
return { schema: monitorSchema, defaults };
}, [data, defaultType]);
};
-8
View File
@@ -78,14 +78,6 @@ function buildDefaults(data: Notification | null): NotificationFormData {
accessToken: data.accessToken || "",
};
}
if (data?.type === "ntfy") {
return {
type: "ntfy",
notificationName: data.notificationName || "",
address: data.address || "",
topic: data.topic || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
-1
View File
@@ -31,7 +31,6 @@ export const useStatusPageForm = ({
const defaults: StatusPageFormData = {
companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(),
customDomain: data?.customDomain ?? null,
timezone: resolveTimezone(data?.timezone),
type: data?.type || ["uptime"],
color: data?.color || "#13715B",
-18
View File
@@ -1,18 +0,0 @@
import { useMemo } from "react";
import { tagSchema, type TagFormData } from "@/Validation/tag";
import type { Tag } from "@/Types/Tag";
interface UseTagsFormOptions {
data?: Tag | null;
}
export const useTagsForm = ({ data }: UseTagsFormOptions) => {
return useMemo(() => {
const defaults: TagFormData = {
name: data?.name ?? "",
color: data?.color ?? "#13715B",
};
return { schema: tagSchema, defaults };
}, [data]);
};
File diff suppressed because it is too large. Load diff
@@ -1,14 +1,7 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import {
Table,
Pagination,
StatusLabel,
Gauge,
ColoredLabel,
} from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements";
import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react";
@@ -20,12 +13,10 @@ import { useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { Checkbox } from "@/Components/inputs";
interface InfraMonitorsTableProps {
monitors: Monitor[];
tags: Tag[];
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
@@ -43,7 +34,6 @@ interface InfraMonitorsTableProps {
export const InfraMonitorsTable = ({
monitors,
tags,
refetch,
setSelectedMonitor,
sortField,
@@ -277,32 +267,6 @@ export const InfraMonitorsTable = ({
return <Gauge progress={diskUsage} />;
},
},
{
id: "tags",
content: t("common.table.headers.tags"),
render: (row) => {
if (row.tags.length === 0) return "-";
return (
<Stack
justifyContent={"center"}
direction={isSmall ? "column" : "row"}
gap={theme.spacing(SPACING.LG)}
>
{row.tags.map((tagId) => {
const fullTag = tags.find((t) => t.id === tagId);
if (!fullTag) return null;
return (
<ColoredLabel
key={fullTag.id}
text={fullTag.name}
color={fullTag.color}
/>
);
})}
</Stack>
);
},
},
{
id: "actions",
@@ -1,7 +1,6 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { MonitorBasePageWithStates } from "@/Components/design-elements";
import { HeaderCreate } from "@/Components/common";
import {
ControlsFilter,
@@ -14,9 +13,7 @@ import { Play, Pause } from "lucide-react";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
@@ -41,15 +38,12 @@ const InfrastructureMonitors = () => {
// Local state
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [search, setSearch] = useState<string>("");
const [page, setPage] = useState<number>(0);
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const { data: tags } = useGet<Tag[]>("/tags/team");
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
@@ -57,7 +51,6 @@ const InfrastructureMonitors = () => {
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
@@ -88,7 +81,6 @@ const InfrastructureMonitors = () => {
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "hardware");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "1");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
@@ -96,7 +88,7 @@ const InfrastructureMonitors = () => {
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, filter, field, sortOrder, selectedTags]);
}, [page, rowsPerPage, filter, field, sortOrder]);
const {
data: monitorsWithChecksData,
@@ -122,9 +114,7 @@ const InfrastructureMonitors = () => {
} = useBulkMonitorActions(refetch, page);
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
const hasActiveFilters = Boolean(selectedStatus || selectedState || search);
// Show empty state only when there are truly no monitors (not just filtered out)
const effectiveTotalCount =
@@ -175,9 +165,6 @@ const InfrastructureMonitors = () => {
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
@@ -189,30 +176,6 @@ const InfrastructureMonitors = () => {
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
@@ -237,7 +200,6 @@ const InfrastructureMonitors = () => {
<InfraMonitorsTable
monitors={monitorsWithChecksData?.monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
+7 -34
View File
@@ -1,15 +1,12 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Stats } from "@/Pages/Logs/components/Stats";
import { MongoStats } from "@/Pages/Logs/components/MongoStats";
import { MongoCollectionStats } from "./components/MongoCollectionStats";
import { StatGauges } from "@/Pages/Logs/components/StatGauges";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useGet } from "@/Hooks/UseApi";
import type { Diagnostics } from "@/Types/Diagnostics";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
export const TabDiagnostics = () => {
const theme = useTheme();
@@ -22,12 +19,12 @@ export const TabDiagnostics = () => {
} = useGet<Diagnostics>("/diagnostic/system", {}, { refreshInterval: 5000 });
return (
<Stack gap={theme.spacing(LAYOUT.XL)}>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack gap={theme.spacing(SPACING.SM)}>
<Stack gap={theme.spacing(12)}>
<Stack gap={theme.spacing(4)}>
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
color="text.secondary"
>
{t("pages.logs.diagnostics.sections.runtimeStats.title")}
</Typography>
@@ -37,11 +34,11 @@ export const TabDiagnostics = () => {
</Stack>
<Stats diagnostics={diagnostics} />
</Stack>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack gap={theme.spacing(SPACING.SM)}>
<Stack gap={theme.spacing(4)}>
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
color="text.secondary"
>
{t("pages.logs.diagnostics.sections.memoryCpu.title")}
</Typography>
@@ -51,30 +48,6 @@ export const TabDiagnostics = () => {
</Stack>
<StatGauges diagnostics={diagnostics} />
</Stack>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
>
{t("pages.logs.diagnostics.sections.mongoDBStats.title")}
</Typography>
<Typography color={theme.palette.text.secondary}>
{t("pages.logs.diagnostics.sections.mongoDBStats.description")}
</Typography>
<MongoStats diagnostics={diagnostics} />
</Stack>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
>
{t("pages.logs.diagnostics.sections.mongoDBCollectionStats.title")}
</Typography>
<Typography color={theme.palette.text.secondary}>
{t("pages.logs.diagnostics.sections.mongoDBCollectionStats.description")}
</Typography>
<MongoCollectionStats diagnostics={diagnostics} />
</Stack>
</Stack>
);
};
+3 -53
View File
@@ -2,9 +2,8 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { TableJobs, TableFailedJobs } from "./components/TableJobs";
import { TableWorkers } from "./components/TableWorkers";
import { useTheme } from "@mui/material";
import { useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import { useGet, usePost } from "@/Hooks/UseApi";
import type { QueueData } from "@/Types/Queue";
@@ -16,27 +15,16 @@ import { SPACING } from "@/Utils/Theme/constants";
export const TabQueue = () => {
const theme = useTheme();
const { t } = useTranslation();
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const {
data: queueData,
isLoading,
// error,
refetch,
} = useGet<QueueData>(
`/queue/all-metrics?page=${page}&rowsPerPage=${rowsPerPage}`,
{},
{ refreshInterval: 1000, keepPreviousData: true }
);
} = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 1000 });
const { post, loading: isFlushing } = usePost();
const jobs = queueData?.jobs ?? [];
const jobsCount = queueData?.count ?? 0;
const workers = (queueData?.metrics?.workers ?? []).map((worker, idx) => ({
...worker,
id: idx,
}));
const metrics = queueData?.metrics ?? null;
const hasNeverRun = !isLoading && metrics !== null && (metrics.totalRuns ?? 0) === 0;
const noQueueData = !isLoading && metrics === null && jobs.length === 0;
@@ -58,34 +46,6 @@ export const TabQueue = () => {
return (
<Stack gap={theme.spacing(16)}>
<Metrics metrics={metrics} />
<Stack gap={theme.spacing(3)}>
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
>
{t("pages.logs.workers")}
</Typography>
<Typography color={theme.palette.text.secondary}>
<Trans
i18nKey="pages.logs.workersExplainers"
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}
/>
),
}}
/>
</Typography>
</Stack>
<TableWorkers queueWorkers={workers} />
</Stack>
<Stack gap={theme.spacing(3)}>
<Stack gap={theme.spacing(1)}>
<Typography
@@ -112,17 +72,7 @@ export const TabQueue = () => {
/>
</Typography>
</Stack>
<TableJobs
jobs={jobs}
count={jobsCount}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={(_, newPage) => setPage(newPage)}
onRowsPerPageChange={(event) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
}}
/>
<TableJobs jobs={jobs} />
</Stack>
<Stack gap={theme.spacing(3)}>
<Stack gap={theme.spacing(1)}>
@@ -1,104 +0,0 @@
import Box from "@mui/material/Box";
import { StatBox, Gauge, Tooltip } from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";
import prettyBytes from "pretty-bytes";
import { useTheme } from "@mui/material";
import type { Diagnostics } from "@/Types/Diagnostics";
import { LAYOUT } from "@/Utils/Theme/constants";
interface MongoStatsProps {
diagnostics: Diagnostics | null;
}
export const MongoCollectionStats = ({ diagnostics }: MongoStatsProps) => {
const theme = useTheme();
const { t } = useTranslation();
const mongoStats = diagnostics?.mongoStats;
if (!mongoStats) {
return null;
}
return (
<Box
display={"grid"}
gap={theme.spacing(LAYOUT.MD)}
sx={{
gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
"& > *": { width: "100% !important" },
}}
>
{mongoStats.collections.map((colStat) => (
<StatBox
key={colStat.name}
title={colStat.name}
subtitle={""}
>
<Stack
direction={"row"}
alignItems={"center"}
justifyContent={"space-between"}
>
<Stack>
{/* <Typography variant="body2">{`Documents: ${colStat.documentCount}`}</Typography> */}
<Typography variant="body2">
{t("pages.logs.diagnostics.mongoDBCollectionStats.documents", {
count: colStat.documentCount,
})}
</Typography>
{colStat.bucketCount !== undefined && (
<Typography variant="body2">
{t("pages.logs.diagnostics.mongoDBCollectionStats.bucketCount", {
count: colStat.bucketCount,
})}
</Typography>
)}
<Typography variant="body2">
{t("pages.logs.diagnostics.mongoDBCollectionStats.storageSize", {
size: prettyBytes(colStat.storageSize),
})}
</Typography>
<Typography variant="body2">
{t("pages.logs.diagnostics.mongoDBCollectionStats.indexSize", {
size: prettyBytes(colStat.totalIndexSize),
})}
</Typography>
<Typography variant="body2">
{t("pages.logs.diagnostics.mongoDBCollectionStats.totalSize", {
size: prettyBytes(colStat.totalSize),
})}
</Typography>
</Stack>
<Stack alignItems={"center"}>
<Tooltip
title={t("pages.logs.diagnostics.mongoDBCollectionStats.dbUsageTooltip", {
percentage: ((colStat.totalSize / mongoStats.totalSize) * 100).toFixed(
2
),
})}
placement="top"
slotProps={{
popper: {
modifiers: [
{
name: "offset",
options: {
offset: [0, -10],
},
},
],
},
}}
>
<Gauge progress={(colStat.totalSize / mongoStats.totalSize) * 100} />
</Tooltip>
</Stack>
</Stack>
</StatBox>
))}
</Box>
);
};
@@ -1,74 +0,0 @@
import Box from "@mui/material/Box";
import { StatBox } from "@/Components/design-elements";
import { useTranslation } from "react-i18next";
import prettyBytes from "pretty-bytes";
import { useTheme } from "@mui/material";
import type { Diagnostics } from "@/Types/Diagnostics";
import { LAYOUT } from "@/Utils/Theme/constants";
interface MongoStatsProps {
diagnostics: Diagnostics | null;
}
export const MongoStats = ({ diagnostics }: MongoStatsProps) => {
const theme = useTheme();
const { t } = useTranslation();
const mongoStats = diagnostics?.mongoStats;
if (!mongoStats) {
return null;
}
return (
<Box
display={"grid"}
gap={theme.spacing(LAYOUT.MD)}
sx={{
gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
"& > *": { width: "100% !important" },
}}
>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.readyState")}
subtitle={mongoStats.readyState.toString()}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.readsPerSecond")}
subtitle={mongoStats.readsPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.insertsPerSecond")}
subtitle={mongoStats.insertsPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.updatesPerSecond")}
subtitle={mongoStats.updatesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.deletesPerSecond")}
subtitle={mongoStats.deletesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.writesPerSecond")}
subtitle={mongoStats.writesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.host")}
subtitle={mongoStats.host}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.port")}
subtitle={mongoStats.port.toString()}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.dbName")}
subtitle={mongoStats.dbName}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.storageSize")}
subtitle={prettyBytes(mongoStats.totalSize)}
/>
</Box>
);
};
+65 -87
View File
@@ -1,4 +1,4 @@
import { Table, Pagination } from "@/Components/design-elements";
import { Table } from "@/Components/design-elements";
import { Typography, useTheme } from "@mui/material";
import prettyMilliseconds from "pretty-ms";
import { formatTimestamp } from "@/Utils/TimeUtils";
@@ -6,27 +6,14 @@ import { formatTimestamp } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue";
import type { Header } from "@/Components/design-elements";
import type { TablePaginationProps } from "@mui/material/TablePagination";
type QueueJobWithId = QueueJobSummary & { id: string | number };
interface TableJobsProps {
jobs: QueueJobSummary[];
count: number;
page: number;
rowsPerPage: number;
onPageChange: TablePaginationProps["onPageChange"];
onRowsPerPageChange: TablePaginationProps["onRowsPerPageChange"];
}
export const TableJobs = ({
jobs,
count,
page,
rowsPerPage,
onPageChange,
onRowsPerPageChange,
}: TableJobsProps) => {
export const TableJobs = ({ jobs }: TableJobsProps) => {
const { t } = useTranslation();
const theme = useTheme();
@@ -42,15 +29,12 @@ export const TableJobs = ({
maxWidth: 220,
};
const isRunning = (row: QueueJobWithId) =>
row.lockedBy !== null && row.lockedUntil !== null && row.lockedUntil > Date.now();
const headers: Header<QueueJobWithId>[] = [
{
id: "state",
content: t("pages.logs.table.headers.state"),
render: (row) => {
if (isRunning(row)) {
if (row.lockedAt) {
return t("common.labels.running");
}
if (row.monitorActive === true) {
@@ -75,6 +59,18 @@ export const TableJobs = ({
</Typography>
),
},
{
id: "url",
content: t("common.table.headers.url"),
render: (row) => (
<Typography
title={row.monitorUrl ?? ""}
sx={cellSx}
>
{row.monitorUrl}
</Typography>
),
},
{
id: "type",
content: t("common.table.headers.type"),
@@ -83,57 +79,50 @@ export const TableJobs = ({
{
id: "interval",
content: t("common.table.headers.interval"),
render: (row) => (
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
),
},
{
id: "lastFinishedAt",
content: t("pages.logs.table.headers.lastFinishedAt"),
render: (row) => {
const lastFinishedAt = formatTimestamp(row.lastFinishedAt) ?? "-";
return (
<Typography
title={lastFinishedAt}
sx={cellSx}
>
{lastFinishedAt}
</Typography>
);
},
},
{
id: "nextScheduledAt",
content: t("pages.logs.table.headers.nextScheduledAt"),
render: (row) => {
const nextScheduledAt = formatTimestamp(row.nextScheduledAt) ?? "-";
return (
<Typography
title={nextScheduledAt}
sx={cellSx}
>
{nextScheduledAt}
</Typography>
);
},
},
{
id: "lockedBy",
content: t("pages.logs.table.headers.lockedBy"),
render: (row) => {
const lockedBy = row.lockedBy;
if (!lockedBy) {
return <Typography sx={cellSx}>-</Typography>;
let interval;
if (row.monitorId.toString().includes("geo")) {
interval = row.monitorGeoInterval ?? 0;
} else {
interval = row.repeat ?? 0;
}
const workerId = lockedBy.split(":")[2];
const shortWorkerId = workerId.slice(workerId.length - 8);
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
},
},
{
id: "lastRun",
content: t("pages.logs.table.headers.lastRunAt"),
render: (row) => {
const v = formatTimestamp(row.lastRunAt) ?? "-";
return (
<Typography
title={workerId}
title={v}
sx={cellSx}
>
{`...${shortWorkerId}`}
{v}
</Typography>
);
},
},
{
id: "lastRunTook",
content: t("pages.logs.table.headers.lastRunTook"),
render: (row) => {
const value = row.lastRunTook ? prettyMilliseconds(row.lastRunTook) : "-";
return <Typography sx={cellSx}>{value}</Typography>;
},
},
{
id: "lockedAt",
content: t("pages.logs.table.headers.lockedAt"),
render: (row) => {
const v = formatTimestamp(row.lockedAt) ?? "-";
return (
<Typography
title={v}
sx={cellSx}
>
{v}
</Typography>
);
},
@@ -141,29 +130,18 @@ export const TableJobs = ({
];
return (
<>
<Table
headers={headers}
data={jobsWithId}
getRowSx={(row) => ({
...(isRunning(row) && {
"& td": { backgroundColor: theme.palette.rowStatus.running },
}),
...(row.monitorActive === false && {
"& td": { backgroundColor: theme.palette.rowStatus.paused },
}),
})}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={onPageChange}
onRowsPerPageChange={onRowsPerPageChange}
itemsOnPage={jobs.length}
/>
</>
<Table
headers={headers}
data={jobsWithId}
getRowSx={(row) => ({
...(row.lockedAt && {
"& td": { backgroundColor: theme.palette.rowStatus.running },
}),
...(row.monitorActive === false && {
"& td": { backgroundColor: theme.palette.rowStatus.paused },
}),
})}
/>
);
};
@@ -1,94 +0,0 @@
import { Table } from "@/Components/design-elements";
import { Typography, useTheme } from "@mui/material";
import { formatTimestamp } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
import type { QueueWorker } from "@/Types/Queue";
import type { Header } from "@/Components/design-elements";
type QueueWorkerWithId = QueueWorker & { id: number };
interface TableWorkersProps {
queueWorkers: QueueWorkerWithId[];
}
export const TableWorkers = ({ queueWorkers }: TableWorkersProps) => {
const { t } = useTranslation();
const theme = useTheme();
const cellSx = {
whiteSpace: "nowrap" as const,
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 220,
};
const headers: Header<QueueWorkerWithId>[] = [
{
id: "host",
content: t("pages.logs.table.headers.host"),
render: (row) => {
const host = row.workerId.split(":")[0];
return (
<Typography
fontFamily={theme.typography.fontFamilyMonospace}
sx={cellSx}
>
{host}
</Typography>
);
},
},
{
id: "processId",
content: t("pages.logs.table.headers.processId"),
render: (row) => {
const processId = row.workerId.split(":")[1];
return (
<Typography
fontFamily={theme.typography.fontFamilyMonospace}
sx={cellSx}
>
{processId}
</Typography>
);
},
},
{
id: "id",
content: t("pages.logs.table.headers.workerId"),
render: (row) => {
const workerId = row.workerId.split(":")[2];
const shortWorkerId = workerId.slice(workerId.length - 8);
return (
<Typography
fontFamily={theme.typography.fontFamilyMonospace}
title={workerId}
sx={cellSx}
>
{`...${shortWorkerId}`}
</Typography>
);
},
},
{
id: "mode",
content: t("pages.logs.table.headers.workerMode"),
render: (row) => <Typography sx={cellSx}>{row.mode}</Typography>,
},
{
id: "lastSeen",
content: t("pages.logs.table.headers.lastSeenAt"),
render: (row) => (
<Typography sx={cellSx}>{formatTimestamp(row.lastSeenAt)}</Typography>
),
},
];
return (
<Table
headers={headers}
data={queueWorkers}
/>
);
};
@@ -150,8 +150,7 @@ const NotificationsCreatePage = () => {
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
watchedType !== "twilio" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
@@ -175,48 +174,6 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "ntfy" && (
<ConfigBox
title={t("pages.notifications.form.ntfy.title")}
subtitle={t("pages.notifications.form.ntfy.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="topic"
control={control}
defaultValue={"topic" in defaults ? defaults.topic : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
@@ -6,9 +6,8 @@ import {
Pagination,
StatusLabel,
StrategyBadge,
ColoredLabel,
} from "@/Components/design-elements";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { LAYOUT } from "@/Utils/Theme/constants";
import { HistogramPageSpeed } from "@/Components/monitors";
import { ArrowDown, ArrowUp } from "lucide-react";
import type { Header } from "@/Components/design-elements/Table";
@@ -21,12 +20,10 @@ import { useNavigate } from "react-router-dom";
import { usePatch } from "@/Hooks/UseApi";
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { ActionMenuItem } from "@/Components/actions-menu";
export const PageSpeedMonitorsTable = ({
monitors,
tags,
refetch,
setSelectedMonitor,
sortField,
@@ -40,7 +37,6 @@ export const PageSpeedMonitorsTable = ({
setRowsPerPage,
}: {
monitors: MonitorWithChecks[];
tags: Tag[];
refetch: Function;
setSelectedMonitor: Function;
sortField: string;
@@ -237,32 +233,6 @@ export const PageSpeedMonitorsTable = ({
);
},
},
{
id: "tags",
content: t("common.table.headers.tags"),
render: (row) => {
if (row.tags.length === 0) return "-";
return (
<Stack
justifyContent={"center"}
direction={isSmall ? "column" : "row"}
gap={theme.spacing(SPACING.LG)}
>
{row.tags.map((tagId) => {
const fullTag = tags.find((tag) => tag.id === tagId);
if (!fullTag) return null;
return (
<ColoredLabel
key={fullTag.id}
text={fullTag.name}
color={fullTag.color}
/>
);
})}
</Stack>
);
},
},
{
id: "actions",
+6 -116
View File
@@ -1,32 +1,22 @@
import {
MonitorBasePageWithStates,
PageSpeedKeyPriorityFallback,
ColoredLabel,
} from "@/Components/design-elements";
import { Dialog, TextField } from "@/Components/inputs";
import { Dialog } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet, useDelete } from "@/Hooks/UseApi";
import useDebounce from "@/Hooks/useDebounce";
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { AppSettingsResponse } from "@/Types/Settings";
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
import { HeaderMonitorsSummary } from "@/Components/monitors";
const PageSpeedMonitorsPage = () => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const { deleteFn, loading: isDeleting } = useDelete();
@@ -43,50 +33,17 @@ const PageSpeedMonitorsPage = () => {
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [search, setSearch] = useState<string>("");
const debouncedSearch = useDebounce<string>(search, 300);
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch;
const { data: tags } = useGet<Tag[]>("/tags/team");
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "pagespeed");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortField) params.append("field", sortField);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, sortOrder, selectedTags, filter, field]);
}, [page, rowsPerPage, sortField, sortOrder]);
const {
data: monitorsData,
@@ -118,28 +75,12 @@ const PageSpeedMonitorsPage = () => {
setSelectedMonitor(null);
};
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return (
<MonitorBasePageWithStates
headerKey="pageSpeed"
loading={isLoading}
error={monitorsError || settingsError}
totalCount={effectiveTotalCount}
totalCount={summary?.totalMonitors ?? 0}
page="pageSpeed"
actionLink="/pagespeed/create"
priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined}
@@ -150,59 +91,8 @@ const PageSpeedMonitorsPage = () => {
isAdmin={isAdmin}
/>
<HeaderMonitorsSummary summary={summary} />
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
<PageSpeedMonitorsTable
monitors={monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
+299 -412
View File
@@ -1,4 +1,4 @@
import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements";
import { BasePage, ConfigBox } from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import { logger } from "@/Utils/logger";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
@@ -26,7 +26,6 @@ import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
import type { StatusPageFormData } from "@/Validation/statusPage";
import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statusPage";
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
@@ -87,27 +86,13 @@ const CreateStatusPage = () => {
defaultValues: defaults,
});
const { control, reset, handleSubmit, trigger } = form;
const { control, reset, handleSubmit } = form;
// Reset form when defaults change (from fetched data)
useEffect(() => {
reset(defaults);
}, [defaults, reset]);
// Multi-step wizard, create only. The configure (edit) flow keeps the flat
// form, where showStep() is always true.
const [currentStep, setCurrentStep] = useState(0);
const showStep = (step: number) => !isCreate || currentStep === step;
const totalSteps = statusPageStepCount();
const handleNext = async () => {
const isValid = await trigger(statusPageStepFieldsFor(currentStep));
if (isValid) {
setCurrentStep((step) => step + 1);
}
};
const handleBack = () => setCurrentStep((step) => step - 1);
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
const computedTypes: MonitorDisplayType[] = useMemo(() => {
const selectedMonitors = (watchedMonitorIds ?? [])
@@ -151,18 +136,12 @@ const CreateStatusPage = () => {
fd.append("isPublished", String(data.isPublished));
if (data.companyName) fd.append("companyName", data.companyName);
if (data.url) fd.append("url", data.url);
if (data.customDomain !== undefined && data.customDomain !== null) {
fd.append("customDomain", data.customDomain);
} else {
fd.append("customDomain", "");
}
if (data.timezone) fd.append("timezone", data.timezone);
if (data.color) fd.append("color", data.color);
fd.append("showCharts", String(data.showCharts));
fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
fd.append("customCSS", data.customCSS ?? "");
if (data.theme) fd.append("theme", data.theme);
if (data.themeMode) fd.append("themeMode", data.themeMode);
@@ -221,382 +200,315 @@ const CreateStatusPage = () => {
onSubmit={handleSubmit(onSubmit, onError)}
>
{!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />}
{isCreate && (
<StepProgress
steps={totalSteps}
current={currentStep}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.access.title")}
subtitle={t("pages.statusPages.form.access.description")}
rightContent={
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.MD)}
>
<Controller
name="isPublished"
control={control}
render={({ field }) => (
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
<Typography>
{t("pages.statusPages.form.access.option.published.name")}
</Typography>
</Stack>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.basicInfo.title")}
subtitle={t("pages.statusPages.form.basicInfo.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="companyName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.name.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.url.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<Controller
name="customDomain"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t(
"pages.statusPages.form.basicInfo.option.customDomain.label"
)}
placeholder={t(
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
)}
helperText={
fieldState.error?.message ||
t("pages.statusPages.form.basicInfo.option.customDomain.helper")
}
error={!!fieldState.error}
/>
)}
/>
</Stack>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.monitors.title")}
subtitle={t("pages.statusPages.form.monitors.description")}
rightContent={
<ConfigBox
title={t("pages.statusPages.form.access.title")}
subtitle={t("pages.statusPages.form.access.description")}
rightContent={
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.MD)}
>
<Controller
name="monitors"
control={control}
render={({ field, fieldState }) => {
const selectedMonitors = field.value
.map((id: string) => monitors.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const reordered = Array.from(field.value);
const [removed] = reordered.splice(result.source.index, 1);
reordered.splice(result.destination.index, 0, removed);
field.onChange(reordered);
};
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={monitors}
getOptionLabel={(option: Monitor) => option.name}
value={selectedMonitors}
onChange={(_, newValue) => {
field.onChange(newValue.map((m: Monitor) => m.id));
}}
fieldLabel={t(
"pages.statusPages.form.monitors.option.monitors.label"
)}
renderInput={(params) => (
<TextField
{...params}
placeholder={
selectedMonitors.length === 0
? t(
"pages.statusPages.form.monitors.option.monitors.placeholder"
)
: ""
}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
{selectedMonitors.length > 0 && (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="monitors-list">
{(provided) => (
<Stack
{...provided.droppableProps}
ref={provided.innerRef}
>
{selectedMonitors.map((monitor, index) => (
<Draggable
key={monitor.id}
draggableId={monitor.id}
index={index}
>
{(provided) => (
<Stack
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
cursor: "grab",
"&:active": { cursor: "grabbing" },
}}
>
<GripVertical size={20} />
<Typography
flexGrow={1}
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value.filter(
(id: string) => id !== monitor.id
)
);
}}
aria-label="Remove monitor"
>
<Trash2 size={16} />
</IconButton>
</Stack>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
)}
</Stack>
);
}}
/>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.timezone.title")}
subtitle={t("pages.statusPages.form.timezone.description")}
rightContent={
<Controller
name="timezone"
name="isPublished"
control={control}
render={({ field }) => (
<Autocomplete
options={timezones}
getOptionLabel={(option: TimezoneOption) => option.name}
value={
timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null
}
onChange={(_, newValue: TimezoneOption | null) => {
field.onChange(newValue?._id ?? "");
}}
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
renderInput={(params) => (
<TextField
{...params}
placeholder={t(
"pages.statusPages.form.timezone.option.timezone.placeholder"
)}
/>
)}
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.appearance.title")}
subtitle={t("pages.statusPages.form.appearance.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack alignItems={"center"}>
<Controller
name="logo"
control={control}
render={({ field }) => (
<ImageUpload
src={field.value?.data}
onChange={(file) => {
if (file) {
field.onChange({
data: file.src,
contentType: file.file.type,
});
} else {
field.onChange(null);
}
}}
/>
)}
/>
</Stack>
<Controller
name="color"
control={control}
render={({ field }) => (
<ColorInput
format="hex"
value={field.value}
onChange={field.onChange}
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
/>
)}
/>
</Stack>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.theme.title")}
subtitle={t("pages.statusPages.form.theme.description")}
rightContent={
<Typography>
{t("pages.statusPages.form.access.option.published.name")}
</Typography>
</Stack>
}
/>
<ConfigBox
title={t("pages.statusPages.form.basicInfo.title")}
subtitle={t("pages.statusPages.form.basicInfo.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="theme"
control={control}
render={({ field: themeField }) => (
<Controller
name="themeMode"
control={control}
render={({ field: modeField }) => (
<ThemePicker
theme={themeField.value ?? "refined"}
themeMode={modeField.value ?? "auto"}
onThemeChange={themeField.onChange}
onThemeModeChange={modeField.onChange}
/>
)}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.customCSS.title")}
subtitle={t("pages.statusPages.form.customCSS.description")}
rightContent={
<Controller
name="customCSS"
name="companyName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
multiline
rows={8}
fieldLabel={t(
"pages.statusPages.form.customCSS.option.customCSS.label"
)}
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
placeholder={t(
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
"pages.statusPages.form.basicInfo.option.name.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="showCharts"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.url.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.statusPages.form.monitors.title")}
subtitle={t("pages.statusPages.form.monitors.description")}
rightContent={
<Controller
name="monitors"
control={control}
render={({ field, fieldState }) => {
const selectedMonitors = field.value
.map((id: string) => monitors.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const reordered = Array.from(field.value);
const [removed] = reordered.splice(result.source.index, 1);
reordered.splice(result.destination.index, 0, removed);
field.onChange(reordered);
};
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={monitors}
getOptionLabel={(option: Monitor) => option.name}
value={selectedMonitors}
onChange={(_, newValue) => {
field.onChange(newValue.map((m: Monitor) => m.id));
}}
fieldLabel={t(
"pages.statusPages.form.monitors.option.monitors.label"
)}
renderInput={(params) => (
<TextField
{...params}
placeholder={
selectedMonitors.length === 0
? t(
"pages.statusPages.form.monitors.option.monitors.placeholder"
)
: ""
}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
}
label={t("pages.statusPages.form.features.option.showCharts.label")}
)}
/>
)}
/>
<Controller
name="showInfrastructure"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t(
"pages.statusPages.form.features.option.showInfrastructure.label"
{selectedMonitors.length > 0 && (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="monitors-list">
{(provided) => (
<Stack
{...provided.droppableProps}
ref={provided.innerRef}
>
{selectedMonitors.map((monitor, index) => (
<Draggable
key={monitor.id}
draggableId={monitor.id}
index={index}
>
{(provided) => (
<Stack
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
cursor: "grab",
"&:active": { cursor: "grabbing" },
}}
>
<GripVertical size={20} />
<Typography
flexGrow={1}
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value.filter(
(id: string) => id !== monitor.id
)
);
}}
aria-label="Remove monitor"
>
<Trash2 size={16} />
</IconButton>
</Stack>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
)}
</Stack>
);
}}
/>
}
/>
<ConfigBox
title={t("pages.statusPages.form.timezone.title")}
subtitle={t("pages.statusPages.form.timezone.description")}
rightContent={
<Controller
name="timezone"
control={control}
render={({ field }) => (
<Autocomplete
options={timezones}
getOptionLabel={(option: TimezoneOption) => option.name}
value={
timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null
}
onChange={(_, newValue: TimezoneOption | null) => {
field.onChange(newValue?._id ?? "");
}}
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
renderInput={(params) => (
<TextField
{...params}
placeholder={t(
"pages.statusPages.form.timezone.option.timezone.placeholder"
)}
/>
)}
/>
{/* <Controller
)}
/>
}
/>
<ConfigBox
title={t("pages.statusPages.form.appearance.title")}
subtitle={t("pages.statusPages.form.appearance.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack alignItems={"center"}>
<Controller
name="logo"
control={control}
render={({ field }) => (
<ImageUpload
src={field.value?.data}
onChange={(file) => {
if (file) {
field.onChange({
data: file.src,
contentType: file.file.type,
});
} else {
field.onChange(null);
}
}}
/>
)}
/>
</Stack>
<Controller
name="color"
control={control}
render={({ field }) => (
<ColorInput
format="hex"
value={field.value}
onChange={field.onChange}
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.statusPages.form.theme.title")}
subtitle={t("pages.statusPages.form.theme.description")}
rightContent={
<Controller
name="theme"
control={control}
render={({ field: themeField }) => (
<Controller
name="themeMode"
control={control}
render={({ field: modeField }) => (
<ThemePicker
theme={themeField.value ?? "refined"}
themeMode={modeField.value ?? "auto"}
onThemeChange={themeField.onChange}
onThemeModeChange={modeField.onChange}
/>
)}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="showCharts"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t("pages.statusPages.form.features.option.showCharts.label")}
/>
)}
/>
<Controller
name="showInfrastructure"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t(
"pages.statusPages.form.features.option.showInfrastructure.label"
)}
/>
)}
/>
{/* <Controller
name="showUptimePercentage"
control={control}
render={({ field }) => (
@@ -630,46 +542,21 @@ const CreateStatusPage = () => {
/>
)}
/> */}
</Stack>
}
/>
)}
</Stack>
}
/>
<Stack
direction="row"
justifyContent={isCreate ? "space-between" : "flex-end"}
justifyContent="flex-end"
>
{isCreate && (
<Button
type="button"
variant="outlined"
color="secondary"
disabled={currentStep === 0}
onClick={handleBack}
>
{t("common.buttons.back")}
</Button>
)}
{isCreate && currentStep < totalSteps - 1 ? (
<Button
key="wizard-next"
type="button"
variant="contained"
color="primary"
onClick={handleNext}
>
{t("common.buttons.next")}
</Button>
) : (
<Button
key="wizard-save"
loading={isSubmitting}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
)}
<Button
loading={isSubmitting}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
<Dialog
open={isDeleteDialogOpen}
@@ -8,8 +8,7 @@ import { Settings, ExternalLink } from "lucide-react";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { type StatusPage } from "@/Types/StatusPage";
import { getStatusPagePublicUrl } from "@/Utils/statusPageUrl";
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
interface HeaderStatusPageControlsProps {
isAdmin: boolean;
@@ -51,7 +50,7 @@ export const HeaderStatusPageControls = ({
<Typography
onClick={() => {
window.open(
getStatusPagePublicUrl(statusPage),
`${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`,
"_blank",
"noopener,noreferrer"
);
+14 -38
View File
@@ -2,7 +2,6 @@ import { BasePage, BaseFallback } from "@/Components/design-elements";
import Typography from "@mui/material/Typography";
import { Link } from "react-router-dom";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
@@ -15,12 +14,6 @@ import {
type StatusPageResponse,
type StatusPageTheme,
} from "@/Types/StatusPage";
import {
buildStatusPageApiPath,
getStatusPagePreviewUrl,
getStatusPagePublicUrl,
isCustomDomainHost,
} from "@/Utils/statusPageUrl";
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import {
@@ -40,7 +33,6 @@ 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 { minimalStyles } from "@/Pages/StatusPage/Status/themes/minimal/styles";
const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
refined: {
@@ -66,11 +58,6 @@ const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
HeroSlot: EditorialHero,
overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" },
},
minimal: {
createStyles: minimalStyles,
HeaderSlot: RefinedHeader,
HeroSlot: RefinedHero,
},
};
const StatusPageView = () => {
@@ -80,14 +67,9 @@ const StatusPageView = () => {
const isAdmin = useIsAdmin();
const location = useLocation();
const onCustomDomainHost = isCustomDomainHost();
const isPublic =
onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const apiUrl = buildStatusPageApiPath({
url,
useCustomDomain: onCustomDomainHost,
});
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
const { data, isLoading, error } = useGet<StatusPageResponse>(
apiUrl,
@@ -129,9 +111,17 @@ const StatusPageView = () => {
);
}
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const themedRenderer = (
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
);
// Public route: render directly on the viewport, themed background covers everything.
if (isPublic) {
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
return (
<StatusPageThemeProvider
theme={statusPage.theme}
@@ -139,17 +129,12 @@ const StatusPageView = () => {
timezone={statusPage.timezone}
paintBody
>
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
{themedRenderer}
</StatusPageThemeProvider>
);
}
const publicUrl = getStatusPagePublicUrl(statusPage);
const previewUrl = getStatusPagePreviewUrl(statusPage);
const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
return (
<BasePage
loading={isLoading}
@@ -168,16 +153,7 @@ const StatusPageView = () => {
timezone={statusPage.timezone}
transparent
>
<BrowserFrame url={publicUrl}>
<Box
component="iframe"
src={previewUrl}
title={t("pages.statusPages.preview.title")}
flex={1}
width="100%"
border={0}
/>
</BrowserFrame>
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
</StatusPageThemeProvider>
</BasePage>
);
@@ -64,13 +64,7 @@ export const BrowserFrame = ({ url, children }: Props) => {
{url}
</Box>
</Box>
<Box
display="flex"
flexDirection="column"
sx={{ flex: 1, minHeight: 0 }}
>
{children}
</Box>
<Box sx={{ flex: 1, minHeight: 0 }}>{children}</Box>
</Box>
);
};
@@ -99,9 +99,9 @@ export const boldStyles = (
},
top: {
alignItems: { xs: "flex-start", md: "center" },
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
mb: "36px",
},
brand: {
@@ -130,7 +130,7 @@ export const boldStyles = (
hero: {
position: "relative",
borderRadius: "22px",
padding: { xs: "24px 20px", md: "36px 36px 32px" },
padding: "36px 36px 32px",
mb: "28px",
background: isDark ? heroBgDark : heroBgLight,
border: `1px solid ${tokens.border}`,
@@ -149,14 +149,13 @@ export const boldStyles = (
},
heroTitle: {
m: 0,
fontSize: { xs: 24, md: 34 },
fontSize: 34,
fontWeight: 800,
letterSpacing: "-0.02em",
lineHeight: 1.1,
color: tokens.text,
display: "flex",
alignItems: { xs: "flex-start", md: "center" },
flexWrap: "wrap",
alignItems: "center",
gap: "10px",
},
heroCheck: (tone) => ({
@@ -172,15 +171,9 @@ export const boldStyles = (
}),
heroSub: { mt: "10px", mb: 0, color: tokens.textMuted, fontSize: 14 },
chartSwitchWrap: {
display: "flex",
justifyContent: { xs: "center", md: "flex-end" },
mb: "16px",
},
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "16px" },
chartSwitch: {
display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.border}`,
borderRadius: "999px",
background: tokens.surface,
@@ -188,20 +181,16 @@ export const boldStyles = (
gap: "2px",
},
chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
border: 0,
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: { xs: "8px 6px", md: "8px 18px" },
padding: "8px 18px",
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: { xs: "0.04em", md: "0.12em" },
letterSpacing: "0.12em",
transition: "background 0.2s, color 0.2s",
borderRadius: "999px",
"&:hover": { color: active ? tokens.up : tokens.text },
@@ -228,21 +217,13 @@ export const boldStyles = (
},
cardRow: {
display: "grid",
alignItems: { xs: "start", md: "center" },
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
gridTemplateAreas: {
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "24px" },
rowGap: { xs: "10px", md: "6px" },
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: "24px",
p: "22px 28px",
},
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
cardLeft: { minWidth: 0 },
monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontWeight: 700,
fontSize: 17,
letterSpacing: "-0.01em",
@@ -252,9 +233,10 @@ export const boldStyles = (
whiteSpace: "nowrap",
},
monitorMeta: {
gridArea: "meta",
display: "flex",
gap: "10px",
alignItems: { xs: "flex-start", md: "center" },
alignItems: "center",
mt: "6px",
flexWrap: "wrap",
},
pill: {
@@ -291,8 +273,6 @@ export const boldStyles = (
},
badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontSize: 11,
fontWeight: 800,
padding: "6px 14px",
@@ -316,10 +296,10 @@ export const boldStyles = (
}),
heatmap: {
padding: { xs: "0 12px 18px", md: "0 28px 22px" },
padding: "0 28px 22px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "3px" },
gap: "3px",
height: 48,
},
heatmapCell: (kind) => ({
@@ -332,10 +312,10 @@ export const boldStyles = (
}),
histogram: {
padding: { xs: "0 12px", md: "0 28px" },
padding: "0 28px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "3px" },
gap: "3px",
alignItems: "flex-end",
height: 48,
},
@@ -90,9 +90,9 @@ export const editorialStyles = (
},
top: {
alignItems: { xs: "flex-start", md: "flex-end" },
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
gap: "12px",
mb: "48px",
borderBottom: `2px solid ${tokens.text}`,
pb: "18px",
@@ -119,7 +119,7 @@ export const editorialStyles = (
},
statusLine: {
fontSize: { xs: 18, md: 22 },
fontSize: 22,
fontWeight: 400,
m: 0,
mb: "8px",
@@ -144,32 +144,26 @@ export const editorialStyles = (
letterSpacing: "0.12em",
color: tokens.textMuted,
m: 0,
mb: { xs: "32px", md: "56px" },
mb: "56px",
},
chartSwitchWrap: {
display: "flex",
justifyContent: { xs: "center", md: "flex-end" },
justifyContent: "flex-end",
mb: "20px",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
},
chartSwitch: {
display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.text}`,
"& > button + button": { borderLeft: `1px solid ${tokens.text}` },
},
chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
fontFamily: "inherit",
fontSize: 10,
textTransform: "uppercase",
letterSpacing: { xs: "0.05em", md: "0.2em" },
padding: { xs: "6px 6px", md: "6px 14px" },
letterSpacing: "0.2em",
padding: "6px 14px",
border: 0,
background: active ? tokens.text : "transparent",
color: active ? tokens.bg : tokens.text,
@@ -194,20 +188,12 @@ export const editorialStyles = (
},
cardRow: {
display: "grid",
alignItems: { xs: "start", md: "baseline" },
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
gridTemplateAreas: {
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "20px" },
rowGap: { xs: "10px", md: "6px" },
gridTemplateColumns: "1fr auto",
alignItems: "baseline",
gap: "20px",
},
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
cardLeft: { minWidth: 0 },
monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontSize: 20,
fontWeight: 700,
letterSpacing: "-0.015em",
@@ -217,13 +203,14 @@ export const editorialStyles = (
whiteSpace: "nowrap",
},
monitorMeta: {
gridArea: "meta",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 12,
color: tokens.textMuted,
mt: "6px",
display: "flex",
gap: "12px",
flexWrap: "wrap",
alignItems: { xs: "flex-start", md: "center" },
alignItems: "center",
},
pill: {
textTransform: "uppercase",
@@ -247,8 +234,6 @@ export const editorialStyles = (
},
badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 10,
fontWeight: 700,
@@ -265,7 +250,7 @@ export const editorialStyles = (
mt: "16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "2px" },
gap: "2px",
height: 40,
},
heatmapCell: (kind) => ({
@@ -277,7 +262,7 @@ export const editorialStyles = (
mt: "16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "2px" },
gap: "2px",
alignItems: "flex-end",
height: 40,
},
@@ -1,66 +0,0 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import type { OverallTone } from "../shared/overallStatus";
import { refinedStyles, type RefinedStyles } from "../refined/styles";
type FlatSx = Exclude<SxProps<Theme>, readonly unknown[] | ((theme: Theme) => unknown)>;
const extend = (base: SxProps<Theme>, overrides: SxProps<Theme>): SxProps<Theme> => [
base as FlatSx,
overrides as FlatSx,
];
export const minimalStyles = (
tokens: StatusPageThemeTokens,
isDark: boolean
): RefinedStyles => {
const base = refinedStyles(tokens, isDark);
return {
...base,
page: extend(base.page, {
maxWidth: 820,
p: { xs: "32px 16px 64px", md: "48px 20px 72px" },
}),
top: extend(base.top, {
mb: "44px",
pb: "16px",
borderBottom: `1px solid ${tokens.border}`,
}),
logoMono: extend(base.logoMono, {
borderRadius: "50%",
background: tokens.text,
}),
hero: extend(base.hero, {
alignItems: "center",
background: "transparent",
border: 0,
boxShadow: "none",
p: 0,
mb: "40px",
}),
heroRow: extend(base.heroRow, { justifyContent: "center" }),
heroTitle: extend(base.heroTitle, {
flex: "initial",
fontSize: { xs: 22, md: 28 },
fontWeight: 650,
textAlign: "center",
}),
heroIcon: (_tone: OverallTone) => ({ display: "none" }),
heroSub: extend(base.heroSub, { textAlign: "center", mt: "4px" }),
chartSwitch: extend(base.chartSwitch, { borderRadius: tokens.radius }),
card: extend(base.card, {
boxShadow: "none",
transition: "none",
"&:hover": { transform: "none", boxShadow: "none" },
}),
cardRow: extend(base.cardRow, { p: "14px 16px" }),
badge: (tone) =>
extend(base.badge(tone), {
background: "transparent",
p: 0,
}),
gauge: extend(base.gauge, { borderRadius: tokens.radius }),
footer: extend(base.footer, { mt: "48px", fontSize: 11 }),
};
};
@@ -11,24 +11,24 @@ export const ModernHero = ({
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box sx={styles.heroRow}>
<Box sx={styles.pulse(overall.tone)} />
<Box sx={styles.pulse(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
<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>
);
};
@@ -17,7 +17,7 @@ export interface ModernStyles {
logoImg: SxProps<Theme>;
hero: SxProps<Theme>;
pulse: (tone: OverallTone) => SxProps<Theme>;
heroRow: SxProps<Theme>;
statusCopy: SxProps<Theme>;
heroTitle: SxProps<Theme>;
heroSub: SxProps<Theme>;
heroIcon: (tone: OverallTone) => SxProps<Theme>;
@@ -117,9 +117,9 @@ export const modernStyles = (
},
top: {
alignItems: { xs: "flex-start", md: "center" },
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
mb: "32px",
},
brand: {
@@ -148,11 +148,11 @@ export const modernStyles = (
hero: {
position: "relative",
borderRadius: "20px",
padding: { xs: "20px", md: "28px 32px" },
padding: "28px 32px",
mb: "24px",
display: "flex",
flexDirection: "column",
gap: "4px",
alignItems: "center",
gap: "20px",
background: tokens.surface,
border: `1px solid ${tokens.border}`,
boxShadow: cardShadow,
@@ -182,37 +182,25 @@ export const modernStyles = (
},
};
},
heroRow: {
display: "flex",
alignItems: "center",
gap: { xs: "14px", md: "20px" },
},
statusCopy: { flex: 1, minWidth: 0 },
heroTitle: {
m: 0,
flex: 1,
minWidth: 0,
fontSize: { xs: 18, md: 22 },
mb: "4px",
fontSize: 22,
fontWeight: 700,
letterSpacing: "-0.02em",
color: tokens.text,
},
heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
heroIcon: (tone) => ({
display: { xs: "none", md: "flex" },
color: toneColor(tone, tokens),
display: "flex",
alignItems: "center",
flexShrink: 0,
}),
chartSwitchWrap: {
display: "flex",
justifyContent: { xs: "center", md: "flex-end" },
mb: "14px",
},
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "14px" },
chartSwitch: {
display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.border}`,
borderRadius: "999px",
background: tokens.surface,
@@ -220,20 +208,16 @@ export const modernStyles = (
gap: "2px",
},
chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
border: 0,
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: { xs: "6px 6px", md: "6px 16px" },
padding: "6px 16px",
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: { xs: "0.04em", md: "0.08em" },
letterSpacing: "0.08em",
transition: "background 0.2s, color 0.2s",
borderRadius: "999px",
"&:hover": { color: active ? tokens.up : tokens.text },
@@ -272,21 +256,13 @@ export const modernStyles = (
},
cardRow: {
display: "grid",
alignItems: { xs: "start", md: "center" },
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
gridTemplateAreas: {
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "20px" },
rowGap: { xs: "10px", md: "6px" },
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: "20px",
p: "18px 24px",
},
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
cardLeft: { minWidth: 0 },
monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontWeight: 600,
fontSize: 15,
letterSpacing: "-0.005em",
@@ -296,9 +272,10 @@ export const modernStyles = (
whiteSpace: "nowrap",
},
monitorMeta: {
gridArea: "meta",
display: "flex",
gap: "10px",
alignItems: { xs: "flex-start", md: "center" },
alignItems: "center",
mt: "6px",
flexWrap: "wrap",
},
pill: {
@@ -322,8 +299,6 @@ export const modernStyles = (
},
badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontSize: 11,
fontWeight: 700,
padding: "5px 12px",
@@ -344,10 +319,10 @@ export const modernStyles = (
}),
heatmap: {
padding: { xs: "0 12px 16px", md: "0 24px 20px" },
padding: "0 24px 20px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "3px" },
gap: "3px",
height: 46,
},
heatmapCell: (kind) => ({
@@ -359,10 +334,10 @@ export const modernStyles = (
}),
histogram: {
padding: { xs: "0 12px", md: "0 24px" },
padding: "0 24px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "3px" },
gap: "3px",
alignItems: "flex-end",
height: 46,
},
@@ -11,24 +11,24 @@ export const RefinedHero = ({
const { t } = useTranslation();
return (
<Box sx={styles.hero}>
<Box sx={styles.heroRow}>
<Box sx={styles.statusDot(overall.tone)} />
<Box sx={styles.statusDot(overall.tone)} />
<Box sx={styles.statusCopy}>
<Box
component="h1"
sx={styles.heroTitle}
>
{overall.message}
</Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box>
<Box
component="p"
sx={styles.heroSub}
>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitorCount,
})}
<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>
);
};
@@ -17,7 +17,7 @@ export interface RefinedStyles {
company: SxProps<Theme>;
hero: SxProps<Theme>;
statusDot: (tone: OverallTone) => SxProps<Theme>;
heroRow: SxProps<Theme>;
statusCopy: SxProps<Theme>;
heroTitle: SxProps<Theme>;
heroSub: SxProps<Theme>;
heroIcon: (tone: OverallTone) => SxProps<Theme>;
@@ -102,9 +102,9 @@ export const refinedStyles = (
},
top: {
alignItems: { xs: "flex-start", md: "center" },
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
mb: "28px",
},
brand: {
@@ -133,10 +133,10 @@ export const refinedStyles = (
background: tokens.surface,
border: `1px solid ${tokens.border}`,
borderRadius: tokens.radius,
padding: { xs: "18px 20px", md: "22px 24px" },
padding: "22px 24px",
display: "flex",
flexDirection: "column",
gap: "4px",
alignItems: "center",
gap: "16px",
boxShadow: cardShadow,
mb: "20px",
},
@@ -148,15 +148,10 @@ export const refinedStyles = (
boxShadow: `0 0 0 4px ${toneSoft(tone, tokens)}`,
flexShrink: 0,
}),
heroRow: {
display: "flex",
alignItems: "center",
gap: { xs: "12px", md: "16px" },
},
statusCopy: { flex: 1, minWidth: 0 },
heroTitle: {
m: 0,
flex: 1,
minWidth: 0,
mb: "2px",
fontSize: 17,
fontWeight: 600,
letterSpacing: "-0.01em",
@@ -164,21 +159,14 @@ export const refinedStyles = (
},
heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
heroIcon: (tone) => ({
display: { xs: "none", md: "flex" },
color: toneColor(tone, tokens),
display: "flex",
alignItems: "center",
flexShrink: 0,
}),
chartSwitchWrap: {
display: "flex",
justifyContent: { xs: "center", md: "flex-end" },
mb: "12px",
},
chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "12px" },
chartSwitch: {
display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.border}`,
borderRadius: "8px",
background: tokens.surface,
@@ -186,15 +174,11 @@ export const refinedStyles = (
gap: "2px",
},
chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
border: 0,
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: { xs: "5px 6px", md: "5px 14px" },
padding: "5px 14px",
cursor: "pointer",
color: active ? tokens.up : tokens.textMuted,
borderRadius: "5px",
@@ -223,21 +207,13 @@ export const refinedStyles = (
},
cardRow: {
display: "grid",
alignItems: { xs: "start", md: "center" },
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" },
gridTemplateAreas: {
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "16px" },
rowGap: { xs: "10px", md: "4px" },
gridTemplateColumns: "1fr auto",
alignItems: "center",
gap: "16px",
p: "16px 20px",
},
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } },
cardLeft: { minWidth: 0 },
monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontWeight: 600,
fontSize: 14,
letterSpacing: "-0.005em",
@@ -247,9 +223,10 @@ export const refinedStyles = (
whiteSpace: "nowrap",
},
monitorMeta: {
gridArea: "meta",
display: "flex",
gap: "10px",
alignItems: { xs: "flex-start", md: "center" },
alignItems: "center",
mt: "4px",
flexWrap: "wrap",
},
pill: {
@@ -274,8 +251,6 @@ export const refinedStyles = (
},
badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontSize: 11,
fontWeight: 600,
padding: "4px 10px",
@@ -286,10 +261,10 @@ export const refinedStyles = (
}),
heatmap: {
padding: { xs: "0 10px 14px", md: "0 20px 16px" },
padding: "0 20px 16px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "3px" },
gap: "3px",
height: 42,
},
heatmapCell: (kind) => ({
@@ -301,10 +276,10 @@ export const refinedStyles = (
}),
histogram: {
padding: { xs: "0 10px", md: "0 20px" },
padding: "0 20px",
display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "3px" },
gap: "3px",
alignItems: "flex-end",
height: 42,
},
@@ -27,7 +27,6 @@ import {
statusBadgeKey,
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import { formatPercentage } from "@/Utils/FormatUtils";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
@@ -102,10 +101,8 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
return (
<Box sx={styles.page}>
{statusPage.customCSS && <style>{statusPage.customCSS}</style>}
<Stack
<Box
component="header"
direction={{ xs: "column", md: "row" }}
sx={styles.top}
>
<HeaderSlot
@@ -115,7 +112,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
monitorCount={monitors.length}
styles={styles}
/>
</Stack>
</Box>
<HeroSlot
statusPage={statusPage}
@@ -164,7 +161,6 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status);
const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0);
return (
<Box
@@ -173,39 +169,32 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
sx={styles.card}
>
<Box sx={styles.cardRow}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<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>
<Stack
direction={{ xs: "column", md: "row" }}
sx={styles.monitorMeta}
>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
<Box
component="span"
sx={{ ...styles.pill }}
>
{uptimePercentage}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Stack>
</Box>
{showInfra && (
@@ -191,47 +191,9 @@ const editorial: ThemeVariants = {
},
};
const minimal: ThemeVariants = {
light: {
bg: "#ffffff",
surface: "#ffffff",
border: "#dfe3e8",
text: "#17191c",
textMuted: "#667085",
up: "#12a174",
upStrong: "#087a57",
upSoft: "#e7f7f1",
degraded: "#b54708",
degradedSoft: "#fff1dc",
down: "#c4323f",
downSoft: "#fde8ea",
warn: "#c27803",
warnSoft: "#fff3d6",
radius: "6px",
},
dark: {
bg: "#0d0f12",
surface: "#111419",
border: "#2b3038",
text: "#f2f4f7",
textMuted: "#98a2b3",
up: "#32d6a0",
upStrong: "#1eb783",
upSoft: "rgba(50,214,160,0.14)",
degraded: "#f0a44b",
degradedSoft: "rgba(240,164,75,0.16)",
down: "#ff6675",
downSoft: "rgba(255,102,117,0.16)",
warn: "#f2b84b",
warnSoft: "rgba(242,184,75,0.16)",
radius: "6px",
},
};
export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
refined,
modern,
bold,
editorial,
minimal,
};
@@ -11,9 +11,7 @@ import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import { getStatusPagePublicPath, getStatusPagePublicUrl } from "@/Utils/statusPageUrl";
import { type StatusPage } from "@/Types/StatusPage";
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
interface StatusPagesTableProps {
data: StatusPage[];
@@ -55,7 +53,8 @@ export const StatusPagesTable = ({
const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => {
if (row.isPublished) {
e.stopPropagation();
window.open(getStatusPagePublicUrl(row), "_blank", "noopener,noreferrer");
const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`;
window.open(url, "_blank", "noopener,noreferrer");
}
};
@@ -71,9 +70,7 @@ export const StatusPagesTable = ({
content: t("pages.statusPages.table.headers.url"),
render: (row) => {
const content = row.isPublished
? row.customDomain
? getStatusPagePublicPath(row)
: `/${row.url}`
? `/${row.url}`
: t("pages.statusPages.table.unpublished");
return (
<Stack
@@ -1,109 +0,0 @@
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements/Table";
import { Table, ColoredLabel } from "@/Components/design-elements";
import { Pagination } from "@/Components/design-elements/Table";
import { useClientPagination } from "@/Hooks/useClientPagination";
import type { Tag } from "@/Types/Tag";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
interface TagsTableProps {
tags: Tag[];
setSelectedTag: Function;
}
export const TagsTable = ({ tags, setSelectedTag }: TagsTableProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const theme = useTheme();
const { pagedRows, paginationProps } = useClientPagination(tags);
const getActions = (tag: Tag): ActionMenuItem[] => {
return [
{
id: 1,
label: t("pages.common.monitors.actions.configure"),
action: () => {
navigate(`/tags/configure/${tag.id}`);
},
closeMenu: true,
},
{
id: 2,
label: (
<Typography color={theme.palette.error.main}>
{t("pages.common.monitors.actions.delete")}
</Typography>
),
action: async () => {
setSelectedTag(tag);
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const headers: Header<Tag>[] = [
{
id: "name",
content: t("common.table.headers.name"),
render: (row) => {
return <Typography>{row?.name}</Typography>;
},
},
{
id: "color",
content: t("pages.tags.table.headers.color"),
render: (row) => {
return <Typography textTransform={"capitalize"}>{row?.color}</Typography>;
},
},
{
id: "chip",
content: t("pages.tags.table.headers.appearance"),
render: (row) => {
return (
<Box
display="flex"
justifyContent="center"
>
<ColoredLabel
text={row.name}
color={row.color}
/>
</Box>
);
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
return (
<>
<Table
headers={getHeaders()}
data={pagedRows}
onRowClick={(row) => {
navigate(`/tags/configure/${row.id}`);
}}
/>
{tags.length > 0 && <Pagination {...paginationProps} />}
</>
);
};
-118
View File
@@ -1,118 +0,0 @@
import { BasePage, ConfigBox } from "@/Components/design-elements";
import { TextField, Button, ColorInput } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { mutate } from "swr";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import type { TagFormData } from "@/Validation/tag";
import type { Tag } from "@/Types/Tag";
import { useTranslation } from "react-i18next";
import { useTagsForm } from "@/Hooks/useTagsForm";
const TagsCreatePage = () => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const { tagId } = useParams<{ tagId: string }>();
const isEditMode = Boolean(tagId);
const { data: existingTag } = useGet<Tag>(isEditMode ? `/tags/${tagId}` : null);
const { post, loading: isSubmitting } = usePost<TagFormData, Tag>();
const { patch, loading: isPatching } = usePatch<TagFormData, Tag>();
const { schema, defaults } = useTagsForm({ data: existingTag });
const form = useForm<TagFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { control, reset, handleSubmit } = form;
useEffect(() => {
reset(defaults);
}, [defaults, reset]);
const onSubmit = async (data: TagFormData) => {
const result = isEditMode
? await patch(`/tags/${tagId}`, data)
: await post("/tags", data);
if (result?.success) {
await mutate((key) => typeof key === "string" && key.startsWith("/tags"));
navigate("/tags");
}
};
return (
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.tags.form.name.title")}
subtitle={t("pages.tags.form.name.description")}
rightContent={
<Controller
name="name"
control={control}
defaultValue={defaults.name}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.tags.form.name.optionName")}
placeholder={t("pages.tags.form.name.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.tags.form.color.title")}
subtitle={t("pages.tags.form.color.description")}
rightContent={
<Controller
name="color"
control={control}
defaultValue={defaults.color}
render={({ field }) => (
<ColorInput
format="hex"
value={field.value}
onChange={field.onChange}
fieldLabel={t("pages.tags.form.color.optionName")}
/>
)}
/>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
>
<Button
loading={isSubmitting || isPatching}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
);
};
export default TagsCreatePage;
-72
View File
@@ -1,72 +0,0 @@
import { BasePageWithStates } from "@/Components/design-elements";
import { TagsTable } from "@/Pages/Tags/components/TagsTable";
import { Dialog } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { useState } from "react";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
import type { Tag } from "@/Types/Tag";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
const TagsPage = () => {
const { t } = useTranslation();
const isAdmin = useIsAdmin();
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const isDialogOpen = Boolean(selectedTag);
const {
data: tags,
isLoading,
isValidating,
error,
refetch,
} = useGet<Tag[]>("/tags/team", {}, { keepPreviousData: true });
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedTag) return;
await deleteFn(`/tags/${selectedTag.id}`);
setSelectedTag(null);
refetch();
};
const handleCancel = () => {
setSelectedTag(null);
};
return (
<BasePageWithStates
headerKey="tags"
page={t("pages.tags.fallback.title")}
description={t("pages.tags.fallback.description")}
loading={isLoading || isValidating}
error={!!error}
totalCount={tags?.length ?? 0}
actionButtonText={t("pages.tags.fallback.actionButton")}
actionLink="/tags/create"
>
<HeaderCreate
path="/tags/create"
isLoading={isLoading || isValidating}
isAdmin={isAdmin}
/>
<TagsTable
tags={tags ?? []}
setSelectedTag={setSelectedTag}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</BasePageWithStates>
);
};
export default TagsPage;
@@ -6,7 +6,6 @@ import {
type Header,
Pagination,
StatusLabel,
ColoredLabel,
} from "@/Components/design-elements";
import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common";
import { ActionsMenu } from "@/Components/actions-menu";
@@ -14,23 +13,19 @@ 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";
import useMediaQuery from "@mui/material/useMediaQuery";
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 type { Tag } from "@/Types/Tag";
import { SPACING } from "@/Utils/Theme/constants";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
interface MonitorTableProps {
monitors: Monitor[];
tags: Tag[];
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
@@ -48,7 +43,6 @@ interface MonitorTableProps {
export const MonitorTable = ({
monitors,
tags,
refetch,
setSelectedMonitor,
sortField,
@@ -65,10 +59,10 @@ export const MonitorTable = ({
}: MonitorTableProps) => {
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 isSmall = useMediaQuery(theme.breakpoints.down("md"));
const selectedSet = new Set(selectedRows);
const isAllSelected =
@@ -214,7 +208,7 @@ export const MonitorTable = ({
mobileLabel: t("common.table.headers.name"),
content: (
<Stack
gap={theme.spacing(4)}
gap={theme.spacing(LAYOUT.XS)}
direction={"row"}
alignItems={"center"}
onClick={(e) => handleSort(e, "name")}
@@ -234,14 +228,14 @@ export const MonitorTable = ({
mobileLabel: t("common.table.headers.status"),
content: (
<Stack
gap={theme.spacing(4)}
gap={theme.spacing(LAYOUT.XS)}
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
<Box width={theme.spacing(LAYOUT.MD)} />
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
</Stack>
@@ -261,72 +255,25 @@ export const MonitorTable = ({
}
},
},
{
id: "uptime",
content: t("pages.uptime.table.headers.uptime"),
render: (row) => {
const uptimePercentage = row.uptimePercentage ?? 0;
const formattedPercentage = formatPercentage(uptimePercentage);
const paletteKey = getUptimePercentageColor(uptimePercentage);
const color = theme.palette[paletteKey].main;
return (
<Stack alignItems={"center"}>
<ColoredLabel
text={formattedPercentage}
color={color}
/>
</Stack>
);
},
},
{
id: "tags",
content: t("common.table.headers.tags"),
render: (row) => {
if (row.tags.length === 0) return "-";
return (
<Stack
justifyContent={"center"}
direction={isSmall ? "column" : "row"}
gap={theme.spacing(SPACING.LG)}
>
{row.tags.map((tag) => {
const fullTag = tags.find((t) => t.id === tag);
if (!fullTag) return null;
return (
<ColoredLabel
key={fullTag.id}
text={fullTag.name}
color={fullTag.color}
/>
);
})}
</Stack>
);
},
},
{
id: "type",
mobileLabel: t("common.table.headers.type"),
content: (
<Stack
gap={theme.spacing(4)}
gap={theme.spacing(LAYOUT.XS)}
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
onClick={(e) => handleSort(e, "type")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
<Box width={theme.spacing(LAYOUT.MD)} />
{t("common.table.headers.type")}
{renderSortIcon(sortField === "type")}
</Stack>
),
render: (row) => {
return row.type === "http" && row.method
? `${row.type} (${row.method})`
: row.type;
return row.type;
},
},
{
@@ -344,6 +291,30 @@ 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}
+35 -47
View File
@@ -3,19 +3,18 @@ import {
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { MonitorBasePageWithStates } from "@/Components/design-elements";
import { TextField, Dialog, Button } 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 Typography from "@mui/material/Typography";
import { BulkEditNotificationsModal } from "@/Components/monitors";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useGet, useDelete } from "@/Hooks/UseApi";
import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useState, useMemo, useCallback } from "react";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
@@ -24,7 +23,7 @@ import type { RootState } from "@/Types/state";
import { useTheme } from "@mui/material";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import { SPACING } from "@/Utils/Theme/constants";
import { LAYOUT } from "@/Utils/Theme/constants";
const UptimeMonitorsPage = () => {
const { t } = useTranslation();
@@ -41,13 +40,12 @@ const UptimeMonitorsPage = () => {
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
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);
@@ -87,7 +85,6 @@ const UptimeMonitorsPage = () => {
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
@@ -95,7 +92,7 @@ const UptimeMonitorsPage = () => {
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [effectiveTypes, selectedTags, page, rowsPerPage, filter, field, sortOrder]);
}, [effectiveTypes, page, rowsPerPage, filter, field, sortOrder]);
const {
data: monitorsWithChecksData,
@@ -108,8 +105,6 @@ const UptimeMonitorsPage = () => {
{ refreshInterval: 5000, keepPreviousData: true }
);
const { data: tags } = useGet<Tag[]>("/tags/team");
const {
monitors: monitorsWithChecks,
summary,
@@ -133,7 +128,6 @@ const UptimeMonitorsPage = () => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
@@ -144,6 +138,14 @@ const UptimeMonitorsPage = () => {
refetch();
};
const handleBulkEditComplete = (success: boolean) => {
setIsBulkEditModalOpen(false);
if (success) {
setSelectedRows([]);
refetch();
}
};
const handleCancel = () => {
setSelectedMonitor(null);
};
@@ -152,11 +154,7 @@ const UptimeMonitorsPage = () => {
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
selectedStatus ||
selectedState ||
selectedTags.length > 0 ||
search
selectedTypes.length > 0 || selectedStatus || selectedState || search
);
// Show empty state only when there are truly no monitors (not just filtered out)
@@ -186,7 +184,7 @@ const UptimeMonitorsPage = () => {
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
gap={theme.spacing(LAYOUT.XS)}
>
<ControlsFilter
selectedTypes={selectedTypes}
@@ -195,9 +193,6 @@ const UptimeMonitorsPage = () => {
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
@@ -209,30 +204,6 @@ const UptimeMonitorsPage = () => {
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
@@ -252,17 +223,27 @@ const UptimeMonitorsPage = () => {
>
{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 || []}
tags={tags || []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
count={count || 0}
page={page}
setPage={setPage}
setPage={(newPage) => {
setPage(newPage);
setSelectedRows([]);
}}
rowsPerPage={rowsPerPage}
sortField={sortField}
setSortField={setSortField}
@@ -276,6 +257,7 @@ const UptimeMonitorsPage = () => {
})
);
setPage(0);
setSelectedRows([]);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
@@ -288,6 +270,12 @@ const UptimeMonitorsPage = () => {
onCancel={handleCancel}
loading={isDeleting}
/>
<BulkEditNotificationsModal
open={isBulkEditModalOpen}
onClose={() => setIsBulkEditModalOpen(false)}
selectedMonitors={selectedRows}
onComplete={handleBulkEditComplete}
/>
</MonitorBasePageWithStates>
);
};
-34
View File
@@ -31,14 +31,9 @@ import CreateStatus from "@/Pages/StatusPage/Create/";
import StatusPages from "@/Pages/StatusPage/StatusPages";
import Status from "@/Pages/StatusPage/Status";
// Notifications
import Notifications from "@/Pages/Notifications";
import CreateNotifications from "@/Pages/Notifications/create";
// Tags
import Tags from "@/Pages/Tags";
import CreateTags from "@/Pages/Tags/create";
// Settings
import Account from "@/Pages/Account";
import EditUser from "@/Pages/Account/EditUser";
@@ -54,24 +49,8 @@ import Logs from "@/Pages/Logs";
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
import CreateMonitor from "@/Pages/CreateMonitor";
import { isCustomDomainHost } from "@/Utils/statusPageUrl";
const Routes = () => {
if (isCustomDomainHost()) {
return (
<LibRoutes>
<Route
path="/"
element={<Status />}
/>
<Route
path="*"
element={<Status />}
/>
</LibRoutes>
);
}
return (
<LibRoutes>
<Route
@@ -180,19 +159,6 @@ const Routes = () => {
path="notifications/configure/:notificationId"
element={<CreateNotifications />}
/>
<Route
path="tags"
element={<Tags />}
/>
<Route
path="tags/create"
element={<CreateTags />}
/>
<Route
path="tags/configure/:tagId"
element={<CreateTags />}
/>
<Route
path="maintenance"
+12 -24
View File
@@ -1,17 +1,28 @@
export interface Diagnostics {
osStats: OsStats;
memoryUsage: MemoryUsage;
cpuUsage: CpuUsage;
v8HeapStats: V8HeapStats;
eventLoopDelayMs: number;
uptimeMs: number;
mongoStats: MongoStats;
}
export interface OsStats {
freeMemoryBytes: number;
totalMemoryBytes: number;
}
export interface MemoryUsage {
rss: number;
heapTotal: number;
heapUsed: number;
external: number;
arrayBuffers: number;
}
export interface CpuUsage {
userUsageMs: number;
systemUsageMs: number;
usagePercentage: number;
}
@@ -20,26 +31,3 @@ export interface V8HeapStats {
usedHeapSizeBytes: number;
heapSizeLimitBytes: number;
}
export interface MongoStats {
readyState: number;
readsPerSecond: number;
insertsPerSecond: number;
updatesPerSecond: number;
deletesPerSecond: number;
writesPerSecond: number;
host: string;
port: number;
dbName: string;
totalSize: number;
collections: CollectionStats[];
}
export interface CollectionStats {
name: string;
documentCount: number;
storageSize: number;
totalIndexSize: number;
totalSize: number;
bucketCount?: number;
}
-7
View File
@@ -48,10 +48,6 @@ export const PageSpeedStrategies = ["desktop", "mobile"] as const;
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
export const HttpMethods = ["GET", "HEAD"] as const;
export type HttpMethod = (typeof HttpMethods)[number];
export const DefaultHttpMethod: HttpMethod = "GET";
export interface Monitor {
id: string;
userId: string;
@@ -68,15 +64,12 @@ export interface Monitor {
jsonPath?: string;
expectedValue?: string;
matchMethod?: MonitorMatchMethod;
method?: HttpMethod;
url: string;
port?: number;
isActive: boolean;
interval: number;
uptimePercentage?: number;
notifications: string[];
tags: string[];
customUpCodes?: number[];
secret?: string;
cpuAlertThreshold: number;
cpuAlertCounter: number;
-2
View File
@@ -9,7 +9,6 @@ export const NotificationChannels = [
"telegram",
"pushover",
"twilio",
"ntfy",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
@@ -26,7 +25,6 @@ export interface Notification {
accessToken?: string;
accountSid?: string;
twilioPhoneNumber?: string;
topic?: string;
createdAt: string;
updatedAt: string;
}
+13 -19
View File
@@ -1,14 +1,5 @@
import type { MonitorType } from "./Monitor";
export const QueueModes = ["primary", "worker"] as const;
export type QueueMode = (typeof QueueModes)[number];
export type QueueWorker = {
workerId: string; // hostname:pid:uuid
mode: QueueMode;
lastSeenAt: number; // epoch ms of the last heartbeat
};
export interface QueueJobFailure {
monitorId: string | number;
monitorUrl: string | null;
@@ -18,32 +9,35 @@ export interface QueueJobFailure {
failReason: string | null;
}
export type QueueMetrics = {
export interface QueueMetrics {
jobs: number;
activeJobs: number;
failingJobs: number;
jobsWithFailures: QueueJobFailure[];
totalRuns: number;
totalFailures: number;
workers: QueueWorker[];
};
}
export type QueueJobSummary = {
export interface QueueJobSummary {
monitorId: string | number;
monitorType: string | null;
monitorUrl: string | null;
monitorType: MonitorType | null;
monitorInterval: number | null;
monitorGeoInterval: number | null;
monitorActive: boolean | null;
lockedBy: string | null;
lockedUntil: number | null;
nextScheduledAt: number;
active: boolean;
lockedAt: number | null;
runCount: number;
failCount: number;
failReason: string | null;
lastRunAt: number | null;
lastFinishedAt: number | null;
};
lastRunTook: number | null;
lastFailedAt: number | null;
repeat: number | null;
}
export interface QueueData {
jobs: QueueJobSummary[];
count: number;
metrics: QueueMetrics;
}
+1 -8
View File
@@ -21,13 +21,7 @@ export const getMonitorTypeLabel = (
return key ? t(key) : type;
};
export const STATUS_PAGE_THEMES = [
"refined",
"modern",
"bold",
"editorial",
"minimal",
] as const;
export const STATUS_PAGE_THEMES = ["refined", "modern", "bold", "editorial"] as const;
export type StatusPageTheme = (typeof STATUS_PAGE_THEMES)[number];
export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined";
@@ -53,7 +47,6 @@ export interface StatusPage {
type: MonitorDisplayType[];
companyName: string;
url: string;
customDomain?: string | null;
timezone?: string;
color: string;
monitors: string[];
-8
View File
@@ -1,8 +0,0 @@
export interface Tag {
id: string;
teamId: string;
name: string;
color: string;
createdAt: string;
updatedAt: string;
}
-10
View File
@@ -63,16 +63,6 @@ export const getResponseTimeColor = (responseTime: number): PaletteKey => {
}
};
export const getUptimePercentageColor = (uptimePercentage: number): PaletteKey => {
if (uptimePercentage >= 0.75) {
return "success";
} else if (uptimePercentage >= 0.5) {
return "warning";
} else {
return "error";
}
};
export const getInfraGaugeColor = (val: number, theme: any) => {
if (val < 50) {
return theme.palette.success.main;
-16
View File
@@ -1,16 +0,0 @@
import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer";
// Mirrors the server check (server/src/utils/customCss.ts) so the form rejects
// the same CSS the API would.
const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i;
export const cssReferencesExternalResource = (css?: string | null): boolean => {
if (typeof css !== "string" || css === "") {
return false;
}
return tokenize({ css }).some(
(token) =>
(isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value)
);
};
-93
View File
@@ -74,96 +74,3 @@ export const getStatusCodeValueType = (code: number): ValueType => {
if (code < 400) return "neutral";
return "negative";
};
export interface HttpStatusCodeOption {
id: number;
name: string;
}
/**
* Must stay in sync with HttpStatusCodes in server/src/domain/monitors/monitor.types.ts
*/
export const ALL_HTTP_STATUS_CODES: HttpStatusCodeOption[] = [
{ id: 100, name: "100 - Continue" },
{ id: 101, name: "101 - Switching Protocols" },
{ id: 102, name: "102 - Processing" },
{ id: 103, name: "103 - Early Hints" },
{ id: 200, name: "200 - OK" },
{ id: 201, name: "201 - Created" },
{ id: 202, name: "202 - Accepted" },
{ id: 203, name: "203 - Non-Authoritative Information" },
{ id: 204, name: "204 - No Content" },
{ id: 205, name: "205 - Reset Content" },
{ id: 206, name: "206 - Partial Content" },
{ id: 207, name: "207 - Multi-Status" },
{ id: 208, name: "208 - Already Reported" },
{ id: 226, name: "226 - IM Used" },
{ id: 300, name: "300 - Multiple Choices" },
{ id: 301, name: "301 - Moved Permanently" },
{ id: 302, name: "302 - Found" },
{ id: 303, name: "303 - See Other" },
{ id: 304, name: "304 - Not Modified" },
{ id: 305, name: "305 - Use Proxy" },
{ id: 307, name: "307 - Temporary Redirect" },
{ id: 308, name: "308 - Permanent Redirect" },
{ id: 400, name: "400 - Bad Request" },
{ id: 401, name: "401 - Unauthorized" },
{ id: 402, name: "402 - Payment Required" },
{ id: 403, name: "403 - Forbidden" },
{ id: 404, name: "404 - Not Found" },
{ id: 405, name: "405 - Method Not Allowed" },
{ id: 406, name: "406 - Not Acceptable" },
{ id: 407, name: "407 - Proxy Authentication Required" },
{ id: 408, name: "408 - Request Timeout" },
{ id: 409, name: "409 - Conflict" },
{ id: 410, name: "410 - Gone" },
{ id: 411, name: "411 - Length Required" },
{ id: 412, name: "412 - Precondition Failed" },
{ id: 413, name: "413 - Payload Too Large" },
{ id: 414, name: "414 - URI Too Long" },
{ id: 415, name: "415 - Unsupported Media Type" },
{ id: 416, name: "416 - Range Not Satisfiable" },
{ id: 417, name: "417 - Expectation Failed" },
{ id: 418, name: "418 - I'm a Teapot" },
{ id: 419, name: "419 - Page Expired (Laravel)" },
{ id: 420, name: "420 - Enhance Your Calm (Twitter)" },
{ id: 421, name: "421 - Misdirected Request" },
{ id: 422, name: "422 - Unprocessable Entity" },
{ id: 423, name: "423 - Locked" },
{ id: 424, name: "424 - Failed Dependency" },
{ id: 425, name: "425 - Too Early" },
{ id: 426, name: "426 - Upgrade Required" },
{ id: 428, name: "428 - Precondition Required" },
{ id: 429, name: "429 - Too Many Requests" },
{ id: 431, name: "431 - Request Header Fields Too Large" },
{ id: 440, name: "440 - Login Time-out (IIS)" },
{ id: 449, name: "449 - Retry With (IIS)" },
{ id: 451, name: "451 - Unavailable For Legal Reasons" },
{ id: 460, name: "460 - Client Closed Connection (AWS ELB)" },
{ id: 463, name: "463 - X-Forwarded-For Too Large (AWS ELB)" },
{ id: 497, name: "497 - HTTP Request Sent to HTTPS Port (NGINX)" },
{ id: 499, name: "499 - Client Closed Request (NGINX)" },
{ id: 500, name: "500 - Internal Server Error" },
{ id: 501, name: "501 - Not Implemented" },
{ id: 502, name: "502 - Bad Gateway" },
{ id: 503, name: "503 - Service Unavailable" },
{ id: 504, name: "504 - Gateway Timeout" },
{ id: 505, name: "505 - HTTP Version Not Supported" },
{ id: 506, name: "506 - Variant Also Negotiates" },
{ id: 507, name: "507 - Insufficient Storage" },
{ id: 508, name: "508 - Loop Detected" },
{ id: 509, name: "509 - Bandwidth Limit Exceeded (Apache)" },
{ id: 510, name: "510 - Not Extended" },
{ id: 511, name: "511 - Network Authentication Required" },
{ id: 520, name: "520 - Web Server Returned an Unknown Error (Cloudflare)" },
{ id: 521, name: "521 - Web Server Is Down (Cloudflare)" },
{ id: 522, name: "522 - Connection Timed Out (Cloudflare)" },
{ id: 523, name: "523 - Origin Is Unreachable (Cloudflare)" },
{ id: 524, name: "524 - A Timeout Occurred (Cloudflare)" },
{ id: 525, name: "525 - SSL Handshake Failed (Cloudflare)" },
{ id: 526, name: "526 - Invalid SSL Certificate (Cloudflare)" },
{ id: 527, name: "527 - Railgun Error (Cloudflare)" },
{ id: 529, name: "529 - Site is overloaded" },
{ id: 530, name: "530 - Site is frozen (Cloudflare)" },
{ id: 561, name: "561 - Unauthorized (AWS ELB)" },
];
-99
View File
@@ -1,99 +0,0 @@
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST as string | undefined;
const getClientOriginProtocol = (): string => {
const source =
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
if (!source) {
return "https:";
}
try {
return new URL(source).protocol;
} catch {
return "https:";
}
};
const buildCustomDomainOrigin = (customDomain: string): string =>
`${getClientOriginProtocol()}//${customDomain}`;
export const getAppHostname = (): string | null => {
const source =
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
if (!source) {
return null;
}
try {
return new URL(source).hostname.toLowerCase();
} catch {
return null;
}
};
export const isCustomDomainHost = (): boolean => {
if (typeof window === "undefined") {
return false;
}
const appHostname = getAppHostname();
if (!appHostname) {
return false;
}
return window.location.hostname.toLowerCase() !== appHostname;
};
export const getStatusPagePublicPath = (
statusPage: Pick<StatusPage, "url" | "customDomain">
): string => {
if (statusPage.customDomain) {
return buildCustomDomainOrigin(statusPage.customDomain);
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
export const getStatusPagePublicUrl = (
statusPage: Pick<StatusPage, "url" | "customDomain">
): string => {
if (statusPage.customDomain) {
return buildCustomDomainOrigin(statusPage.customDomain);
}
if (typeof window !== "undefined") {
return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
// Same-origin URL for the admin preview iframe; a custom domain would be cross
// origin and blocked by the admin document's CSP frame-src.
export const getStatusPagePreviewUrl = (statusPage: Pick<StatusPage, "url">): string => {
if (typeof window !== "undefined") {
return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
export const buildStatusPageApiPath = (options: {
url?: string;
useCustomDomain?: boolean;
}): string | null => {
const query = "type=uptime&type=infrastructure";
if (options.useCustomDomain && typeof window !== "undefined") {
const domain = encodeURIComponent(window.location.hostname);
return `/status-page/resolve?domain=${domain}&${query}`;
}
if (options.url) {
return `/status-page/${options.url}?${query}`;
}
return null;
};
+20 -95
View File
@@ -1,18 +1,6 @@
import { z } from "zod";
import type { FieldPath } from "react-hook-form";
import { GeoContinents } from "@/Types/GeoCheck";
import {
DnsRecordTypes,
HttpMethods,
PageSpeedStrategies,
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
// Wizard step a field is validated on. Attached inline to each field below so
// the grouping lives next to the field definition; unannotated fields default
// to step 0. Read via `stepFieldsFor`.
export const monitorStepRegistry = z.registry<{ step: number }>();
import { DnsRecordTypes, PageSpeedStrategies } from "@/Types/Monitor";
// URL schema with custom error message
const urlSchema = z.url({ message: "Please enter a valid URL" });
@@ -24,70 +12,39 @@ const baseSchema = z.object({
.min(1, "Monitor name is required")
.max(50, "Monitor name must be at most 50 characters"),
description: z.string().optional(),
interval: z
.number()
.min(15000, "Interval must be at least 15 seconds")
.register(monitorStepRegistry, { step: 1 }),
notifications: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
tags: z.array(z.string()).register(monitorStepRegistry, { step: 1 }),
interval: z.number().min(15000, "Interval must be at least 15 seconds"),
notifications: z.array(z.string()),
statusWindowSize: z
.number({ message: "Status window size is required" })
.min(1, "Status window size must be at least 1")
.max(25, "Status window size must be at most 25")
.register(monitorStepRegistry, { step: 1 }),
.max(25, "Status window size must be at most 25"),
statusWindowThreshold: z
.number({ message: "Threshold percentage is required" })
.min(1, "Incident percentage must be at least 1")
.max(100, "Incident percentage must be at most 100")
.register(monitorStepRegistry, { step: 1 }),
});
// Geo-distributed check fields. Only http and ping support geo checks
// (GeoCheckSupportedTypes), so these live on those schemas rather than the base
// — that way each type's step count is exactly what its own fields declare.
const geoCheckFields = {
geoCheckEnabled: z.boolean().optional().register(monitorStepRegistry, { step: 2 }),
geoCheckLocations: z
.array(z.enum(GeoContinents))
.optional()
.register(monitorStepRegistry, { step: 2 }),
.max(100, "Incident percentage must be at most 100"),
geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z
.number()
.min(300000, "Interval must be at least 5 minutes")
.optional()
.register(monitorStepRegistry, { step: 2 }),
};
// HTTP monitor schema
const httpStatusCodeSet = new Set(ALL_HTTP_STATUS_CODES.map((c) => c.id));
const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code), {
message: "Must be a valid HTTP status code",
.optional(),
});
// HTTP monitor schema
const httpSchema = baseSchema.extend({
type: z.literal("http"),
url: urlSchema,
method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }),
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }),
matchMethod: z
.enum(["equal", "include", "regex", ""])
.optional()
.register(monitorStepRegistry, { step: 2 }),
expectedValue: z.string().optional().register(monitorStepRegistry, { step: 2 }),
jsonPath: z.string().optional().register(monitorStepRegistry, { step: 2 }),
customUpCodes: z
.array(httpStatusCode)
.optional()
.register(monitorStepRegistry, { step: 2 }),
...geoCheckFields,
ignoreTlsErrors: z.boolean(),
useAdvancedMatching: z.boolean(),
matchMethod: z.enum(["equal", "include", "regex", ""]).optional(),
expectedValue: z.string().optional(),
jsonPath: z.string().optional(),
});
// Ping monitor schema
const pingSchema = baseSchema.extend({
type: z.literal("ping"),
url: z.string().min(1, "Host is required"),
...geoCheckFields,
});
// Port monitor schema
@@ -126,7 +83,7 @@ const grpcSchema = baseSchema.extend({
.min(1, "Port must be at least 1")
.max(65535, "Port must be at most 65535"),
grpcServiceName: z.string().optional(),
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
ignoreTlsErrors: z.boolean(),
});
// PageSpeed monitor schema
@@ -144,23 +101,19 @@ const hardwareSchema = baseSchema.extend({
cpuAlertThreshold: z
.number()
.min(0, "CPU threshold must be at least 0")
.max(100, "CPU threshold must be at most 100")
.register(monitorStepRegistry, { step: 1 }),
.max(100, "CPU threshold must be at most 100"),
memoryAlertThreshold: z
.number()
.min(0, "Memory threshold must be at least 0")
.max(100, "Memory threshold must be at most 100")
.register(monitorStepRegistry, { step: 1 }),
.max(100, "Memory threshold must be at most 100"),
diskAlertThreshold: z
.number()
.min(0, "Disk threshold must be at least 0")
.max(100, "Disk threshold must be at most 100")
.register(monitorStepRegistry, { step: 1 }),
.max(100, "Disk threshold must be at most 100"),
tempAlertThreshold: z
.number()
.min(0, "Temperature threshold must be at least 0")
.max(150, "Temperature threshold must be at most 150")
.register(monitorStepRegistry, { step: 1 }),
.max(150, "Temperature threshold must be at most 150"),
selectedDisks: z.array(z.string()),
});
@@ -168,7 +121,7 @@ const hardwareSchema = baseSchema.extend({
const websocketSchema = baseSchema.extend({
type: z.literal("websocket"),
url: z.string().min(1, "WebSocket URL is required"),
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
ignoreTlsErrors: z.boolean(),
});
// Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots,
@@ -210,34 +163,6 @@ export const monitorSchema = z.discriminatedUnion("type", [
export type MonitorFormData = z.infer<typeof monitorSchema>;
const optionForType = (type: MonitorType) =>
monitorSchema.options.find((o) => o.shape.type.value === type);
// The step each field of a type is validated on, from the per-field
// `monitorStepRegistry` metadata (unannotated fields default to step 0).
const fieldStepsFor = (
type: MonitorType
): [name: FieldPath<MonitorFormData>, step: number][] =>
Object.entries(optionForType(type)?.shape ?? {}).map(([name, field]) => [
name as FieldPath<MonitorFormData>,
monitorStepRegistry.get(field)?.step ?? 0,
]);
// The selected type's fields that belong to a given wizard step. Derived from
// the schema so it can't drift from the field definitions.
export const stepFieldsFor = (
type: MonitorType,
step: number
): FieldPath<MonitorFormData>[] =>
fieldStepsFor(type)
.filter(([, fieldStep]) => fieldStep === step)
.map(([name]) => name);
// Number of wizard steps a type has, derived from the highest step its fields
// declare. A type with no step-2 fields is a 2-step form, and so on.
export const monitorStepCount = (type: MonitorType): number =>
Math.max(0, ...fieldStepsFor(type).map(([, step]) => step)) + 1;
// Type-specific schemas exported for individual use
export {
httpSchema,
-7
View File
@@ -70,12 +70,6 @@ const twilioSchema = baseSchema.extend({
twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
});
const ntfySchema = baseSchema.extend({
type: z.literal("ntfy"),
address: z.string().min(1, "Server URL is required").url("Please enter a valid URL"),
topic: z.string().min(1, "Topic is required"),
});
export const notificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
@@ -87,7 +81,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
telegramSchema,
pushoverSchema,
twilioSchema,
ntfySchema,
]);
export type NotificationFormData = z.infer<typeof notificationSchema>;
+9 -76
View File
@@ -1,31 +1,5 @@
import { z } from "zod";
import type { FieldPath } from "react-hook-form";
import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage";
import { cssReferencesExternalResource } from "@/Utils/customCss";
// Wizard step a field is validated on. Attached inline to each field below so
// the grouping lives next to the field definition; unannotated fields default
// to step 0. Read via `statusPageStepFieldsFor`. Mirrors the monitor schema's
// `monitorStepRegistry` so the create-status-page wizard can't drift from the
// field definitions.
export const statusPageStepRegistry = z.registry<{ step: number }>();
const statusPageHostnameRegex =
/^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
const normalizeCustomDomainInput = (raw: string | null): string | null => {
if (raw === null) {
return null;
}
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
return null;
}
const withoutScheme = trimmed.replace(/^https?:\/\//, "");
return withoutScheme.split("/")[0]?.split(":")[0] ?? null;
};
export const statusPageSchema = z.object({
companyName: z
@@ -40,68 +14,27 @@ export const statusPageSchema = z.object({
/^[a-z0-9-]+$/,
"URL can only contain lowercase letters, numbers, and hyphens"
),
customDomain: z
.union([z.string(), z.null()])
.transform(normalizeCustomDomainInput)
.refine((domain) => domain === null || statusPageHostnameRegex.test(domain), {
message: "Enter a valid domain name (e.g. status.example.com)",
}),
timezone: z.string().optional(),
type: z
.array(z.enum(["uptime", "infrastructure"]))
.min(1, "At least one type is required"),
color: z.string().min(1, "Color is required").register(statusPageStepRegistry, {
step: 1,
}),
color: z.string().min(1, "Color is required"),
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
isPublished: z.boolean(),
showCharts: z.boolean().register(statusPageStepRegistry, { step: 1 }),
showUptimePercentage: z.boolean().register(statusPageStepRegistry, { step: 1 }),
showAdminLoginLink: z.boolean().register(statusPageStepRegistry, { step: 1 }),
showInfrastructure: z.boolean().register(statusPageStepRegistry, { step: 1 }),
customCSS: z
.string()
.max(100000, "Custom CSS must be at most 100000 characters")
.refine((css) => !cssReferencesExternalResource(css), {
message: "Custom CSS cannot reference external URLs or use @import",
})
.optional()
.register(statusPageStepRegistry, { step: 1 }),
theme: z
.enum(STATUS_PAGE_THEMES)
.optional()
.register(statusPageStepRegistry, { step: 1 }),
themeMode: z
.enum(STATUS_PAGE_THEME_MODES)
.optional()
.register(statusPageStepRegistry, { step: 1 }),
showCharts: z.boolean(),
showUptimePercentage: z.boolean(),
showAdminLoginLink: z.boolean(),
showInfrastructure: z.boolean(),
customCSS: z.string().optional(),
theme: z.enum(STATUS_PAGE_THEMES).optional(),
themeMode: z.enum(STATUS_PAGE_THEME_MODES).optional(),
logo: z
.object({
data: z.string(),
contentType: z.string(),
})
.nullable()
.optional()
.register(statusPageStepRegistry, { step: 1 }),
.optional(),
});
export type StatusPageFormData = z.infer<typeof statusPageSchema>;
// The step each field is validated on, from the per-field
// `statusPageStepRegistry` metadata (unannotated fields default to step 0).
const fieldSteps = (): [name: FieldPath<StatusPageFormData>, step: number][] =>
Object.entries(statusPageSchema.shape).map(([name, field]) => [
name as FieldPath<StatusPageFormData>,
statusPageStepRegistry.get(field)?.step ?? 0,
]);
// The fields that belong to a given wizard step. Derived from the schema so it
// can't drift from the field definitions.
export const statusPageStepFieldsFor = (step: number): FieldPath<StatusPageFormData>[] =>
fieldSteps()
.filter(([, fieldStep]) => fieldStep === step)
.map(([name]) => name);
// Number of wizard steps, derived from the highest step its fields declare.
export const statusPageStepCount = (): number =>
Math.max(0, ...fieldSteps().map(([, step]) => step)) + 1;
-10
View File
@@ -1,10 +0,0 @@
import { z } from "zod";
export const tagSchema = z.object({
name: z.string().min(1, "Please enter a tag name"),
color: z
.string()
.regex(/^#([0-9a-fA-F]{6})$/, "Color must be a 6-digit hex value (e.g. #13715B)"),
});
export type TagFormData = z.infer<typeof tagSchema>;
+47 -142
View File
@@ -73,9 +73,7 @@
"paused": "متوقف مؤقتاً",
"na": "غير متوفر",
"resolved": "تم الحل",
"responseTime": "وقت الاستجابة",
"idle": "خامل",
"running": "قيد التشغيل"
"responseTime": "وقت الاستجابة"
},
"form": {
"role": {
@@ -122,17 +120,6 @@
"active": "نشط",
"responseTime": "وقت الاستجابة"
}
},
"statusCode": {
"down": "متوقف",
"networkError": {
"dns": "تعذر تحليل اسم المضيف (فشل بحث DNS).",
"refused": "رفض الخادم الاتصال (المنفذ مغلق أو الخدمة لا تستمع).",
"reset": "تمت إعادة تعيين الاتصال قبل تلقي استجابة.",
"timeout": "لم يستجب الخادم في الوقت المحدد (انتهت مهلة الطلب).",
"tls": "فشل تأكيد اتصال TLS أو SSL (مشكلة في الشهادة أو التشفير).",
"unknown": "لم يتم تلقي أي استجابة HTTP من الهدف."
}
}
},
"components": {
@@ -319,28 +306,28 @@
"common": {
"passwordRules": {
"length": {
"beginning": "يجب أن يكون على الأقل",
"highlighted": "8 أحرف"
"beginning": "",
"highlighted": ""
},
"lowercase": {
"beginning": "يجب أن يحتوي على الأقل على",
"highlighted": "حرف صغير واحد"
"beginning": "",
"highlighted": ""
},
"match": {
"beginning": "كلمات المرور",
"highlighted": "يجب أن تتطابق"
"beginning": "",
"highlighted": ""
},
"number": {
"beginning": "يجب أن يحتوي على الأقل على",
"highlighted": "رقم واحد"
"beginning": "",
"highlighted": ""
},
"special": {
"beginning": "يجب أن يحتوي على الأقل على",
"highlighted": "حرف خاص واحد (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)"
"beginning": "",
"highlighted": ""
},
"uppercase": {
"beginning": "يجب أن يحتوي على الأقل على",
"highlighted": "حرف كبير واحد"
"beginning": "",
"highlighted": ""
}
},
"form": {
@@ -465,8 +452,7 @@
"optionPagespeed": "PageSpeed",
"optionHardware": "البنية التحتية",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket",
"optionDns": "DNS"
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "تم إيقاف {{count}} شاشة مؤقتاً",
@@ -476,8 +462,7 @@
"alreadyPaused_one": "الشاشة المحددة متوقفة مؤقتاً بالفعل",
"alreadyPaused_other": "جميع الشاشات المحددة متوقفة مؤقتاً بالفعل",
"alreadyRunning_one": "الشاشة المحددة قيد التشغيل بالفعل",
"alreadyRunning_other": "جميع الشاشات المحددة قيد التشغيل بالفعل",
"partialFailure": "تم تحديث {{successCount}} من أجهزة المراقبة، ولكن فشلت مزامنة {{failedCount}} مع قائمة انتظار الوظائف"
"alreadyRunning_other": "جميع الشاشات المحددة قيد التشغيل بالفعل"
}
}
},
@@ -498,9 +483,9 @@
},
"matchMethod": {
"label": "طريقة المطابقة",
"equal": "يساوي",
"include": "يحتوي",
"regex": "تعبير عادي"
"equal": "",
"include": "",
"regex": ""
}
},
"title": "إعدادات متقدمة"
@@ -512,15 +497,15 @@
"label": "تكرار الفحص",
"value": {
"fifteenMinutes": "15 دقيقة",
"fifteenSeconds": "15 ثانية",
"fifteenSeconds": "",
"fiveMinutes": "5 دقائق",
"fourMinutes": "4 دقائق",
"oneMinute": "دقيقة واحدة",
"fourMinutes": "",
"oneMinute": "",
"tenMinutes": "10 دقائق",
"thirtyMinutes": "30 دقيقة",
"thirtySeconds": "30 ثانية",
"threeMinutes": "3 دقائق",
"twoMinutes": "دقيقتان"
"thirtySeconds": "",
"threeMinutes": "",
"twoMinutes": ""
}
}
},
@@ -549,8 +534,8 @@
"placeholder": "https://www.google.com"
},
"game": {
"label": "اختر اللعبة",
"placeholder": "حدد لعبة"
"label": "",
"placeholder": ""
},
"port": {
"label": "المنفذ المراد مراقبته",
@@ -567,40 +552,19 @@
"captureUrl": {
"label": "نقطة نهاية Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
},
"dnsRecordType": {
"label": "نوع سجل DNS",
"value": {
"A": "A",
"AAAA": "AAAA",
"CNAME": "CNAME",
"MX": "MX",
"NS": "NS",
"TXT": "TXT"
}
},
"dnsServer": {
"label": "خادم DNS",
"placeholder": "8.8.8.8"
},
"strategy": {
"desktop": "سطح المكتب",
"label": "الاستراتيجية",
"mobile": "الجوال"
}
},
"title": "الإعدادات العامة",
"description": {
"http": "أدخل الرابط أو عنوان IP للمراقبة (مثال: https://example.com/ أو 192.168.1.100) وأضف اسم عرض واضح يظهر في لوحة التحكم.",
"ping": "أدخل عنوان IP أو اسم المضيف للاتصال (على سبيل المثال، 192.168.1.100 أو example.com) وأضف اسماً واضحاً يظهر في لوحة المعلومات.",
"port": "أدخل عنوان URL أو IP للخادم ورقم المنفذ واسماً واضحاً يظهر في لوحة المعلومات.",
"docker": "أدخل اسم حاوية Docker أو المعرف. يمكنك استخدام اسم الحاوية (مثل my-app) أو معرف الحاوية (معرف كامل 64 حرف أو معرف قصير).",
"ping": "",
"port": "",
"docker": "",
"game": "أدخل عنوان IP أو اسم المضيف ورقم المنفذ للاختبار (مثل 192.168.1.100 أو example.com) واختر نوع اللعبة. تتطلب بعض الألعاب تفعيل استعلامات الخادم في ملف الإعدادات الخاص بها — يُرجى مراجعة وثائق خادم اللعبة. اطّلع على <gamedigLink>قائمة الألعاب المدعومة</gamedigLink> لمعرفة تفاصيل البروتوكول.",
"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'.",
"dns": "راقب ما إذا كان خادم DNS محدد يستجيب."
"hardware": "راقب استخدام وحدة المعالجة المركزية والذاكرة والقرص ودرجة الحرارة لبنيتك التحتية. يجب أن يتضمن عنوان URL لنقطة نهاية Capture المسار الكامل ‎/api/v1/metrics‎ (مثال: ‎http://192.168.1.10:5001/api/v1/metrics‎). بالنسبة لمفتاح التفويض، الصق سلسلة الرمز فقط — لا تُضمّن كلمة 'Bearer'."
}
},
"ignoreTls": {
@@ -637,8 +601,7 @@
"optionHttpDescription": "استخدم HTTP(S) لمراقبة موقعك الإلكتروني أو نقطة نهاية API.",
"optionPingDescription": "استخدم ICMP Ping لمراقبة ما إذا كان الخادم متصلاً.",
"optionPortDescription": "مراقبة ما إذا كان منفذ معين على الخادم مفتوحاً.",
"title": "النوع",
"optionDnsDescription": "راقب ما إذا كان خادم DNS محدد يستجيب."
"title": "النوع"
},
"thresholds": {
"title": "حدود التنبيه",
@@ -802,7 +765,7 @@
"headers": {
"endTime": "وقت الانتهاء",
"resolutionType": "نوع الحل",
"startTime": "وقت البدء",
"startTime": "",
"statusCode": "رمز الحالة"
},
"resolvedIncidents": "الحوادث المحلولة",
@@ -846,13 +809,13 @@
}
},
"statBoxes": {
"avgCpuTemperature": "متوسط درجة حرارة المعالج",
"cpuFrequency": "تردد المعالج",
"cpuLogical": "المعالج (المنطقي)",
"cpuPhysical": "المعالج (الفعلي)",
"avgCpuTemperature": "",
"cpuFrequency": "",
"cpuLogical": "",
"cpuPhysical": "",
"disk": "القرص",
"memory": "الذاكرة",
"os": "نظام التشغيل"
"os": ""
},
"table": {
"headers": {
@@ -863,7 +826,7 @@
},
"tabs": {
"labels": {
"network": "الشبكة",
"network": "",
"overview": "نظرة عامة"
}
},
@@ -941,10 +904,12 @@
"monitorId": "معرّف المراقب",
"runCount": "عدد التشغيلات",
"failCount": "عدد حالات الفشل",
"lastRunAt": "آخر تشغيل في",
"lockedAt": "مقفل في",
"lastFinishedAt": "آخر انتهاء في",
"lastRunTook": "مدة آخر تشغيل",
"lastFailedAt": "آخر فشل في",
"failReason": "سبب الفشل",
"state": "الحالة"
"failReason": "سبب الفشل"
}
},
"queueFallback": {
@@ -964,8 +929,8 @@
},
"table": {
"headers": {
"nextWindow": "النافذة التالية",
"repeat": "تكرار"
"nextWindow": "",
"repeat": ""
}
},
"form": {
@@ -1067,7 +1032,7 @@
},
"telegram": {
"title": "تهيئة Telegram",
"description": "قم بتكوين بوت Telegram الخاص بك لإرسال الإشعارات.",
"description": "",
"optionBotToken": "رمز البوت",
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "معرّف المحادثة",
@@ -1134,10 +1099,6 @@
"header": {
"title": "مراقبات PageSpeed",
"description": "تابع Core Web Vitals ودرجات Lighthouse عبر الزمن لاكتشاف التراجع قبل أن يلاحظه المستخدمون."
},
"strategy": {
"desktop": "سطح المكتب",
"mobile": "الجوال"
}
},
"settings": {
@@ -1240,7 +1201,7 @@
"email": {
"title": "إعدادات البريد الإلكتروني",
"description": "تهيئة إعدادات البريد الإلكتروني لنظامك. يُستخدم لإرسال الإشعارات والتنبيهات.",
"descriptionTransport": "هذا يبني نقل SMTP لـ NodeMailer",
"descriptionTransport": "",
"descriptionTransportLink": "اطلع على المواصفات هنا",
"option": {
"host": {
@@ -1286,10 +1247,6 @@
},
"rejectUnauthorized": {
"label": "رفض الشهادات غير الصالحة: رفض الاتصالات ذات الشهادات الموقعة ذاتياً أو غير الموثوقة"
},
"displayName": {
"label": "اسم المرسل (اختياري) - الاسم الودي المعروض في حقل المرسل",
"placeholder": "إشعارات Checkmate"
}
}
},
@@ -1350,20 +1307,6 @@
"uptime": {
"title": "وقت التشغيل",
"responseTime": "وقت الاستجابة"
},
"chart": {
"avg": "متوسط {{value}} مللي ثانية",
"downTooltip": "متوقف",
"heatmapAria": "خريطة حرارية لزمن الاستجابة",
"max": "أقصى {{value}} مللي ثانية"
},
"status": {
"breached": "تم تجاوز الحد",
"down": "متوقف",
"initializing": "قيد التهيئة",
"maintenance": "صيانة",
"paused": "متوقف مؤقتاً",
"up": "قيد التشغيل"
}
},
"statusBar": {
@@ -1381,9 +1324,7 @@
"maintenance": "بعض الأنظمة قيد الصيانة",
"partiallyPaused": "بعض الأنظمة متوقفة مؤقتًا",
"initializing": "بعض الأنظمة قيد التهيئة",
"noMonitors": "لم يتم تكوين أي شاشة",
"monitoringSummary": "مراقبة {{count}} خدمة · يتم التحديث كل 10 ثوانٍ",
"monitoringSummary_other": "مراقبة {{count}} خدمات · يتم التحديث كل 10 ثوانٍ"
"noMonitors": "لم يتم تكوين أي شاشة"
},
"table": {
"headers": {
@@ -1474,47 +1415,11 @@
"label": "إظهار مقاييس البنية التحتية"
}
}
},
"theme": {
"description": "اختر النمط المرئي لصفحة الحالة العامة الخاصة بك.",
"label": "السمة",
"options": {
"bold": {
"description": "خط كبير ومسافات واثقة. رائعة للصفحات التي تركز على العلامة التجارية.",
"name": "غامق"
},
"editorial": {
"description": "عناوين بخط مسرف وفواصل دقيقة. مميز وهادئ.",
"name": "تحريري"
},
"modern": {
"description": "بجودة SaaS مع نقطة حالة نابضة وبطاقات تتلاشى بنعومة.",
"name": "حديث"
},
"refined": {
"description": "نظيف واحترافي وقريب من مظهر Checkmate الكلاسيكي.",
"name": "مكرر"
}
},
"title": "سمة الصفحة"
},
"themeMode": {
"auto": "تلقائي (اتبع جهاز الزائر)",
"dark": "داكن فقط",
"label": "وضع السمة",
"light": "فاتح فقط"
}
},
"header": {
"title": "صفحات الحالة",
"description": "انشر صفحات عامة تُظهر للعملاء وأصحاب المصلحة وقت التشغيل الفعلي وتاريخ الحوادث."
},
"editorial": {
"allUp": "جميع الأنظمة تعمل بشكل طبيعي.",
"reportTitle": "تقرير الخدمة"
},
"footer": {
"poweredBy": "مشغل بواسطة"
}
},
"uptime": {
+9 -62
View File
@@ -73,9 +73,7 @@
"paused": "pausat",
"na": "N/D",
"resolved": "Resolt",
"responseTime": "Temps de resposta",
"idle": "Inactiu",
"running": "En execució"
"responseTime": "Temps de resposta"
},
"form": {
"role": {
@@ -122,17 +120,6 @@
"active": "Actiu",
"responseTime": "Temps de resposta"
}
},
"statusCode": {
"down": "Inactiu",
"networkError": {
"dns": "No s'ha pogut resoldre el nom d'amfitrió (la cerca DNS ha fallat).",
"refused": "El servidor ha rebutjat la connexió (port tancat o servei no actiu).",
"reset": "La connexió s'ha reiniciat abans de rebre una resposta.",
"timeout": "El servidor no ha respost a temps (s'ha esgotat el temps d'espera).",
"tls": "Ha fallat la negociació TLS o SSL (problema de certificat o xifratge).",
"unknown": "No s'ha rebut cap resposta HTTP de l'objectiu."
}
}
},
"components": {
@@ -434,11 +421,7 @@
"inviteMember": "Convidar membre",
"openSite": "Obrir lloc",
"pause": "Pausar",
"resume": "Reprendre",
"bulkSelected_one": "{{count}} monitor seleccionat",
"bulkSelected_other": "{{count}} monitors seleccionats",
"selectAll": "Selecciona tots els monitors",
"selectMonitor": "Selecciona {{name}}"
"resume": "Reprendre"
},
"statBoxes": {
"activeFor": "Actiu des de",
@@ -465,19 +448,7 @@
"optionPagespeed": "PageSpeed",
"optionHardware": "Infraestructura",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket",
"optionDns": "DNS"
},
"bulkPause": {
"alreadyPaused_one": "El monitor seleccionat ja està pausat",
"alreadyPaused_other": "Tots els monitors seleccionats ja estan pausats",
"alreadyRunning_one": "El monitor seleccionat ja s'està executant",
"alreadyRunning_other": "Tots els monitors seleccionats ja s'estan executant",
"paused_one": "{{count}} monitor pausat",
"paused_other": "{{count}} monitors pausats",
"resumed_one": "{{count}} monitor reprès",
"resumed_other": "{{count}} monitors represos",
"partialFailure": "S'han actualitzat {{successCount}} monitors, però {{failedCount}} han fallat en sincronitzar-se amb la cua de tasques"
"optionWebSocket": "WebSocket"
}
}
},
@@ -567,26 +538,6 @@
"captureUrl": {
"label": "Endpoint de captura",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
},
"dnsRecordType": {
"label": "Tipus de registre DNS",
"value": {
"A": "A",
"AAAA": "AAAA",
"CNAME": "CNAME",
"MX": "MX",
"NS": "NS",
"TXT": "TXT"
}
},
"dnsServer": {
"label": "Servidor DNS",
"placeholder": "8.8.8.8"
},
"strategy": {
"desktop": "Escriptori",
"label": "Estratègia",
"mobile": "Mòbil"
}
},
"title": "Configuració general",
@@ -599,8 +550,7 @@
"pagespeed": "Fes un seguiment del rendiment de càrrega de pàgina, Core Web Vitals i puntuacions d'optimització del teu lloc web.",
"grpc": "Introdueix el nom d'host i el port del servidor gRPC, opcionalment especifica un nom de servei per a la comprovació de salut, i afegeix un nom de visualització.",
"websocket": "Introdueix la URL de WebSocket a monitorar (p. ex., wss://example.com/socket) i afegeix un nom de visualització.",
"hardware": "Monitoritza l'ús de CPU, memòria, disc i temperatura per a la teva infraestructura. La URL de l'endpoint de captura ha d'incloure la ruta completa /api/v1/metrics (p. ex., http://192.168.1.10:5001/api/v1/metrics). Per al secret d'autorització, enganxa només la cadena del token — no incloguis 'Bearer'.",
"dns": "Monitoritza si un servidor DNS específic respon."
"hardware": "Monitoritza l'ús de CPU, memòria, disc i temperatura per a la teva infraestructura. La URL de l'endpoint de captura ha d'incloure la ruta completa /api/v1/metrics (p. ex., http://192.168.1.10:5001/api/v1/metrics). Per al secret d'autorització, enganxa només la cadena del token — no incloguis 'Bearer'."
}
},
"ignoreTls": {
@@ -637,8 +587,7 @@
"optionHttpDescription": "Usa HTTP(S) per monitorar el teu lloc web o endpoint d'API.",
"optionPingDescription": "Usa ICMP Ping per monitorar si un servidor és en línia.",
"optionPortDescription": "Monitoritza si un port específic d'un servidor és obert.",
"title": "Tipus",
"optionDnsDescription": "Monitoritza si un servidor DNS específic respon."
"title": "Tipus"
},
"thresholds": {
"title": "Llindars d'alerta",
@@ -945,10 +894,12 @@
"monitorId": "ID del monitor",
"runCount": "Recompte d'execucions",
"failCount": "Recompte",
"lastRunAt": "Darrera execució",
"lockedAt": "Bloquejat el",
"lastFinishedAt": "Darrera finalització",
"lastRunTook": "Durada de la darrera execució",
"lastFailedAt": "Darrera fallada",
"failReason": "Motiu de la fallada",
"state": "Estat"
"failReason": "Motiu de la fallada"
}
},
"header": {
@@ -1134,10 +1085,6 @@
"header": {
"title": "Monitors de PageSpeed",
"description": "Fes un seguiment de les Core Web Vitals i les puntuacions de Lighthouse al llarg del temps per detectar regressions abans que els usuaris ho facin."
},
"strategy": {
"desktop": "Escriptori",
"mobile": "Mòbil"
}
},
"settings": {
+35 -130
View File
@@ -73,9 +73,7 @@
"paused": "pozastaveno",
"na": "N/A",
"resolved": "Vyřešeno",
"responseTime": "Doba odezvy",
"idle": "Nečinný",
"running": "Spuštěno"
"responseTime": "Doba odezvy"
},
"form": {
"role": {
@@ -122,17 +120,6 @@
"active": "Aktivní",
"responseTime": "Doba odezvy"
}
},
"statusCode": {
"down": "Mimo provoz",
"networkError": {
"dns": "Nepodařilo se přeložit název hostitele (vyhledávání DNS selhalo).",
"refused": "Server odmítl spojení (port je uzavřený nebo služba neposlouchá).",
"reset": "Spojení bylo resetováno před přijetím odpovědi.",
"timeout": "Server neodpověděl včas (vypršel časový limit požadavku).",
"tls": "Selhal handshake TLS nebo SSL (problém s certifikátem nebo šifrováním).",
"unknown": "Z cíle nebyla obdržena žádná odpověď HTTP."
}
}
},
"components": {
@@ -465,8 +452,7 @@
"optionPagespeed": "PageSpeed",
"optionHardware": "Infrastruktura",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket",
"optionDns": "DNS"
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitor pozastaven",
@@ -476,8 +462,7 @@
"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ěží",
"partialFailure": "Bylo aktualizováno {{successCount}} monitorů, ale u {{failedCount}} se nezdařila synchronizace s frontou úloh"
"alreadyRunning_other": "Všechny vybrané monitory již běží"
}
}
},
@@ -498,9 +483,9 @@
},
"matchMethod": {
"label": "Metoda porovnání",
"equal": "Rovná se",
"include": "Obsahuje",
"regex": "Regulární výraz"
"equal": "",
"include": "",
"regex": ""
}
},
"title": "Pokročilá nastavení"
@@ -512,15 +497,15 @@
"label": "Frekvence kontroly",
"value": {
"fifteenMinutes": "15 minut",
"fifteenSeconds": "15 sekund",
"fifteenSeconds": "",
"fiveMinutes": "5 minut",
"fourMinutes": "4 minuty",
"oneMinute": "1 minuta",
"fourMinutes": "",
"oneMinute": "",
"tenMinutes": "10 minut",
"thirtyMinutes": "30 minut",
"thirtySeconds": "30 sekund",
"threeMinutes": "3 minuty",
"twoMinutes": "2 minuty"
"thirtySeconds": "",
"threeMinutes": "",
"twoMinutes": ""
}
}
},
@@ -549,8 +534,8 @@
"placeholder": "https://www.google.com"
},
"game": {
"label": "Vybrat hru",
"placeholder": "Vyberte hru"
"label": "",
"placeholder": ""
},
"port": {
"label": "Port ke sledování",
@@ -567,40 +552,19 @@
"captureUrl": {
"label": "Endpoint Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
},
"dnsRecordType": {
"label": "Typ záznamu DNS",
"value": {
"A": "A",
"AAAA": "AAAA",
"CNAME": "CNAME",
"MX": "MX",
"NS": "NS",
"TXT": "TXT"
}
},
"dnsServer": {
"label": "DNS server",
"placeholder": "8.8.8.8"
},
"strategy": {
"desktop": "Stolní počítač",
"label": "Strategie",
"mobile": "Mobil"
}
},
"title": "Obecná nastavení",
"description": {
"http": "Zadejte URL nebo IP adresu ke sledování (např. https://example.com/ nebo 192.168.1.100) a přidejte jasný zobrazovaný název, který se zobrazí na přehledu.",
"ping": "Zadejte IP adresu nebo název hostitele pro ping (např. 192.168.1.100 nebo example.com) a přidejte přehledný název, který se zobrazí na dashboardu.",
"port": "Zadejte URL nebo IP serveru, číslo portu a přehledný název, který se zobrazí na dashboardu.",
"docker": "Zadejte název nebo ID Docker kontejneru. Můžete použít buď název kontejneru (např. my-app) nebo ID kontejneru (úplné 64znakové ID nebo krátké ID).",
"ping": "",
"port": "",
"docker": "",
"game": "Zadejte IP adresu nebo název hostitele a číslo portu pro ping (např. 192.168.1.100 nebo example.com) a vyberte typ hry. Některé hry vyžadují povolení dotazů na server v jejich konfiguračním souboru — viz dokumentace vašeho herního serveru. Podrobnosti o protokolu najdete v <gamedigLink>seznamu podporovaných her</gamedigLink>.",
"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'.",
"dns": "Sledujte, zda konkrétní DNS server odpovídá."
"hardware": "Sledujte využití CPU, paměti, disku a teplotu vaší infrastruktury. URL endpointu Capture musí obsahovat celou cestu /api/v1/metrics (např. http://192.168.1.10:5001/api/v1/metrics). Do autorizačního klíče vložte pouze řetězec tokenu — nezahrnujte 'Bearer'."
}
},
"ignoreTls": {
@@ -637,8 +601,7 @@
"optionHttpDescription": "Použijte HTTP(S) ke sledování vašeho webu nebo API koncového bodu.",
"optionPingDescription": "Použijte ICMP Ping ke sledování, zda je server online.",
"optionPortDescription": "Sledujte, zda je konkrétní port na serveru otevřen.",
"title": "Typ",
"optionDnsDescription": "Sledujte, zda konkrétní DNS server odpovídá."
"title": "Typ"
},
"thresholds": {
"title": "Prahové hodnoty upozornění",
@@ -802,7 +765,7 @@
"headers": {
"endTime": "Čas ukončení",
"resolutionType": "Typ řešení",
"startTime": "Čas zahájení",
"startTime": "",
"statusCode": "Stavový kód"
},
"resolvedIncidents": "Vyřešené incidenty",
@@ -846,13 +809,13 @@
}
},
"statBoxes": {
"avgCpuTemperature": "Průměrná teplota CPU",
"cpuFrequency": "Frekvence CPU",
"cpuLogical": "CPU (logický)",
"cpuPhysical": "CPU (fyzický)",
"avgCpuTemperature": "",
"cpuFrequency": "",
"cpuLogical": "",
"cpuPhysical": "",
"disk": "Disk",
"memory": "Paměť",
"os": "OS"
"os": ""
},
"table": {
"headers": {
@@ -863,7 +826,7 @@
},
"tabs": {
"labels": {
"network": "Síť",
"network": "",
"overview": "Přehled"
}
},
@@ -941,10 +904,12 @@
"monitorId": "ID monitoru",
"runCount": "Počet spuštění",
"failCount": "Počet selhání",
"lastRunAt": "Poslední spuštění",
"lockedAt": "Uzamčeno v",
"lastFinishedAt": "Naposledy dokončeno",
"lastRunTook": "Poslední běh trval",
"lastFailedAt": "Poslední selhání",
"failReason": "Důvod selhání",
"state": "Stav"
"failReason": "Důvod selhání"
}
},
"queueFallback": {
@@ -964,8 +929,8 @@
},
"table": {
"headers": {
"nextWindow": "Další okno",
"repeat": "Opakovat"
"nextWindow": "",
"repeat": ""
}
},
"form": {
@@ -1067,7 +1032,7 @@
},
"telegram": {
"title": "Konfigurace Telegramu",
"description": "Nakonfigurujte svého Telegram bota pro odesílání oznámení.",
"description": "",
"optionBotToken": "Token bota",
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "ID chatu",
@@ -1134,10 +1099,6 @@
"header": {
"title": "Monitory PageSpeed",
"description": "Sledujte Core Web Vitals a skóre Lighthouse v čase a zachyťte regrese dříve než vaši uživatelé."
},
"strategy": {
"desktop": "Stolní počítač",
"mobile": "Mobil"
}
},
"settings": {
@@ -1240,7 +1201,7 @@
"email": {
"title": "Nastavení emailu",
"description": "Nastavte emailovou konfiguraci pro váš systém. Používá se k odesílání oznámení a upozornění.",
"descriptionTransport": "Toto vytvoří SMTP transport pro NodeMailer",
"descriptionTransport": "",
"descriptionTransportLink": "Zobrazit specifikace zde",
"option": {
"host": {
@@ -1286,10 +1247,6 @@
},
"rejectUnauthorized": {
"label": "Odmítnout neplatné certifikáty: Odmítnout připojení se sebou podepsanými nebo nedůvěryhodnými certifikáty"
},
"displayName": {
"label": "Jméno odesílatele (nepovinné) - Přátelské jméno zobrazené v poli Od",
"placeholder": "Oznámení Checkmate"
}
}
},
@@ -1350,20 +1307,6 @@
"uptime": {
"title": "Dostupnost",
"responseTime": "Doba odezvy"
},
"chart": {
"avg": "průměr {{value}}ms",
"downTooltip": "Mimo provoz",
"heatmapAria": "Tepelná mapa doby odezvy",
"max": "max {{value}}ms"
},
"status": {
"breached": "Překročen limit",
"down": "Mimo provoz",
"initializing": "Inicializace",
"maintenance": "Údržba",
"paused": "Pozastaveno",
"up": "V provozu"
}
},
"statusBar": {
@@ -1381,9 +1324,7 @@
"maintenance": "Některé systémy jsou v údržbě",
"partiallyPaused": "Některé systémy jsou pozastavené",
"initializing": "Některé systémy se inicializují",
"noMonitors": "Nebyly nakonfigurovány žádné monitory",
"monitoringSummary": "Sledování {{count}} služby · Aktualizace každých 10 sekund",
"monitoringSummary_other": "Sledování {{count}} služeb · Aktualizace každých 10 sekund"
"noMonitors": "Nebyly nakonfigurovány žádné monitory"
},
"table": {
"headers": {
@@ -1474,47 +1415,11 @@
"label": "Zobrazit metriky infrastruktury"
}
}
},
"theme": {
"description": "Vyberte vizuální styl pro veřejnou stavovou stránku.",
"label": "Motiv",
"options": {
"bold": {
"description": "Velké písmo a sebevědomé rozložení. Vhodné pro stránky zaměřené na značku.",
"name": "Tučný"
},
"editorial": {
"description": "Patkové nadpisy a tenké oddělovače. Odlišný a klidný.",
"name": "Redakční"
},
"modern": {
"description": "Úroveň SaaS s pulzující stavovou tečkou a jemně mizejícími kartami.",
"name": "Moderní"
},
"refined": {
"description": "Čistý, profesionální, blízký klasickému vzhledu Checkmate.",
"name": "Vytříbený"
}
},
"title": "Motiv stránky"
},
"themeMode": {
"auto": "Automaticky (podle zařízení návštěvníka)",
"dark": "Pouze tmavý",
"label": "Režim motivu",
"light": "Pouze světlý"
}
},
"header": {
"title": "Stavové stránky",
"description": "Publikujte veřejné stránky, které zákazníkům a zúčastněným stranám zobrazují dostupnost v reálném čase a historii incidentů."
},
"editorial": {
"allUp": "Všechny systémy fungují normálně.",
"reportTitle": "Zpráva o službě"
},
"footer": {
"poweredBy": "Provozováno službou"
}
},
"uptime": {
+10 -105
View File
@@ -73,9 +73,7 @@
"paused": "pausiert",
"na": "N/A",
"resolved": "Gelöst",
"responseTime": "Antwortzeit",
"idle": "Untätig",
"running": "Läuft"
"responseTime": "Antwortzeit"
},
"form": {
"role": {
@@ -122,17 +120,6 @@
"active": "Aktiv",
"responseTime": "Antwortzeit"
}
},
"statusCode": {
"down": "Ausgefallen",
"networkError": {
"dns": "Hostname konnte nicht aufgelöst werden (DNS-Auflösung fehlgeschlagen).",
"refused": "Der Server hat die Verbindung abgelehnt (Port geschlossen oder Dienst lauscht nicht).",
"reset": "Die Verbindung wurde zurückgesetzt, bevor eine Antwort empfangen wurde.",
"timeout": "Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung der Anfrage).",
"tls": "TLS- oder SSL-Handshake fehlgeschlagen (Zertifikats- oder Verschlüsselungsproblem).",
"unknown": "Es wurde keine HTTP-Antwort vom Ziel empfangen."
}
}
},
"components": {
@@ -465,8 +452,7 @@
"optionPagespeed": "PageSpeed",
"optionHardware": "Infrastruktur",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket",
"optionDns": "DNS"
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} Monitor pausiert",
@@ -476,8 +462,7 @@
"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",
"partialFailure": "{{successCount}} Monitore aktualisiert, aber {{failedCount}} konnten nicht mit der Job-Warteschlange synchronisiert werden"
"alreadyRunning_other": "Alle ausgewählten Monitore werden bereits ausgeführt"
}
}
},
@@ -567,26 +552,6 @@
"captureUrl": {
"label": "Capture-Endpunkt",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
},
"dnsRecordType": {
"label": "DNS-Eintragstyp",
"value": {
"A": "A",
"AAAA": "AAAA",
"CNAME": "CNAME",
"MX": "MX",
"NS": "NS",
"TXT": "TXT"
}
},
"dnsServer": {
"label": "DNS-Server",
"placeholder": "8.8.8.8"
},
"strategy": {
"desktop": "Desktop",
"label": "Strategie",
"mobile": "Mobil"
}
},
"title": "Allgemeine Einstellungen",
@@ -599,8 +564,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.",
"dns": "Überwachen Sie, ob ein bestimmter DNS-Server antwortet."
"hardware": "Überwachen Sie CPU-, Speicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur. Die URL des Capture-Endpunkts muss den vollständigen Pfad /api/v1/metrics enthalten (z. B. http://192.168.1.10:5001/api/v1/metrics). Fügen Sie für das Autorisierungsgeheimnis nur den Token-String ein — geben Sie 'Bearer' nicht mit an."
}
},
"ignoreTls": {
@@ -637,8 +601,7 @@
"optionHttpDescription": "HTTP(S) verwenden, um Ihre Website oder Ihren API-Endpunkt zu überwachen.",
"optionPingDescription": "ICMP-Ping verwenden, um zu überwachen, ob ein Server online ist.",
"optionPortDescription": "Überwachen, ob ein bestimmter Port auf einem Server geöffnet ist.",
"title": "Typ",
"optionDnsDescription": "Überwachen Sie, ob ein bestimmter DNS-Server antwortet."
"title": "Typ"
},
"thresholds": {
"title": "Alarmschwellenwerte",
@@ -941,10 +904,12 @@
"monitorId": "Monitor-ID",
"runCount": "Ausführungen",
"failCount": "Fehlerzahl",
"lastRunAt": "Letzter Lauf am",
"lockedAt": "Gesperrt am",
"lastFinishedAt": "Zuletzt abgeschlossen am",
"lastRunTook": "Letzter Lauf dauerte",
"lastFailedAt": "Letzter Fehler am",
"failReason": "Fehlergrund",
"state": "Status"
"failReason": "Fehlergrund"
}
},
"queueFallback": {
@@ -1134,10 +1099,6 @@
"header": {
"title": "PageSpeed-Monitore",
"description": "Verfolgen Sie Core Web Vitals und Lighthouse-Werte im Zeitverlauf, um Regressionen zu erkennen, bevor Nutzer es tun."
},
"strategy": {
"desktop": "Desktop",
"mobile": "Mobil"
}
},
"settings": {
@@ -1286,10 +1247,6 @@
},
"rejectUnauthorized": {
"label": "Ungültige Zertifikate ablehnen: Verbindungen mit selbstsignierten oder nicht vertrauenswürdigen Zertifikaten ablehnen"
},
"displayName": {
"label": "Absendername (optional) - Anzeigename im Absenderfeld",
"placeholder": "Checkmate-Benachrichtigungen"
}
}
},
@@ -1350,20 +1307,6 @@
"uptime": {
"title": "Verfügbarkeit",
"responseTime": "Antwortzeit"
},
"chart": {
"avg": "Ø {{value}}ms",
"downTooltip": "Ausgefallen",
"heatmapAria": "Heatmap der Antwortzeit",
"max": "max. {{value}}ms"
},
"status": {
"breached": "Schwellenwert überschritten",
"down": "Ausgefallen",
"initializing": "Initialisierung",
"maintenance": "Wartung",
"paused": "Pausiert",
"up": "Betriebsbereit"
}
},
"statusBar": {
@@ -1381,9 +1324,7 @@
"maintenance": "Einige Systeme befinden sich in Wartung",
"partiallyPaused": "Einige Systeme sind pausiert",
"initializing": "Einige Systeme werden initialisiert",
"noMonitors": "Es wurden keine Monitore konfiguriert",
"monitoringSummary": "Überwachung von {{count}} Dienst · Aktualisierung alle 10 Sekunden",
"monitoringSummary_other": "Überwachung von {{count}} Diensten · Aktualisierung alle 10 Sekunden"
"noMonitors": "Es wurden keine Monitore konfiguriert"
},
"table": {
"headers": {
@@ -1474,47 +1415,11 @@
"label": "Infrastruktur-Metriken anzeigen"
}
}
},
"theme": {
"description": "Wählen Sie den visuellen Stil für Ihre öffentliche Statusseite.",
"label": "Theme",
"options": {
"bold": {
"description": "Große Schrift und großzügige Abstände. Ideal für markenorientierte Seiten.",
"name": "Fett"
},
"editorial": {
"description": "Serifenüberschriften und feine Trennlinien. Markant und ruhig.",
"name": "Redaktionell"
},
"modern": {
"description": "SaaS-Niveau mit pulsierendem Statuspunkt und sanft verblassenden Karten.",
"name": "Modern"
},
"refined": {
"description": "Sauber, professionell, nah am klassischen Checkmate-Look.",
"name": "Verfeinert"
}
},
"title": "Seiten-Theme"
},
"themeMode": {
"auto": "Automatisch (Gerät des Besuchers folgen)",
"dark": "Nur dunkel",
"label": "Theme-Modus",
"light": "Nur hell"
}
},
"header": {
"title": "Statusseiten",
"description": "Veröffentlichen Sie öffentliche Seiten, die Kunden und Stakeholdern Echtzeit-Verfügbarkeit und Vorfallsverlauf zeigen."
},
"editorial": {
"allUp": "Alle Systeme funktionieren normal.",
"reportTitle": "Servicebericht"
},
"footer": {
"poweredBy": "Bereitgestellt von"
}
},
"uptime": {
+22 -125
View File
@@ -30,6 +30,7 @@
"home": "Home"
},
"buttons": {
"selected": "selected",
"addMember": "Add member",
"cancel": "Cancel",
"close": "Close",
@@ -43,8 +44,6 @@
"pause": "Pause",
"resume": "Resume",
"save": "Save",
"next": "Next",
"back": "Back",
"sendInvite": "Send invite",
"test": "Test",
"testNotifications": "Test notifications",
@@ -133,8 +132,7 @@
"url": "Url",
"interval": "Interval",
"active": "Active",
"responseTime": "Response time",
"tags": "Tags"
"responseTime": "Response time"
}
}
},
@@ -187,7 +185,6 @@
"pagespeed": "Pagespeed",
"infrastructure": "Infrastructure",
"notifications": "Notifications",
"tags": "Tags",
"checks": "Checks",
"incidents": "Incidents",
"statusPages": "Status pages",
@@ -429,6 +426,20 @@
},
"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",
@@ -452,8 +463,7 @@
"paused_one": "{{count}} monitor paused",
"paused_other": "{{count}} monitors paused",
"resumed_one": "{{count}} monitor resumed",
"resumed_other": "{{count}} monitors resumed",
"partialFailure": "{{successCount}} monitors updated, but {{failedCount}} failed to sync with the job queue"
"resumed_other": "{{count}} monitors resumed"
},
"statBoxes": {
"activeFor": "Active for",
@@ -490,13 +500,6 @@
"advanced": {
"description": "Optional settings for advanced use cases",
"option": {
"method": {
"label": "Request method",
"description": {
"GET": "GET fetches the full response including the body. Advanced matching fully supported",
"HEAD": "HEAD fetches only response headers for reducing load on the target. Supports status code matching but not advanced matching as there is no body."
}
},
"advancedMatching": {
"label": "Use advanced matching"
},
@@ -512,11 +515,6 @@
"equal": "Equal",
"include": "Include",
"regex": "Regex"
},
"customUpCodes": {
"label": "Accepted status codes",
"description": "Add any non-2xx codes that should also be considered successful.",
"removeAriaLabel": "Remove status code"
}
},
"title": "Advanced settings"
@@ -644,10 +642,6 @@
"description": "Select the notification channels you want to use",
"title": "Notifications"
},
"tags": {
"description": "Select the tags you want to apply to this monitor",
"title": "Tags"
},
"type": {
"description": "Select the type of check to perform",
"optionDockerDescription": "Use Docker to monitor if a container is running.",
@@ -897,37 +891,6 @@
"description": "Track server performance, identify bottlenecks, and ensure reliability with real-time infrastructure monitoring."
}
},
"tags": {
"header": {
"title": "Tags",
"description": "Organize your monitors with tags"
},
"fallback": {
"title": "No tags yet",
"description": "Organize your monitors with tags. Once you create a tag and assign it to a monitor, you'll be able to filter by that tag across the app and see tag details here.",
"actionButton": "Create a tag"
},
"table": {
"headers": {
"color": "Color",
"appearance": "Appearance"
}
},
"form": {
"name": {
"title": "Tag name",
"description": "Choose a name for your tag",
"optionName": "Tag name",
"placeholder": "e.g. Group 1"
},
"color": {
"title": "Tag color",
"description": "Choose a color for your tag",
"optionName": "Tag color",
"placeholder": "e.g. #13715B"
}
}
},
"logs": {
"fallback": {
"title": "No logs yet",
@@ -945,8 +908,6 @@
"logLevelSelect": {
"label": "Log level"
},
"workers": "Workers",
"workersExplainers": "Shows worker processes. Worker IDs are truncated, hover for full ID",
"jobQueue": "Job queue",
"jobQueueExplainer": "<highlight>Highlighted rows</highlight> are jobs currently being processed by a worker.",
"failedJobs": "Failed jobs",
@@ -968,14 +929,6 @@
"memoryCpu": {
"title": "Memory & CPU",
"description": "How the process is using its allocated memory and host CPU."
},
"mongoDBStats": {
"title": "MongoDB stats",
"description": "Snapshot of MongoDB metrics"
},
"mongoDBCollectionStats": {
"title": "MongoDB collection stats",
"description": "Stats for MongoDB collections"
}
},
"stats": {
@@ -985,26 +938,6 @@
"totalHeapSize": "Total heap size",
"osMemoryLimit": "OS memory limit"
},
"mongoDBStats": {
"readyState": "Ready state",
"readsPerSecond": "Reads/s",
"insertsPerSecond": "Inserts/s",
"updatesPerSecond": "Updates/s",
"deletesPerSecond": "Deletes/s",
"writesPerSecond": "Total writes/s",
"host": "Host",
"port": "Port",
"dbName": "Database name",
"storageSize": "Storage size"
},
"mongoDBCollectionStats": {
"documents": "Documents: {{count}}",
"bucketCount": "Bucket count: {{count}}",
"storageSize": "Storage size: {{size}}",
"indexSize": "Index size: {{size}}",
"totalSize": "Total size: {{size}}",
"dbUsageTooltip": "{{percentage}}% of DB storage"
},
"gauges": {
"heapAllocation": "Heap allocation",
"heapUsage": "Heap usage",
@@ -1019,11 +952,6 @@
},
"table": {
"headers": {
"host": "Host",
"processId": "Process ID",
"workerId": "Worker ID",
"workerMode": "Worker mode",
"lastSeenAt": "Last seen at",
"state": "State",
"timestamp": "Timestamp",
"level": "Level",
@@ -1032,9 +960,10 @@
"monitorId": "Monitor ID",
"runCount": "Run count",
"failCount": "Count",
"nextScheduledAt": "Next run at",
"lockedBy": "Locked by",
"lastRunAt": "Last run at",
"lockedAt": "Locked at",
"lastFinishedAt": "Last finished at",
"lastRunTook": "Last run took",
"lastFailedAt": "Last failed",
"failReason": "Fail reason"
}
@@ -1180,14 +1109,6 @@
"placeholderFromNumber": "+15551234567",
"optionToNumber": "To number (recipient)",
"placeholderToNumber": "+15559876543"
},
"ntfy": {
"title": "ntfy configuration",
"description": "Configure an ntfy server and topic for push notifications.",
"optionServerUrl": "Server URL",
"placeholderServerUrl": "https://ntfy.sh",
"optionTopic": "Topic",
"placeholderTopic": "checkmate-alerts"
}
},
"table": {
@@ -1524,11 +1445,6 @@
"url": {
"label": "Your status page address",
"placeholder": "my-status-page"
},
"customDomain": {
"label": "Custom domain",
"placeholder": "status.example.com",
"helper": "Optional. Point a DNS record to this Checkmate instance to serve the status page at your own domain."
}
}
},
@@ -1585,10 +1501,6 @@
"editorial": {
"name": "Editorial",
"description": "Serif headings and hairline dividers. Distinctive and calm."
},
"minimal": {
"name": "Minimal",
"description": "Flat, focused, and compact with only the essentials."
}
}
},
@@ -1598,16 +1510,6 @@
"light": "Light only",
"dark": "Dark only"
},
"customCSS": {
"title": "Custom CSS",
"description": "Add custom CSS to fine-tune the look of your public status page.",
"option": {
"customCSS": {
"label": "Custom CSS",
"placeholder": "footer { display: none; }"
}
}
},
"features": {
"title": "Features",
"description": "Configure what information is displayed on your status page.",
@@ -1630,22 +1532,17 @@
"header": {
"title": "Status pages",
"description": "Publish public pages that show real-time uptime and incident history to your customers and stakeholders."
},
"preview": {
"title": "Status page preview"
}
},
"uptime": {
"filters": {
"search": {
"placeholder": "Search monitors..."
},
"activeTags": "Active tags"
}
},
"table": {
"headers": {
"responseTime": "Response time",
"uptime": "Uptime %"
"responseTime": "Response time"
}
},
"fallback": {
+24 -105
View File
@@ -47,7 +47,14 @@
"exportToJSON": "Exportar a JSON",
"importFromJSON": "Importar desde JSON",
"clearFilters": "Limpiar filtros",
"removeUser": "Eliminar usuario"
"removeUser": "Eliminar usuario",
"testNotification": "Probar notificación",
"invite": "Invitar",
"copyLink": "Copiar enlace",
"regenerateToken": "Regenerar token",
"copyToken": "Copiar token",
"exportLogs": "Exportar registros",
"refresh": "Actualizar"
},
"charts": {
"labels": {
@@ -73,9 +80,7 @@
"paused": "pausado",
"na": "N/D",
"resolved": "Resuelto",
"responseTime": "Tiempo de respuesta",
"idle": "Inactivo",
"running": "En ejecución"
"responseTime": "Tiempo de respuesta"
},
"form": {
"role": {
@@ -122,17 +127,6 @@
"active": "Activo",
"responseTime": "Tiempo de respuesta"
}
},
"statusCode": {
"down": "Caído",
"networkError": {
"dns": "No se pudo resolver el nombre de host (la búsqueda DNS falló).",
"refused": "El servidor rechazó la conexión (puerto cerrado o servicio inactivo).",
"reset": "La conexión se restableció antes de recibir una respuesta.",
"timeout": "El servidor no respondió a tiempo (tiempo de espera agotado).",
"tls": "Falló el protocolo TLS o SSL (problema de certificado o cifrado).",
"unknown": "No se recibió ninguna respuesta HTTP del destino."
}
}
},
"components": {
@@ -465,8 +459,7 @@
"optionPagespeed": "PageSpeed",
"optionHardware": "Infraestructura",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket",
"optionDns": "DNS"
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} monitor pausado",
@@ -476,8 +469,7 @@
"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",
"partialFailure": "Se actualizaron {{successCount}} monitores, pero {{failedCount}} fallaron al sincronizarse con la cola de trabajos"
"alreadyRunning_other": "Todos los monitores seleccionados ya se están ejecutando"
}
}
},
@@ -567,26 +559,6 @@
"captureUrl": {
"label": "Endpoint de Capture",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
},
"dnsRecordType": {
"label": "Tipo de registro DNS",
"value": {
"A": "A",
"AAAA": "AAAA",
"CNAME": "CNAME",
"MX": "MX",
"NS": "NS",
"TXT": "TXT"
}
},
"dnsServer": {
"label": "Servidor DNS",
"placeholder": "8.8.8.8"
},
"strategy": {
"desktop": "Escritorio",
"label": "Estrategia",
"mobile": "Móvil"
}
},
"title": "Configuración general",
@@ -599,8 +571,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'.",
"dns": "Monitorea si un servidor DNS específico responde."
"hardware": "Supervisa el uso de CPU, memoria, disco y temperatura de tu infraestructura. La URL del endpoint de Capture debe incluir la ruta completa /api/v1/metrics (p. ej., http://192.168.1.10:5001/api/v1/metrics). Para el secreto de autorización, pega solo la cadena del token — no incluyas 'Bearer'."
}
},
"ignoreTls": {
@@ -637,8 +608,7 @@
"optionHttpDescription": "Use HTTP(S) para monitorear su sitio web o endpoint de API.",
"optionPingDescription": "Use ICMP Ping para monitorear si un servidor está en línea.",
"optionPortDescription": "Monitoree si un puerto específico de un servidor está abierto.",
"title": "Tipo",
"optionDnsDescription": "Monitorea si un servidor DNS específico responde."
"title": "Tipo"
},
"thresholds": {
"title": "Umbrales de alerta",
@@ -941,10 +911,12 @@
"monitorId": "ID de monitor",
"runCount": "Conteo de ejecuciones",
"failCount": "Conteo de fallas",
"lastRunAt": "Última ejecución",
"lockedAt": "Bloqueado a las",
"lastFinishedAt": "Última finalización",
"lastRunTook": "Última ejecución tomó",
"lastFailedAt": "Última falla",
"failReason": "Razón de falla",
"state": "Estado"
"failReason": "Razón de falla"
}
},
"queueFallback": {
@@ -1134,10 +1106,6 @@
"header": {
"title": "Monitores de PageSpeed",
"description": "Sigue las Core Web Vitals y las puntuaciones de Lighthouse a lo largo del tiempo para detectar regresiones antes que tus usuarios."
},
"strategy": {
"desktop": "Escritorio",
"mobile": "Móvil"
}
},
"settings": {
@@ -1286,10 +1254,6 @@
},
"rejectUnauthorized": {
"label": "Rechazar certificados inválidos: Rechazar conexiones con certificados autofirmados o no confiables"
},
"displayName": {
"label": "Nombre del remitente (opcional) - Nombre amigable mostrado en el campo De",
"placeholder": "Notificaciones de Checkmate"
}
}
},
@@ -1350,20 +1314,6 @@
"uptime": {
"title": "Tiempo de actividad",
"responseTime": "Tiempo de respuesta"
},
"chart": {
"avg": "prom {{value}}ms",
"downTooltip": "Caído",
"heatmapAria": "Mapa de calor del tiempo de respuesta",
"max": "máx {{value}}ms"
},
"status": {
"breached": "Umbral superado",
"down": "Caído",
"initializing": "Inicializando",
"maintenance": "Mantenimiento",
"paused": "Pausado",
"up": "Operativo"
}
},
"statusBar": {
@@ -1381,9 +1331,7 @@
"maintenance": "Algunos sistemas están en mantenimiento",
"partiallyPaused": "Algunos sistemas están en pausa",
"initializing": "Algunos sistemas se están inicializando",
"noMonitors": "No se ha configurado ningún monitor",
"monitoringSummary": "Monitoreando {{count}} servicio · Actualizaciones cada 10 segundos",
"monitoringSummary_other": "Monitoreando {{count}} servicios · Actualizaciones cada 10 segundos"
"noMonitors": "No se ha configurado ningún monitor"
},
"table": {
"headers": {
@@ -1474,47 +1422,18 @@
"label": "Mostrar métricas de infraestructura"
}
}
},
"theme": {
"description": "Elige el estilo visual para tu página de estado pública.",
"label": "Tema",
"options": {
"bold": {
"description": "Tipografía grande y espaciado confiado. Ideal para páginas centradas en la marca.",
"name": "Audaz"
},
"editorial": {
"description": "Encabezados con serifa y divisores finos. Distintivo y tranquilo.",
"name": "Editorial"
},
"modern": {
"description": "Calidad SaaS con un punto de estado pulsante y tarjetas que se desvanecen suavemente.",
"name": "Moderno"
},
"refined": {
"description": "Limpio, profesional, cercano al aspecto clásico de Checkmate.",
"name": "Refinado"
}
},
"title": "Tema de la página"
},
"themeMode": {
"auto": "Automático (seguir dispositivo del visitante)",
"dark": "Solo oscuro",
"label": "Modo de tema",
"light": "Solo claro"
}
},
"header": {
"title": "Páginas de estado",
"description": "Publica páginas públicas que muestren a tus clientes y partes interesadas la disponibilidad en tiempo real y el historial de incidentes."
},
"editorial": {
"allUp": "Todos los sistemas funcionan con normalidad.",
"reportTitle": "Informe de servicio"
},
"footer": {
"poweredBy": "Desarrollado por"
"publicPage": {
"maintenance": {
"upcoming": "Mantenimiento programado",
"inProgress": "Mantenimiento en curso",
"completed": "Mantenimiento completado"
}
}
},
"uptime": {
+47 -142
View File
@@ -73,9 +73,7 @@
"paused": "keskeytetty",
"na": "Ei saatavilla",
"resolved": "Ratkaistu",
"responseTime": "Vasteaika",
"idle": "Toimeton",
"running": "Käynnissä"
"responseTime": "Vasteaika"
},
"form": {
"role": {
@@ -122,17 +120,6 @@
"active": "Aktiivinen",
"responseTime": "Vasteaika"
}
},
"statusCode": {
"down": "Alhaalla",
"networkError": {
"dns": "Isäntänimeä ei voitu selvittää (DNS-haku epäonnistui).",
"refused": "Palvelin hylkäsi yhteyden (portti suljettu tai palvelu ei kuuntele).",
"reset": "Yhteys nollattiin ennen vastauksen vastaanottamista.",
"timeout": "Palvelin ei vastannut ajoissa (pyynnön aikakatkaisu).",
"tls": "TLS- tai SSL-kättely epäonnistui (sertifikaatti- tai salausongelma).",
"unknown": "Kohteesta ei vastaanotettu HTTP-vastausta."
}
}
},
"components": {
@@ -319,28 +306,28 @@
"common": {
"passwordRules": {
"length": {
"beginning": "Vähintään",
"highlighted": "8 merkkiä pitkä"
"beginning": "",
"highlighted": ""
},
"lowercase": {
"beginning": "Sisällettävä vähintään",
"highlighted": "yksi pieni kirjain"
"beginning": "",
"highlighted": ""
},
"match": {
"beginning": "Salasanat",
"highlighted": "täytyy täsmätä"
"beginning": "",
"highlighted": ""
},
"number": {
"beginning": "Sisällettävä vähintään",
"highlighted": "yksi numero"
"beginning": "",
"highlighted": ""
},
"special": {
"beginning": "Sisällettävä vähintään",
"highlighted": "yksi erikoismerkki (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)"
"beginning": "",
"highlighted": ""
},
"uppercase": {
"beginning": "Sisällettävä vähintään",
"highlighted": "yksi iso kirjain"
"beginning": "",
"highlighted": ""
}
},
"form": {
@@ -465,8 +452,7 @@
"optionPagespeed": "PageSpeed",
"optionHardware": "Infrastruktuuri",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket",
"optionDns": "DNS"
"optionWebSocket": "WebSocket"
},
"bulkPause": {
"paused_one": "{{count}} näyttö keskeytetty",
@@ -476,8 +462,7 @@
"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ä",
"partialFailure": "{{successCount}} monitoria päivitettiin, mutta {{failedCount}} epäonnistui synkronoinnissa työjonon kanssa"
"alreadyRunning_other": "Kaikki valitut näytöt ovat jo käynnissä"
}
}
},
@@ -498,9 +483,9 @@
},
"matchMethod": {
"label": "Vertailutapa",
"equal": "Yhtä suuri",
"include": "Sisältää",
"regex": "Säännöllinen lauseke"
"equal": "",
"include": "",
"regex": ""
}
},
"title": "Lisäasetukset"
@@ -512,15 +497,15 @@
"label": "Tarkistusväli",
"value": {
"fifteenMinutes": "15 minuuttia",
"fifteenSeconds": "15 sekuntia",
"fifteenSeconds": "",
"fiveMinutes": "5 minuuttia",
"fourMinutes": "4 minuuttia",
"oneMinute": "1 minuutti",
"fourMinutes": "",
"oneMinute": "",
"tenMinutes": "10 minuuttia",
"thirtyMinutes": "30 minuuttia",
"thirtySeconds": "30 sekuntia",
"threeMinutes": "3 minuuttia",
"twoMinutes": "2 minuuttia"
"thirtySeconds": "",
"threeMinutes": "",
"twoMinutes": ""
}
}
},
@@ -549,8 +534,8 @@
"placeholder": "https://www.google.com"
},
"game": {
"label": "Valitse peli",
"placeholder": "Valitse peli"
"label": "",
"placeholder": ""
},
"port": {
"label": "Monitoroitava portti",
@@ -567,40 +552,19 @@
"captureUrl": {
"label": "Capture-päätepiste",
"placeholder": "http://192.168.1.10:5001/api/v1/metrics"
},
"dnsRecordType": {
"label": "DNS-tietuetyyppi",
"value": {
"A": "A",
"AAAA": "AAAA",
"CNAME": "CNAME",
"MX": "MX",
"NS": "NS",
"TXT": "TXT"
}
},
"dnsServer": {
"label": "DNS-palvelin",
"placeholder": "8.8.8.8"
},
"strategy": {
"desktop": "Työpöytä",
"label": "Strategia",
"mobile": "Mobiili"
}
},
"title": "Yleiset asetukset",
"description": {
"http": "Syötä monitoroitava URL tai IP (esim. https://example.com/ tai 192.168.1.100) ja lisää selkeä näyttönimi, joka näkyy hallintapaneelissa.",
"ping": "Anna pingattava IP-osoite tai isäntänimi (esim. 192.168.1.100 tai example.com) ja lisää selkeä näyttönimi, joka näkyy hallintapaneelissa.",
"port": "Anna palvelimen URL tai IP, portin numero ja selkeä näyttönimi, joka näkyy hallintapaneelissa.",
"docker": "Anna Docker-kontin nimi tai tunnus. Voit käyttää joko kontin nimeä (esim. my-app) tai kontin tunnusta (täysi 64-merkkinen tunnus tai lyhyt tunnus).",
"ping": "",
"port": "",
"docker": "",
"game": "Anna IP-osoite tai isäntänimi ja portin numero pingausta varten (esim. 192.168.1.100 tai example.com) ja valitse pelityyppi. Jotkin pelit vaativat palvelinkyselyiden käyttöönoton asetustiedostossaan — tarkista pelipalvelimesi dokumentaatio. Katso protokollatiedot <gamedigLink>tuettujen pelien luettelosta</gamedigLink>.",
"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'.",
"dns": "Tarkkaile, vastaako tietty DNS-palvelin."
"hardware": "Seuraa infrastruktuurisi suorittimen, muistin, levyn käyttöä ja lämpötilaa. Capture-päätepisteen URL-osoitteen on sisällettävä koko polku /api/v1/metrics (esim. http://192.168.1.10:5001/api/v1/metrics). Liitä valtuutusavaimeen vain tokenin merkkijono — älä sisällytä sanaa 'Bearer'."
}
},
"ignoreTls": {
@@ -637,8 +601,7 @@
"optionHttpDescription": "Käytä HTTP(S)-protokollaa verkkosivustosi tai API-päätepisteen monitorointiin.",
"optionPingDescription": "Käytä ICMP Ping -komentoa monitoroimaan, onko palvelin verkossa.",
"optionPortDescription": "Monitoroi, onko tietty palvelimen portti auki.",
"title": "Tyyppi",
"optionDnsDescription": "Tarkkaile, vastaako tietty DNS-palvelin."
"title": "Tyyppi"
},
"thresholds": {
"title": "Hälytysrajat",
@@ -802,7 +765,7 @@
"headers": {
"endTime": "Päättymisaika",
"resolutionType": "Ratkaisun tyyppi",
"startTime": "Aloitusaika",
"startTime": "",
"statusCode": "Tilakoodi"
},
"resolvedIncidents": "Ratkaistut häiriöt",
@@ -846,13 +809,13 @@
}
},
"statBoxes": {
"avgCpuTemperature": "Keskimääräinen CPU-lämpötila",
"cpuFrequency": "CPU-taajuus",
"cpuLogical": "CPU (looginen)",
"cpuPhysical": "CPU (fyysinen)",
"avgCpuTemperature": "",
"cpuFrequency": "",
"cpuLogical": "",
"cpuPhysical": "",
"disk": "Levy",
"memory": "Muisti",
"os": "Käyttöjärjestelmä"
"os": ""
},
"table": {
"headers": {
@@ -863,7 +826,7 @@
},
"tabs": {
"labels": {
"network": "Verkko",
"network": "",
"overview": "Yleiskatsaus"
}
},
@@ -941,10 +904,12 @@
"monitorId": "Monitorin tunnus",
"runCount": "Suorituskerrat",
"failCount": "Epäonnistumiskerrat",
"lastRunAt": "Viimeisin suoritus",
"lockedAt": "Lukittu",
"lastFinishedAt": "Viimeisin valmistuminen",
"lastRunTook": "Viimeisin suoritus kesti",
"lastFailedAt": "Viimeisin epäonnistuminen",
"failReason": "Epäonnistumisen syy",
"state": "Tila"
"failReason": "Epäonnistumisen syy"
}
},
"queueFallback": {
@@ -964,8 +929,8 @@
},
"table": {
"headers": {
"nextWindow": "Seuraava ikkuna",
"repeat": "Toista"
"nextWindow": "",
"repeat": ""
}
},
"form": {
@@ -1067,7 +1032,7 @@
},
"telegram": {
"title": "Telegram-määritykset",
"description": "Määritä Telegram-bottisi lähettämään ilmoituksia.",
"description": "",
"optionBotToken": "Bottitunniste",
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "Chat-tunnus",
@@ -1134,10 +1099,6 @@
"header": {
"title": "PageSpeed-monitorit",
"description": "Seuraa Core Web Vitals -mittareita ja Lighthouse-pisteitä ajan mittaan ja huomaa heikennykset ennen käyttäjiä."
},
"strategy": {
"desktop": "Työpöytä",
"mobile": "Mobiili"
}
},
"settings": {
@@ -1240,7 +1201,7 @@
"email": {
"title": "Sähköpostiasetukset",
"description": "Määritä järjestelmäsi sähköpostiasetukset. Niitä käytetään ilmoitusten ja hälytysten lähettämiseen.",
"descriptionTransport": "Tämä rakentaa SMTP-kuljetuksen NodeMailerille",
"descriptionTransport": "",
"descriptionTransportLink": "Katso tekniset tiedot täältä",
"option": {
"host": {
@@ -1286,10 +1247,6 @@
},
"rejectUnauthorized": {
"label": "Hylkää virheelliset varmenteet: Hylkää yhteydet itseallekirjoitetuilla tai ei-luotetuilla varmenteilla"
},
"displayName": {
"label": "Lähettäjän nimi (valinnainen) - Lähettäjäkentässä näkyvä ystävällinen nimi",
"placeholder": "Checkmate-ilmoitukset"
}
}
},
@@ -1350,20 +1307,6 @@
"uptime": {
"title": "Käytettävyys",
"responseTime": "Vasteaika"
},
"chart": {
"avg": "ka {{value}}ms",
"downTooltip": "Alhaalla",
"heatmapAria": "Vasteajan lämpökartta",
"max": "max {{value}}ms"
},
"status": {
"breached": "Kynnys ylittyi",
"down": "Alhaalla",
"initializing": "Alustetaan",
"maintenance": "Huolto",
"paused": "Keskeytetty",
"up": "Toiminnassa"
}
},
"statusBar": {
@@ -1381,9 +1324,7 @@
"maintenance": "Jotkin järjestelmät ovat huollossa",
"partiallyPaused": "Jotkin järjestelmät on keskeytetty",
"initializing": "Jotkin järjestelmät alustuvat",
"noMonitors": "Yhtään monitoria ei ole määritetty",
"monitoringSummary": "Tarkkaillaan {{count}} palvelua · Päivitykset 10 sekunnin välein",
"monitoringSummary_other": "Tarkkaillaan {{count}} palvelua · Päivitykset 10 sekunnin välein"
"noMonitors": "Yhtään monitoria ei ole määritetty"
},
"table": {
"headers": {
@@ -1474,47 +1415,11 @@
"label": "Näytä infrastruktuurin mittarit"
}
}
},
"theme": {
"description": "Valitse julkisen tilasivusi visuaalinen tyyli.",
"label": "Teema",
"options": {
"bold": {
"description": "Suuri typografia ja itsevarma välistys. Sopii brändilähtöisille sivuille.",
"name": "Rohkea"
},
"editorial": {
"description": "Antiikvaotsikot ja ohuet erottimet. Erottuva ja rauhallinen.",
"name": "Toimituksellinen"
},
"modern": {
"description": "SaaS-tasoa sykkivällä tilapisteellä ja pehmeästi haalistuvilla korteilla.",
"name": "Moderni"
},
"refined": {
"description": "Siisti, ammattimainen, lähellä klassista Checkmate-ilmettä.",
"name": "Hienostunut"
}
},
"title": "Sivun teema"
},
"themeMode": {
"auto": "Automaattinen (seuraa vierailijan laitetta)",
"dark": "Vain tumma",
"label": "Teematila",
"light": "Vain vaalea"
}
},
"header": {
"title": "Tilasivut",
"description": "Julkaise julkisia sivuja, jotka näyttävät asiakkaille ja sidosryhmille reaaliaikaisen käytettävyyden ja tapaushistorian."
},
"editorial": {
"allUp": "Kaikki järjestelmät toimivat normaalisti.",
"reportTitle": "Palveluraportti"
},
"footer": {
"poweredBy": "Tarjoaa"
}
},
"uptime": {
Loaded 100 of 478 files, more files were not shown because too many files have changed in this diff. Show more