diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 index dac57bbf1..efabe6826 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,23 @@ #!/bin/sh + +# Skip during merge/rebase/cherry-pick/revert. lint-staged's internal +# git-stash mechanism is incompatible with these states and will silently +# wipe MERGE_HEAD and your conflict resolutions on commit failure. +git_dir=$(git rev-parse --git-dir) +if [ -e "$git_dir/MERGE_HEAD" ] \ + || [ -e "$git_dir/CHERRY_PICK_HEAD" ] \ + || [ -e "$git_dir/REVERT_HEAD" ] \ + || [ -d "$git_dir/rebase-merge" ] \ + || [ -d "$git_dir/rebase-apply" ]; then + echo "Skipping lint-staged (mid merge/rebase/cherry-pick/revert)" + exit 0 +fi + +# Anchor to repo root so commits made from a subdirectory still work, and +# run each package's lint-staged in a subshell so cwd is scoped (a failure +# inside one package can't poison the cwd for the next). +cd "$(git rev-parse --show-toplevel)" || exit 1 + echo "Running lint-staged..." -cd client && npx lint-staged && cd .. -cd server && npx lint-staged && cd .. \ No newline at end of file +(cd client && npx lint-staged) || exit 1 +(cd server && npx lint-staged) || exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index c5f16d3a9..63a349ae7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,14 +159,26 @@ When working on anything related to check scheduling, incident lifecycle, or not ## Code Conventions -### Frontend conventions (mandatory for `client/src`) -Read `docs/frontend-conventions.md` before touching any `.tsx` file. Five rules, all enforced in code review: +### Coding conventions (mandatory) +Read `docs/coding-conventions.md` before touching any `.tsx` or `.ts` file. The doc covers both frontend and backend rules, all enforced in code review. + +**Universal — rule 0:** look at peer files first and follow the established pattern. Don't sneak in novel shapes. + +**Frontend (`client/src`):** 1. Prefer MUI native props over `sx` (e.g. `color={…}`, `bgcolor={…}`, `mt={…}` — not `sx={{ color, bgcolor, mt }}`). 2. Use the full theme path for colors: `color={theme.palette.text.secondary}`, never `color="text.secondary"` (greppability). 3. No hardcoded literals — use `LAYOUT.*`, `typographyLevels.*`, `theme.shape.borderRadius`, `theme.palette.*`. 4. Use `useTheme()` inside components; don't `import { theme } from "@/Utils/Theme/Theme"`. 5. Pair runtime tuples with derived types: `const X = [...] as const; type X = (typeof X)[number]`. +**Backend (`server/src`):** +6. Layering: Controller → Service → Repository → Mongoose. No DB calls outside repositories. +7. Provider conventions: DI for stdlib clients, `SERVICE_NAME` + `TIMEOUT_MS` constants, `timeRequest()` helper, inline `setTimeout` race for timeout, outer try/catch into `AppError`. +8. Mongoose schema fields with closed value sets must declare `enum` reusing the same `types/*` const tuple. +9. Provider tests at `server/test/unit/providers/network/.test.ts`, `from "@jest/globals"`, `testStatusProviderContract`, inline `setup()` per test (not `beforeEach`). +10. Centralize validation enums in `types/*` const tuples; never inline `z.enum([…])` more than once. +11. New entity fields must land in the validator's `*ResponseSchema` so the auto-generated OpenAPI spec sees them. + ### Internationalization All user-facing strings must use the translation function: ```javascript diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17ad26903..c2e536da5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,6 +151,7 @@ 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: diff --git a/README.es.md b/README.es.md deleted file mode 100644 index 92237ea3c..000000000 --- a/README.es.md +++ /dev/null @@ -1,172 +0,0 @@ -

bluewave-labs%2Fcheckmate | Trendshift

- -![](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) - -

Checkmate

- -

Una aplicación de código abierto para monitoreo de infraestructura y tiempo de actividad

- -image - -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 - -

-image -

-

- image -

-

-image -

-

-image -

- -## 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. - - - - - -[![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. diff --git a/README.md b/README.md index 3f32f1b60..c6830f566 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,25 @@

bluewave-labs%2Fcheckmate | Trendshift

- + +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ ![](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) @@ -7,7 +27,6 @@ ![](https://img.shields.io/github/languages/top/bluewave-labs/checkmate) ![](https://img.shields.io/github/issues/bluewave-labs/checkmate) ![](https://img.shields.io/github/issues-pr/bluewave-labs/checkmate) -[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9901/badge)](https://www.bestpractices.dev/projects/9901) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate)

Checkmate

@@ -16,7 +35,10 @@ [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) -image +image + +
+ This repository contains both the frontend and the backend of Checkmate, an open-source, self-hosted monitoring tool for tracking server hardware, uptime, response times, and incidents in real-time with beautiful visualizations. Checkmate regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time. @@ -24,8 +46,6 @@ Checkmate also has an agent, called [Capture](https://github.com/bluewave-labs/c Checkmate has been stress-tested with 1000+ active monitors without any particular issues or performance bottlenecks. -**If you would like to sponsor a feature, [see this link](https://checkmate.so/sponsored-features).** - ## 📚 Table of contents - [📦 Demo](#demo) @@ -38,15 +58,16 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul - [🏗️ Tech stack](#tech-stack) - [🔗 A few links](#a-few-links) - [🤝 Contributing](#contributing) -- [💰 Our sponsors](#our-sponsors) + ## Demo 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). + ## User's guide Usage instructions can be found [here](https://checkmate.so/docs). @@ -55,6 +76,7 @@ Usage instructions can be found [here](https://checkmate.so/docs). - [Docker](https://www.docker.com/) installed - [Git](https://git-scm.com/) installed + ## Installation See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs). @@ -67,6 +89,7 @@ If you need to monitor internal HTTPS endpoints with certificates from private C For more documentation, see the [docs directory](./docs/). + ## 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: @@ -77,6 +100,7 @@ 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) + ## Questions & Ideas If you have any questions, suggestions or comments, you have several options: @@ -86,33 +110,21 @@ 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! + ## Features - Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5) -- Website monitoring +- Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server - Page speed monitoring - Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent - Selective disk monitoring with mountpoint selection -- Docker monitoring -- Ping monitoring -- SSL monitoring -- Port monitoring -- Game server monitoring (3.0) - Incidents at a glance -- Status pages -- E-mail, Webhooks, Discord and Slack notifications +- Status pages with 4 beautiful themes +- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications - Scheduled maintenance - JSON query monitoring -- Multi-language support for English, German, Japanese, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, Chinese (Traditional, Taiwan) +- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese -**Short term roadmap:** - -- Plugins that will help Checkmate get any information from a remote service (e.g database, etc) -- Better notifications -- Network monitoring -- ..and a few more features - -If you would like to sponsor an additional feature, [see this page](https://checkmate.so/sponsored-features). ## Monitor Lifecycle @@ -123,6 +135,7 @@ If you would like to sponsor an additional feature, [see this page](https://chec 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 + ## Screenshots

@@ -140,6 +153,7 @@ If you would like to sponsor an additional feature, [see this page](https://chec + ## Tech stack - [ReactJs](https://react.dev/) @@ -149,6 +163,7 @@ If you would like to sponsor an additional feature, [see this page](https://chec - [Recharts](https://recharts.org) - Lots of other open source components! + ## A few links - If you would like to support us, please consider giving it a ⭐ and click on "watch". @@ -156,6 +171,7 @@ If you would like to sponsor an additional feature, [see this page](https://chec - 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) + ## 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. diff --git a/client/package-lock.json b/client/package-lock.json index 6210918f4..520250c26 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "3.7.1", + "version": "3.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "3.7.1", + "version": "3.8.0", "dependencies": { "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", @@ -18,7 +18,7 @@ "@mui/x-date-pickers": "7.29.4", "@reduxjs/toolkit": "2.7.0", "@types/maplibre-gl": "^1.13.2", - "axios": "^1.7.4", + "axios": "^1.15.2", "dayjs": "1.11.13", "html2canvas": "^1.4.1", "human-interval": "2.0.1", @@ -57,9 +57,10 @@ "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.1" + "vite": "^6.4.2" } }, "node_modules/@babel/code-frame": { @@ -84,6 +85,7 @@ "node_modules/@babel/core": { "version": "7.29.0", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -354,6 +356,7 @@ "node_modules/@emotion/react": { "version": "11.14.0", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -391,6 +394,7 @@ "node_modules/@emotion/styled": { "version": "11.14.1", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1107,6 +1111,16 @@ "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", @@ -1160,6 +1174,7 @@ "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", @@ -1264,6 +1279,7 @@ "node_modules/@mui/system": { "version": "7.3.7", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/private-theming": "^7.3.7", @@ -2304,6 +2320,7 @@ "version": "24.5.2", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.12.0" } @@ -2319,6 +2336,7 @@ "node_modules/@types/react": { "version": "18.3.27", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2352,6 +2370,23 @@ "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", @@ -2392,6 +2427,7 @@ "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", @@ -2672,6 +2708,7 @@ "version": "8.15.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2925,14 +2962,14 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-plugin-macros": { @@ -3006,6 +3043,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3020,6 +3058,16 @@ "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", @@ -3239,9 +3287,9 @@ } }, "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -3437,7 +3485,8 @@ }, "node_modules/dayjs": { "version": "1.11.13", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -3832,6 +3881,7 @@ "version": "8.57.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4191,12 +4241,16 @@ } }, "node_modules/flatted": { - "version": "3.3.3", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -4623,6 +4677,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -5218,6 +5273,16 @@ "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", @@ -5313,7 +5378,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -5406,6 +5473,7 @@ "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", @@ -5445,6 +5513,13 @@ "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", @@ -5470,9 +5545,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -5534,6 +5609,109 @@ "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" @@ -5881,7 +6059,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -5912,7 +6092,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "funding": [ { "type": "opencollective", @@ -6004,14 +6186,19 @@ "license": "MIT" }, "node_modules/protocol-buffers-schema": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", - "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "license": "MIT" }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -6063,6 +6250,7 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6074,6 +6262,7 @@ "node_modules/react-hook-form": { "version": "7.71.1", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6273,7 +6462,8 @@ }, "node_modules/redux": { "version": "5.0.1", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-persist": { "version": "6.0.0", @@ -6423,6 +6613,7 @@ "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" }, @@ -6698,6 +6889,13 @@ "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", @@ -6808,6 +7006,16 @@ "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", @@ -7121,6 +7329,19 @@ "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", @@ -7234,6 +7455,7 @@ "version": "5.9.3", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7365,10 +7587,11 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "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", @@ -7457,6 +7680,30 @@ "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, @@ -7648,9 +7895,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "devOptional": true, "license": "ISC", "bin": { diff --git a/client/package.json b/client/package.json index 041b1ef86..2b3e25080 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "3.7.1", + "version": "3.8.0", "type": "module", "scripts": { "dev": "vite", @@ -27,7 +27,7 @@ "@mui/x-date-pickers": "7.29.4", "@reduxjs/toolkit": "2.7.0", "@types/maplibre-gl": "^1.13.2", - "axios": "^1.7.4", + "axios": "^1.15.2", "dayjs": "1.11.13", "html2canvas": "^1.4.1", "human-interval": "2.0.1", @@ -66,8 +66,9 @@ "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.1" + "vite": "^6.4.2" } } diff --git a/client/src/Components/common/charts/HeatmapResponseTime.tsx b/client/src/Components/common/charts/HeatmapResponseTime.tsx index f2aed0b7a..9beb92f37 100644 --- a/client/src/Components/common/charts/HeatmapResponseTime.tsx +++ b/client/src/Components/common/charts/HeatmapResponseTime.tsx @@ -5,7 +5,7 @@ import { getResponseColor } from "@/Utils/DataUtils"; import { HeatmapResponseTimeTooltip } from "@/Components/common/charts/HeatmapResponseTimeTooltip"; import type { SxProps } from "@mui/material/styles"; import type { ResponsiveStyleValue } from "@mui/system"; - +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; interface PlaceholderCheck { status: "placeholder"; responseTime: 0; @@ -32,11 +32,11 @@ export const HeatmapResponseTime = ({ if (!checks || checks.length === 0) return null; - const latestChecks = checks.slice(-25); + const latestChecks = checks.slice(-MAX_RECENT_CHECKS); let data: Array; - if (latestChecks.length !== 25) { - const placeholders = Array(25 - latestChecks.length).fill({ + if (latestChecks.length !== MAX_RECENT_CHECKS) { + const placeholders = Array(MAX_RECENT_CHECKS - latestChecks.length).fill({ status: "placeholder" as const, }); data = [...latestChecks, ...placeholders]; @@ -44,13 +44,13 @@ export const HeatmapResponseTime = ({ data = latestChecks; } return ( - + @@ -82,50 +82,50 @@ export const HeatmapResponseTime = ({ check={check} > diff --git a/client/src/Components/common/charts/HistogramResponseTime.tsx b/client/src/Components/common/charts/HistogramResponseTime.tsx index 1ef400daf..d9485318a 100644 --- a/client/src/Components/common/charts/HistogramResponseTime.tsx +++ b/client/src/Components/common/charts/HistogramResponseTime.tsx @@ -7,6 +7,7 @@ import type { CheckSnapshot } from "@/Types/Check"; import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; interface HistogramResponseTimeProps { checks: CheckSnapshot[]; @@ -34,7 +35,7 @@ const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats } const responseTimes = validChecks.map((check) => check.originalResponseTime); - const max = Math.max(...responseTimes); + const max = Math.round(Math.max(...responseTimes)); const avg = Math.round( responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length ); @@ -57,8 +58,11 @@ export const HistogramResponseTime = ({ if (!Array.isArray(checks) || checks.length === 0) return null; const data = - checks.length !== 25 - ? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })] + checks.length !== MAX_RECENT_CHECKS + ? [ + ...checks, + ...Array(MAX_RECENT_CHECKS - checks.length).fill({ status: "placeholder" }), + ] : checks; const chartHeight = typeof height === "number" ? `${height}px` : height; @@ -69,14 +73,20 @@ export const HistogramResponseTime = ({ - + {t("common.charts.histogram.avg", { value: stats.avg })} - + {t("common.charts.histogram.max", { value: stats.max })} @@ -86,17 +96,17 @@ export const HistogramResponseTime = ({ {data.map((check, index) => { @@ -108,23 +118,23 @@ export const HistogramResponseTime = ({ : theme.palette.error.light; const bar = ( @@ -133,8 +143,8 @@ export const HistogramResponseTime = ({ return isPlaceholder ? ( {bar} diff --git a/client/src/Components/design-elements/Gauge.tsx b/client/src/Components/design-elements/Gauge.tsx index a377a5925..4130f67a7 100644 --- a/client/src/Components/design-elements/Gauge.tsx +++ b/client/src/Components/design-elements/Gauge.tsx @@ -1,30 +1,36 @@ import { BaseChart } from "@/Components/design-elements"; import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; +import Box, { type BoxProps } from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; -import { useMemo, useState, useEffect } from "react"; +import { forwardRef, useMemo, useState, useEffect } from "react"; import { getInfraGaugeColor } from "@/Utils/MonitorUtils"; const MINIMUM_VALUE = 0; const MAXIMUM_VALUE = 100; -export const Gauge = ({ - isLoading = false, - progress = 0, - radius = 70, - strokeWidth = 15, - precision = 1, - unit = "%", -}: { +interface GaugeProps extends Omit { isLoading?: boolean; progress?: number; radius?: number; strokeWidth?: number; precision?: number; unit?: string; -}) => { +} + +export const Gauge = forwardRef(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)); @@ -51,17 +57,19 @@ export const Gauge = ({ const fillColor = getInfraGaugeColor(progressWithinRange, theme); if (isLoading) { - return; + return null; } return ( ); -}; +}); export const DetailGauge = ({ title, diff --git a/client/src/Components/design-elements/MonitorStatus.tsx b/client/src/Components/design-elements/MonitorStatus.tsx index 14f33d934..2d53470f0 100644 --- a/client/src/Components/design-elements/MonitorStatus.tsx +++ b/client/src/Components/design-elements/MonitorStatus.tsx @@ -1,12 +1,13 @@ import type { Monitor } from "@/Types/Monitor"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import { PulseDot, Dot } from "@/Components/design-elements"; +import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements"; import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils"; import { useTheme } from "@mui/material/styles"; import prettyMilliseconds from "pretty-ms"; import { typographyLevels } from "@/Utils/Theme/Palette"; import useMediaQuery from "@mui/material/useMediaQuery"; +import { LAYOUT } from "@/Utils/Theme/constants"; export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); @@ -28,7 +29,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { { > {formatUrl(monitor?.url)} + {monitor.type === "pagespeed" && monitor.strategy && ( + <> + + + + )} {!isSmall && ( <> diff --git a/client/src/Components/design-elements/StatusCodeLabel.tsx b/client/src/Components/design-elements/StatusCodeLabel.tsx new file mode 100644 index 000000000..f088550f0 --- /dev/null +++ b/client/src/Components/design-elements/StatusCodeLabel.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from "react-i18next"; +import { + formatStatusCode, + getStatusCodeTooltip, + getStatusCodeValueType, +} from "@/Utils/statusCode"; +import { ValueLabel } from "./StatusLabel"; +import { Tooltip } from "./Tooltip"; + +export type StatusCodeLabelProps = { + statusCode: number | null | undefined; + message?: string | null; + fallback?: string; +}; + +export const StatusCodeLabel = ({ + statusCode, + message, + fallback = "N/A", +}: StatusCodeLabelProps) => { + const { t } = useTranslation(); + if (!statusCode) { + return <>{fallback}; + } + + const text = formatStatusCode(statusCode, t); + const tooltip = getStatusCodeTooltip(statusCode, message, t); + const body = ( + + ); + if (!tooltip) return body; + return ( + + {body} + + ); +}; diff --git a/client/src/Components/design-elements/StatusLabel.tsx b/client/src/Components/design-elements/StatusLabel.tsx index d50058378..c3fd089d7 100644 --- a/client/src/Components/design-elements/StatusLabel.tsx +++ b/client/src/Components/design-elements/StatusLabel.tsx @@ -93,3 +93,20 @@ export const ValueLabel = ({ value, text }: { value: ValueType; text: string }) ); }; + +export const ColoredLabel = ({ text, color }: { text: string; color: string }) => { + const theme = useTheme(); + + return ( + + {text} + + ); +}; diff --git a/client/src/Components/design-elements/StepProgress.tsx b/client/src/Components/design-elements/StepProgress.tsx new file mode 100644 index 000000000..33a49b15d --- /dev/null +++ b/client/src/Components/design-elements/StepProgress.tsx @@ -0,0 +1,42 @@ +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 ( + + {Array.from({ length: steps }).map((_, index) => ( + + ))} + + ); +}; diff --git a/client/src/Components/design-elements/StrategyBadge.tsx b/client/src/Components/design-elements/StrategyBadge.tsx new file mode 100644 index 000000000..cb06d17c1 --- /dev/null +++ b/client/src/Components/design-elements/StrategyBadge.tsx @@ -0,0 +1,32 @@ +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import type { PageSpeedStrategy } from "@/Types/Monitor"; +import { SPACING } from "@/Utils/Theme/constants"; + +interface StrategyBadgeProps { + strategy: PageSpeedStrategy | undefined; + variant?: "caption" | "body2"; +} + +export const StrategyBadge = ({ strategy, variant = "body2" }: StrategyBadgeProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + + if (!strategy) { + return null; + } + + return ( + + {t(`pages.pageSpeed.strategy.${strategy}`)} + + ); +}; diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index 91bb69615..a02f9be8f 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -35,6 +35,9 @@ import useMediaQuery from "@mui/material/useMediaQuery"; export type Header = { id: number | string; content: React.ReactNode; + mobileLabel?: React.ReactNode; + hideMobileLabel?: boolean; + align?: "left" | "center" | "right" | "justify" | "inherit"; onClick?: (event: React.MouseEvent, row: T) => void; render: (row: T) => React.ReactNode; }; @@ -109,6 +112,24 @@ export function DataTable< key={key} > {headers.map((header) => { + if (header.hideMobileLabel) { + return ( + + + {header.render(row)} + + + ); + } + return ( - {header.content} + {header.mobileLabel ?? header.content} { return ( {header.content} @@ -222,7 +243,7 @@ export function DataTable< {headers.map((header, index) => { return ( header.onClick!(e, row) : undefined diff --git a/client/src/Components/design-elements/Tooltip.tsx b/client/src/Components/design-elements/Tooltip.tsx index f9bbbb92b..36ba84f48 100644 --- a/client/src/Components/design-elements/Tooltip.tsx +++ b/client/src/Components/design-elements/Tooltip.tsx @@ -6,7 +6,11 @@ import type { TooltipProps } from "@mui/material/Tooltip"; type StyledTooltipProps = TooltipProps; -export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) => { +export const Tooltip = ({ + placement = "top", + slotProps, + ...props +}: StyledTooltipProps) => { const theme = useTheme(); return ( @@ -27,6 +31,7 @@ export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) => boxShadow: theme.shadows[6], }, }, + ...slotProps, }} > {props.children} diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index f6e2771ec..d7d38d014 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -25,3 +25,6 @@ export * from "./TextLink"; export * from "./NoticeBanner"; export * from "./OfflineBanner"; export * from "./Avatar"; +export * from "./StatusCodeLabel"; +export * from "./StrategyBadge"; +export * from "./StepProgress"; diff --git a/client/src/Components/inputs/AutoComplete.tsx b/client/src/Components/inputs/AutoComplete.tsx index c98e8ff32..4822a805f 100644 --- a/client/src/Components/inputs/AutoComplete.tsx +++ b/client/src/Components/inputs/AutoComplete.tsx @@ -14,17 +14,19 @@ type AutoCompleteInputProps = Omit< renderInput?: AutocompleteProps["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) => ( option.id} renderTags={() => null} - renderOption={(props, option, { selected }) => { - const { key, ...optionProps } = props; - return ( - - { + const { key, ...optionProps } = props; + return ( + - {multiple && } - {option.name} - - - ); - }} + + {multiple && } + {renderOptionContent ? renderOptionContent(option) : option.name} + + + ); + }) + } sx={{ "&.MuiAutocomplete-root .MuiAutocomplete-input": { padding: `0 ${theme.spacing(5)}`, diff --git a/client/src/Components/inputs/Checkbox.tsx b/client/src/Components/inputs/Checkbox.tsx index 195a796dd..f86d0d1fe 100644 --- a/client/src/Components/inputs/Checkbox.tsx +++ b/client/src/Components/inputs/Checkbox.tsx @@ -1,7 +1,7 @@ import { forwardRef } from "react"; import Checkbox from "@mui/material/Checkbox"; import type { CheckboxProps } from "@mui/material/Checkbox"; -import { Square, SquareCheck } from "lucide-react"; +import { Square, SquareCheck, SquareMinus } from "lucide-react"; import { useTheme } from "@mui/material/styles"; type CheckboxInputProps = CheckboxProps & { @@ -34,9 +34,15 @@ export const CheckboxInput = forwardRef( strokeWidth={1.5} /> } + indeterminateIcon={ + + } sx={{ color: theme.palette.text.secondary, - "&.Mui-checked": { + "&.Mui-checked, &.MuiCheckbox-indeterminate": { color: theme.palette.primary.main, }, "&:hover": { backgroundColor: "transparent" }, diff --git a/client/src/Components/inputs/LanguageSelector.tsx b/client/src/Components/inputs/LanguageSelector.tsx index 430cd28b5..a83f3e0fa 100644 --- a/client/src/Components/inputs/LanguageSelector.tsx +++ b/client/src/Components/inputs/LanguageSelector.tsx @@ -10,6 +10,7 @@ import type { RootState } from "@/Types/state"; export const languageNames: Record = { en: "English", de: "Deutsch", + ca: "Català", es: "Español", fr: "Français", "pt-BR": "Português (BR)", diff --git a/client/src/Components/monitors/BulkActionsBar.tsx b/client/src/Components/monitors/BulkActionsBar.tsx new file mode 100644 index 000000000..aa520745f --- /dev/null +++ b/client/src/Components/monitors/BulkActionsBar.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Paper from "@mui/material/Paper"; +import Slide from "@mui/material/Slide"; +import IconButton from "@mui/material/IconButton"; +import { X } from "lucide-react"; + +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { useSidebar } from "@/Hooks/useSidebar"; + +interface BulkActionsBarProps { + selectedCount: number; + onCancel: () => void; + children?: React.ReactNode; +} + +export const BulkActionsBar = ({ + selectedCount, + onCancel, + children, +}: BulkActionsBarProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + const isOpen = selectedCount > 0; + + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const { width, collapsedWidth, transition } = useSidebar(); + + return ( + + + + + + {t("pages.common.monitors.actions.bulkSelected", { + count: selectedCount, + })} + + + + + + + {children} + + + + + ); +}; diff --git a/client/src/Components/monitors/ControlsFilter.tsx b/client/src/Components/monitors/ControlsFilter.tsx index a617d454c..196ef2042 100644 --- a/client/src/Components/monitors/ControlsFilter.tsx +++ b/client/src/Components/monitors/ControlsFilter.tsx @@ -1,9 +1,11 @@ import Stack from "@mui/material/Stack"; -import { Select, Button } from "@/Components/inputs"; +import { Select, Autocomplete, Button, TextField } from "@/Components/inputs"; +import { ColoredLabel } from "@/Components/design-elements"; 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"; @@ -28,22 +30,34 @@ export const ControlsFilter = ({ setSelectedStatus, selectedState, setSelectedState, + tagOptions, + selectedTags, + setSelectedTags, onClearFilters, }: { showTypes?: boolean; selectedTypes?: MonitorType[]; setSelectedTypes?: React.Dispatch>; - selectedStatus: string; - setSelectedStatus: React.Dispatch>; - selectedState: string; - setSelectedState: React.Dispatch>; + selectedStatus?: string; + setSelectedStatus?: React.Dispatch>; + selectedState?: string; + setSelectedState?: React.Dispatch>; + tagOptions?: Tag[]; + selectedTags?: string[]; + setSelectedTags?: React.Dispatch>; onClearFilters: () => void; }) => { const theme = useTheme(); const { t } = useTranslation(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isFilterActive = - (selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== ""; + (selectedTypes?.length ?? 0) > 0 || + Boolean(selectedStatus) || + Boolean(selectedState) || + (selectedTags?.length ?? 0) > 0; + const selectedTagObjects = (tagOptions ?? []).filter((tag) => + (selectedTags ?? []).includes(tag.id) + ); return ( )} - - + {setSelectedStatus && ( + + )} + {setSelectedState && ( + + )} + {tagOptions && setSelectedTags && ( + option.name} + onChange={(_: unknown, newValue: Tag[]) => { + setSelectedTags(newValue.map((tag) => tag.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderOptionContent={(option) => ( + + )} + renderInput={(params) => ( + + )} + slotProps={{ + popper: { + sx: { width: "fit-content !important", minWidth: 280 }, + }, + }} + sx={{ minWidth: 180 }} + /> + )} {isFilterActive && ( + {!isEditMode && ( + + )} + {!isEditMode && currentStep < totalSteps - 1 ? ( + + ) : ( + + )}

{ if (!incident) { return null; } + return ( { {t("pages.incidents.dialog.details.statusCode")} - {incident.statusCode ?? "N/A"} + + + {t("pages.incidents.dialog.details.downtime")} diff --git a/client/src/Pages/Incidents/Components/IncidentTable.tsx b/client/src/Pages/Incidents/Components/IncidentTable.tsx index bb1759dcd..399f4d8f8 100644 --- a/client/src/Pages/Incidents/Components/IncidentTable.tsx +++ b/client/src/Pages/Incidents/Components/IncidentTable.tsx @@ -1,8 +1,6 @@ -import { Table } from "@/Components/design-elements"; +import { Table, ValueLabel, StatusCodeLabel } from "@/Components/design-elements"; import { Pagination } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table"; -import { ValueLabel } from "@/Components/design-elements"; -import type { ValueType } from "@/Components/design-elements/StatusLabel"; import { ActionsMenu } from "@/Components/actions-menu"; import type { Incident } from "@/Types/Incident"; import type { Monitor } from "@/Types/Monitor"; @@ -136,13 +134,12 @@ export const IncidentsTable = ({ return ( {row.resolutionType} @@ -155,15 +152,10 @@ export const IncidentsTable = ({ id: "statusCode", content: t("pages.incidents.table.headers.statusCode"), render: (row) => { - const code = row.statusCode; - if (!code) return "N/A"; - let value: ValueType = "neutral"; - if (code < 300) value = "positive"; - else if (code >= 400) value = "negative"; return ( - ); }, diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx index d369dc7c6..ba8399522 100644 --- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx +++ b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx @@ -1,6 +1,14 @@ import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements"; +import { + Table, + Pagination, + StatusLabel, + Gauge, + ColoredLabel, +} from "@/Components/design-elements"; +import { SPACING } from "@/Utils/Theme/constants"; import type { Header } from "@/Components/design-elements/Table"; import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; import { ArrowUp, ArrowDown } from "lucide-react"; @@ -12,9 +20,30 @@ 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; + setSortField: (field: string) => void; + sortOrder: "asc" | "desc"; + setSortOrder: (order: "asc" | "desc") => void; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; + selectedRows?: string[]; + onSelectionChange?: (selected: string[]) => void; +} export const InfraMonitorsTable = ({ monitors, + tags, refetch, setSelectedMonitor, sortField, @@ -26,29 +55,32 @@ export const InfraMonitorsTable = ({ setPage, rowsPerPage, setRowsPerPage, -}: { - monitors: Monitor[]; - refetch: Function; - setSelectedMonitor: Function; - sortField: string; - setSortField: (field: string) => void; - sortOrder: "asc" | "desc"; - setSortOrder: (order: "asc" | "desc") => void; - count: number; - page: number; - setPage: (page: number) => void; - rowsPerPage: number; - setRowsPerPage: (rowsPerPage: number) => void; -}) => { + selectedRows = [], + onSelectionChange, +}: InfraMonitorsTableProps) => { const { t } = useTranslation(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); const navigate = useNavigate(); - const { - post, - // loading: isPatching, - // error: postError, - } = usePost(); + const { post } = usePost, Monitor>(); + + const selectedSet = new Set(selectedRows); + const isAllSelected = + monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id)); + const isSomeSelected = selectedRows.length > 0 && !isAllSelected; + + const handleSelectAll = (checked: boolean) => { + onSelectionChange?.(checked ? monitors.map((m) => m.id) : []); + }; + + const handleSelectRow = (id: string, checked: boolean) => { + if (!onSelectionChange) return; + onSelectionChange( + checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id) + ); + }; + + const isRowSelected = (id: string) => selectedSet.has(id); const handlePageChange = ( _e: React.MouseEvent | null, @@ -65,7 +97,7 @@ export const InfraMonitorsTable = ({ setRowsPerPage(value); }; - const handleSort = (e: any, field: string) => { + const handleSort = (e: React.MouseEvent, field: string) => { e.preventDefault(); e.stopPropagation(); if (sortField === field) { @@ -101,13 +133,6 @@ export const InfraMonitorsTable = ({ navigate(`/infrastructure/configure/${monitor.id}`); }, }, - // { - // id: 5, - // label: "Clone", - // action: () => { - - // }, - // }, { id: 6, label: @@ -152,8 +177,35 @@ export const InfraMonitorsTable = ({ ); const headers: Header[] = [ + { + id: "selection", + content: ( + handleSelectAll(e.target.checked)} + /> + ), + hideMobileLabel: true, + render: (row) => ( + { + handleSelectRow(row.id, e.target.checked); + }} + onClick={(e) => e.stopPropagation()} + /> + ), + onClick: (e) => e?.stopPropagation(), + }, { id: "name", + align: "left", + mobileLabel: t("common.table.headers.name"), content: ( handleSort(e, "status")} sx={{ cursor: "pointer" }} > + {t("common.table.headers.status")} {renderSortIcon(sortField === "status")} - + ), render: (row) => { return ; @@ -223,6 +277,32 @@ export const InfraMonitorsTable = ({ return ; }, }, + { + id: "tags", + content: t("common.table.headers.tags"), + render: (row) => { + if (row.tags.length === 0) return "-"; + return ( + + {row.tags.map((tagId) => { + const fullTag = tags.find((t) => t.id === tagId); + if (!fullTag) return null; + return ( + + ); + })} + + ); + }, + }, { id: "actions", @@ -248,6 +328,11 @@ export const InfraMonitorsTable = ({ onRowClick={(row) => { navigate(`/infrastructure/${row.id}`); }} + getRowSx={(row) => ({ + backgroundColor: isRowSelected(row.id) + ? theme.palette.action.selected + : "inherit", + })} /> { const { t } = useTranslation(); @@ -32,11 +41,15 @@ const InfrastructureMonitors = () => { // Local state const [selectedStatus, setSelectedStatus] = useState(""); const [selectedState, setSelectedState] = useState(""); + const [selectedTags, setSelectedTags] = useState([]); const [search, setSearch] = useState(""); const [page, setPage] = useState(0); const [sortField, setSortField] = useState(""); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [selectedMonitor, setSelectedMonitor] = useState(null); + + const { data: tags } = useGet("/tags/team"); + const isDialogOpen = Boolean(selectedMonitor); const debouncedSearch = useDebounce(search, 300); @@ -44,6 +57,7 @@ const InfrastructureMonitors = () => { const handleClearFilters = useCallback(() => { setSelectedStatus(""); setSelectedState(""); + setSelectedTags([]); setSearch(""); }, []); @@ -74,6 +88,7 @@ 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)); @@ -81,7 +96,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]); + }, [page, rowsPerPage, filter, field, sortOrder, selectedTags]); const { data: monitorsWithChecksData, @@ -97,8 +112,19 @@ const InfrastructureMonitors = () => { const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 }; const isLoading = monitorsWithChecksLoading; + // Bulk actions + const { + selectedRows, + setSelectedRows, + handleBulkPause, + handleBulkResume, + handleCancelSelection, + } = useBulkMonitorActions(refetch, page); + // Check if any filters are active - const hasActiveFilters = Boolean(selectedStatus || selectedState || search); + const hasActiveFilters = Boolean( + selectedStatus || selectedState || selectedTags.length > 0 || search + ); // Show empty state only when there are truly no monitors (not just filtered out) const effectiveTotalCount = @@ -149,6 +175,9 @@ const InfrastructureMonitors = () => { setSelectedStatus={setSelectedStatus} selectedState={selectedState} setSelectedState={setSelectedState} + tagOptions={tags ?? []} + selectedTags={selectedTags} + setSelectedTags={setSelectedTags} onClearFilters={handleClearFilters} /> { }} /> + + {selectedTags.length > 0 && ( + + + {t("pages.uptime.filters.activeTags")} + + {selectedTags.map((tagId) => { + const tag = tags?.find((t) => t.id === tagId); + if (!tag) return null; + return ( + + ); + })} + + )} + + {!isLoading && ( + + + + + )} + { ); setPage(0); }} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} /> { const theme = useTheme(); @@ -19,12 +22,12 @@ export const TabDiagnostics = () => { } = useGet("/diagnostic/system", {}, { refreshInterval: 5000 }); return ( - - - + + + {t("pages.logs.diagnostics.sections.runtimeStats.title")} @@ -34,11 +37,11 @@ export const TabDiagnostics = () => { - - + + {t("pages.logs.diagnostics.sections.memoryCpu.title")} @@ -48,6 +51,30 @@ export const TabDiagnostics = () => { + + + {t("pages.logs.diagnostics.sections.mongoDBStats.title")} + + + {t("pages.logs.diagnostics.sections.mongoDBStats.description")} + + + + + + {t("pages.logs.diagnostics.sections.mongoDBCollectionStats.title")} + + + {t("pages.logs.diagnostics.sections.mongoDBCollectionStats.description")} + + + ); }; diff --git a/client/src/Pages/Logs/TabQueue.tsx b/client/src/Pages/Logs/TabQueue.tsx index 84573e8f0..761b7def8 100644 --- a/client/src/Pages/Logs/TabQueue.tsx +++ b/client/src/Pages/Logs/TabQueue.tsx @@ -2,28 +2,41 @@ 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"; import { Metrics } from "@/Pages/Logs/components/Metrics"; import { Button } from "@/Components/inputs"; import { EmptyState } from "@/Components/design-elements"; +import { SPACING } from "@/Utils/Theme/constants"; export const TabQueue = () => { const theme = useTheme(); const { t } = useTranslation(); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(10); const { data: queueData, isLoading, // error, refetch, - } = useGet("/queue/all-metrics", {}, { refreshInterval: 5000 }); + } = useGet( + `/queue/all-metrics?page=${page}&rowsPerPage=${rowsPerPage}`, + {}, + { refreshInterval: 1000, keepPreviousData: true } + ); 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; @@ -49,54 +62,77 @@ export const TabQueue = () => { - {t("pages.logs.jobQueue")} + {t("pages.logs.workers")} - + ), }} /> - + + {t("pages.logs.jobQueue")} + + + + ), + }} + /> + + + setPage(newPage)} + onRowsPerPageChange={(event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }} + /> + + + + {t("pages.logs.failedJobs")} - + {t("pages.logs.failedJobsExplainer")} diff --git a/client/src/Pages/Logs/components/MongoCollectionStats.tsx b/client/src/Pages/Logs/components/MongoCollectionStats.tsx new file mode 100644 index 000000000..4f20fa0b6 --- /dev/null +++ b/client/src/Pages/Logs/components/MongoCollectionStats.tsx @@ -0,0 +1,104 @@ +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 ( + *": { width: "100% !important" }, + }} + > + {mongoStats.collections.map((colStat) => ( + + + + {/* {`Documents: ${colStat.documentCount}`} */} + + {t("pages.logs.diagnostics.mongoDBCollectionStats.documents", { + count: colStat.documentCount, + })} + + {colStat.bucketCount !== undefined && ( + + {t("pages.logs.diagnostics.mongoDBCollectionStats.bucketCount", { + count: colStat.bucketCount, + })} + + )} + + {t("pages.logs.diagnostics.mongoDBCollectionStats.storageSize", { + size: prettyBytes(colStat.storageSize), + })} + + + {t("pages.logs.diagnostics.mongoDBCollectionStats.indexSize", { + size: prettyBytes(colStat.totalIndexSize), + })} + + + {t("pages.logs.diagnostics.mongoDBCollectionStats.totalSize", { + size: prettyBytes(colStat.totalSize), + })} + + + + + + + + + + ))} + + ); +}; diff --git a/client/src/Pages/Logs/components/MongoStats.tsx b/client/src/Pages/Logs/components/MongoStats.tsx new file mode 100644 index 000000000..ed7b9cc53 --- /dev/null +++ b/client/src/Pages/Logs/components/MongoStats.tsx @@ -0,0 +1,54 @@ +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 ( + *": { width: "100% !important" }, + }} + > + + + + + + + ); +}; diff --git a/client/src/Pages/Logs/components/TableJobs.tsx b/client/src/Pages/Logs/components/TableJobs.tsx index f690bd417..241b6f016 100644 --- a/client/src/Pages/Logs/components/TableJobs.tsx +++ b/client/src/Pages/Logs/components/TableJobs.tsx @@ -1,4 +1,4 @@ -import { Table } from "@/Components/design-elements"; +import { Table, Pagination } from "@/Components/design-elements"; import { Typography, useTheme } from "@mui/material"; import prettyMilliseconds from "pretty-ms"; import { formatTimestamp } from "@/Utils/TimeUtils"; @@ -6,14 +6,27 @@ 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 }: TableJobsProps) => { +export const TableJobs = ({ + jobs, + count, + page, + rowsPerPage, + onPageChange, + onRowsPerPageChange, +}: TableJobsProps) => { const { t } = useTranslation(); const theme = useTheme(); @@ -30,6 +43,22 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { }; const headers: Header[] = [ + { + id: "state", + content: t("pages.logs.table.headers.state"), + render: (row) => { + if (row.lockedAt) { + return t("common.labels.running"); + } + if (row.monitorActive === true) { + return t("common.labels.active"); + } + if (row.monitorActive === false) { + return t("common.labels.paused"); + } + return t("common.labels.idle"); + }, + }, { id: "id", content: t("common.table.headers.monitorId"), @@ -47,7 +76,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { id: "url", content: t("common.table.headers.url"), render: (row) => ( - + {row.monitorUrl} ), @@ -60,9 +92,15 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { { id: "interval", content: t("common.table.headers.interval"), - render: (row) => ( - {prettyMilliseconds(row.monitorInterval ?? 0)} - ), + render: (row) => { + let interval; + if (row.monitorId.toString().includes("geo")) { + interval = row.monitorGeoInterval ?? 0; + } else { + interval = row.repeat ?? 0; + } + return {prettyMilliseconds(interval)}; + }, }, { id: "lastRun", @@ -70,7 +108,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { render: (row) => { const v = formatTimestamp(row.lastRunAt) ?? "-"; return ( - + {v} ); @@ -90,27 +131,62 @@ export const TableJobs = ({ jobs }: TableJobsProps) => { render: (row) => { const v = formatTimestamp(row.lockedAt) ?? "-"; return ( - + {v} ); }, }, + { + id: "lockedBy", + content: t("pages.logs.table.headers.lockedBy"), + render: (row) => { + const lockedBy = row.lockedBy; + if (!lockedBy) { + return -; + } + + const workerId = lockedBy.split(":")[2]; + const shortWorkerId = workerId.slice(workerId.length - 8); + return ( + + {`...${shortWorkerId}`} + + ); + }, + }, ]; - const isDark = theme.palette.mode === "dark"; - const runningBg = isDark ? "rgba(19, 113, 91, 0.18)" : "#ECF7F2"; - return ( - ({ - ...(row.lockedAt && { - "& td": { backgroundColor: runningBg }, - }), - })} - /> + <> +
({ + ...(row.lockedAt && { + "& td": { backgroundColor: theme.palette.rowStatus.running }, + }), + ...(row.monitorActive === false && { + "& td": { backgroundColor: theme.palette.rowStatus.paused }, + }), + })} + /> + + ); }; diff --git a/client/src/Pages/Logs/components/TableWorkers.tsx b/client/src/Pages/Logs/components/TableWorkers.tsx new file mode 100644 index 000000000..5d1f44860 --- /dev/null +++ b/client/src/Pages/Logs/components/TableWorkers.tsx @@ -0,0 +1,94 @@ +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[] = [ + { + id: "host", + content: t("pages.logs.table.headers.host"), + render: (row) => { + const host = row.workerId.split(":")[0]; + return ( + + {host} + + ); + }, + }, + { + id: "processId", + content: t("pages.logs.table.headers.processId"), + render: (row) => { + const processId = row.workerId.split(":")[1]; + return ( + + {processId} + + ); + }, + }, + { + 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 ( + + {`...${shortWorkerId}`} + + ); + }, + }, + { + id: "mode", + content: t("pages.logs.table.headers.workerMode"), + render: (row) => {row.mode}, + }, + { + id: "lastSeen", + content: t("pages.logs.table.headers.lastSeenAt"), + render: (row) => ( + {formatTimestamp(row.lastSeenAt)} + ), + }, + ]; + + return ( +
+ ); +}; diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index d130ee7c7..20a0b461e 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -22,6 +22,7 @@ import type { MaintenanceWindowFormData } from "@/Validation/maintenanceWindow"; import { repeatOptions, durationUnitOptions } from "@/Validation/maintenanceWindow"; import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; +import { mutate } from "swr"; import { useParams, useNavigate } from "react-router-dom"; import type { Monitor } from "@/Types/Monitor"; import { useForm, Controller } from "react-hook-form"; @@ -85,10 +86,6 @@ const CreateMaintenanceWindowPage = () => { repeat, }; - if (repeat === 0) { - payload.expiry = endDateTime.toISOString(); - } - return payload; }; @@ -103,6 +100,9 @@ const CreateMaintenanceWindowPage = () => { } if (result?.success) { + await mutate( + (key) => typeof key === "string" && key.startsWith("/maintenance-window") + ); navigate("/maintenance"); } }; diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 0f6d9b652..84d264616 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -150,7 +150,8 @@ const NotificationsCreatePage = () => { {watchedType !== "matrix" && watchedType !== "telegram" && watchedType !== "pushover" && - watchedType !== "twilio" && ( + watchedType !== "twilio" && + watchedType !== "ntfy" && ( { } /> )} + {watchedType === "ntfy" && ( + + ( + + )} + /> + ( + + )} + /> + + } + /> + )} {watchedType === "telegram" && ( ), render: (row) => { - return row.name; + return ( + + {row.name} + + + ); }, }, { @@ -215,6 +237,32 @@ export const PageSpeedMonitorsTable = ({ ); }, }, + { + id: "tags", + content: t("common.table.headers.tags"), + render: (row) => { + if (row.tags.length === 0) return "-"; + return ( + + {row.tags.map((tagId) => { + const fullTag = tags.find((tag) => tag.id === tagId); + if (!fullTag) return null; + return ( + + ); + })} + + ); + }, + }, { id: "actions", diff --git a/client/src/Pages/PageSpeed/Monitors/index.tsx b/client/src/Pages/PageSpeed/Monitors/index.tsx index 4cb28b51e..b9d0b49b1 100644 --- a/client/src/Pages/PageSpeed/Monitors/index.tsx +++ b/client/src/Pages/PageSpeed/Monitors/index.tsx @@ -1,22 +1,32 @@ import { MonitorBasePageWithStates, PageSpeedKeyPriorityFallback, + ColoredLabel, } from "@/Components/design-elements"; -import { Dialog } from "@/Components/inputs"; +import { Dialog, TextField } 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 { useMemo, useState } from "react"; +import { useCallback, 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 { HeaderMonitorsSummary } from "@/Components/monitors"; +import { ControlsFilter, 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(); @@ -33,17 +43,50 @@ const PageSpeedMonitorsPage = () => { const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); + const [selectedTags, setSelectedTags] = useState([]); + const [selectedStatus, setSelectedStatus] = useState(""); + const [selectedState, setSelectedState] = useState(""); + const [search, setSearch] = useState(""); + + const debouncedSearch = useDebounce(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([ + [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("/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 (sortField) params.append("field", sortField); + if (filter) params.append("filter", filter); + if (field) params.append("field", field); if (sortOrder) params.append("order", sortOrder); return `/monitors/team/with-checks?${params.toString()}`; - }, [page, rowsPerPage, sortField, sortOrder]); + }, [page, rowsPerPage, sortOrder, selectedTags, filter, field]); const { data: monitorsData, @@ -75,12 +118,28 @@ 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 ( : undefined} @@ -91,8 +150,59 @@ const PageSpeedMonitorsPage = () => { isAdmin={isAdmin} /> + + + + { + setSearch(event.target.value); + }} + /> + + + {selectedTags.length > 0 && ( + + + {t("pages.uptime.filters.activeTags")} + + {selectedTags.map((tagId) => { + const tag = tags?.find((t) => t.id === tagId); + if (!tag) return null; + return ( + + ); + })} + + )} + { const checks: CheckSnapshot[] = []; - for (let i = 0; i < 25; i++) { + for (let i = 0; i < MAX_RECENT_CHECKS; i++) { const isUp = Math.random() > 0.1; const responseTime = Math.floor(Math.random() * 80) + 20; checks.push({ diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index fc3fceceb..e14366e0f 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -128,8 +128,8 @@ export const SettingsPage = () => { timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null; const handleTimezoneChange = (newValue: Timezone | null) => { - const newId = newValue?.id ?? ""; - dispatch(setTimezone({ timezone: newId })); + if (!newValue?.id) return; + dispatch(setTimezone({ timezone: newValue.id })); }; const handleModeChange = (e: SelectChangeEvent) => { @@ -182,6 +182,9 @@ export const SettingsPage = () => { systemEmailRequireTLS: formValues.systemEmailRequireTLS, systemEmailRejectUnauthorized: formValues.systemEmailRejectUnauthorized, ...(formValues.systemEmailUser && { systemEmailUser: formValues.systemEmailUser }), + ...(formValues.systemEmailDisplayName && { + systemEmailDisplayName: formValues.systemEmailDisplayName, + }), ...(formValues.systemEmailTLSServername && { systemEmailTLSServername: formValues.systemEmailTLSServername, }), @@ -577,44 +580,65 @@ export const SettingsPage = () => { href="https://nodemailer.com/smtp/" target="_blank" /> - - - {JSON.stringify( - { - host: form.watch("systemEmailHost") || "", - port: form.watch("systemEmailPort") || "", - secure: form.watch("systemEmailSecure") ?? false, - auth: { - user: - form.watch("systemEmailUser") || - form.watch("systemEmailAddress") || - "", - pass: "", - }, - name: form.watch("systemEmailConnectionHost") || "localhost", - pool: form.watch("systemEmailPool") ?? false, - tls: { - rejectUnauthorized: - form.watch("systemEmailRejectUnauthorized") ?? true, - ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, - requireTLS: form.watch("systemEmailRequireTLS") ?? false, - servername: form.watch("systemEmailTLSServername") || "", - }, - }, - null, - 2 - )} - - + {(() => { + const address = form.watch("systemEmailAddress") || ""; + const displayName = form.watch("systemEmailDisplayName")?.trim(); + return ( + <> + + + {JSON.stringify( + { + host: form.watch("systemEmailHost") || "", + port: form.watch("systemEmailPort") || "", + secure: form.watch("systemEmailSecure") ?? false, + auth: { + user: form.watch("systemEmailUser") || address, + pass: "", + }, + name: form.watch("systemEmailConnectionHost") || "localhost", + pool: form.watch("systemEmailPool") ?? false, + tls: { + rejectUnauthorized: + form.watch("systemEmailRejectUnauthorized") ?? true, + ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, + requireTLS: form.watch("systemEmailRequireTLS") ?? false, + servername: form.watch("systemEmailTLSServername") || "", + }, + }, + null, + 2 + )} + + + {address && ( + + + {`From: ${displayName ? `"${displayName}" <${address}>` : address}`} + + + )} + + ); + })()} } rightContent={ @@ -680,6 +704,24 @@ export const SettingsPage = () => { )} /> + {/* Email Display Name (Optional) */} + ( + + )} + /> + {/* Email User (Optional) */} + slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null; + const CreateStatusPage = () => { const theme = useTheme(); const { t } = useTranslation(); @@ -59,9 +64,7 @@ const CreateStatusPage = () => { // Fetch existing status page data when configuring const { data: statusPageData, isLoading: isLoadingStatusPage } = - useGet( - isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure` - ); + useGet(isCreate ? null : buildStatusPageKey(url)); const { data: monitorsResponse } = useGet(monitorsUrl); const monitors = monitorsResponse ?? []; @@ -84,13 +87,27 @@ const CreateStatusPage = () => { defaultValues: defaults, }); - const { control, reset, handleSubmit } = form; + const { control, reset, handleSubmit, trigger } = 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 ?? []) @@ -181,6 +198,10 @@ const CreateStatusPage = () => { } if (result) { + await mutate(buildStatusPageKey(data.url)); + if (!isCreate && url !== data.url) { + await mutate(buildStatusPageKey(url)); + } navigate(`/status/${data.url}`); } }; @@ -194,315 +215,334 @@ const CreateStatusPage = () => { onSubmit={handleSubmit(onSubmit, onError)} > {!isCreate && } - - ( - field.onChange(e.target.checked)} - /> - )} - /> - - {t("pages.statusPages.form.access.option.published.name")} - - - } - /> - - ( - - )} - /> - ( - - )} - /> - - } - /> - { - 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 ( - - 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) => ( - - )} - /> - {selectedMonitors.length > 0 && ( - - - {(provided) => ( - - {selectedMonitors.map((monitor, index) => ( - - {(provided) => ( - - - {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} - { - field.onChange( - field.value.filter( - (id: string) => id !== monitor.id - ) - ); - }} - aria-label="Remove monitor" - > - - - - )} - - ))} - {provided.placeholder} - - )} - - - )} - - ); - }} - /> - } - /> - ( - 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) => ( - + )} + {showStep(0) && ( + + ( + field.onChange(e.target.checked)} /> )} /> - )} - /> - } - /> - - + + {t("pages.statusPages.form.access.option.published.name")} + + + } + /> + )} + {showStep(0) && ( + ( - { - if (file) { - field.onChange({ - data: file.src, - contentType: file.file.type, - }); - } else { - field.onChange(null); - } - }} + render={({ field, fieldState }) => ( + + )} + /> + ( + )} /> + } + /> + )} + {showStep(0) && ( + ( - - )} - /> - - } - /> - ( - ( - - )} - /> - )} - /> - } - /> - - ( - { + 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 ( + + 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) => ( + + )} /> - } - label={t("pages.statusPages.form.features.option.showCharts.label")} - /> - )} + {selectedMonitors.length > 0 && ( + + + {(provided) => ( + + {selectedMonitors.map((monitor, index) => ( + + {(provided) => ( + + + {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} + { + field.onChange( + field.value.filter( + (id: string) => id !== monitor.id + ) + ); + }} + aria-label="Remove monitor" + > + + + + )} + + ))} + {provided.placeholder} + + )} + + + )} + + ); + }} /> + } + /> + )} + {showStep(0) && ( + ( - + option.name} + value={ + timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null } - label={t( - "pages.statusPages.form.features.option.showInfrastructure.label" + onChange={(_, newValue: TimezoneOption | null) => { + field.onChange(newValue?._id ?? ""); + }} + fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")} + renderInput={(params) => ( + )} /> )} /> - {/* + )} + {showStep(1) && ( + + + ( + { + if (file) { + field.onChange({ + data: file.src, + contentType: file.file.type, + }); + } else { + field.onChange(null); + } + }} + /> + )} + /> + + ( + + )} + /> + + } + /> + )} + {showStep(1) && ( + ( + ( + + )} + /> + )} + /> + } + /> + )} + {showStep(1) && ( + + ( + + } + label={t("pages.statusPages.form.features.option.showCharts.label")} + /> + )} + /> + ( + + } + label={t( + "pages.statusPages.form.features.option.showInfrastructure.label" + )} + /> + )} + /> + {/* ( @@ -536,21 +576,46 @@ const CreateStatusPage = () => { /> )} /> */} - - } - /> + + } + /> + )} - + {isCreate && ( + + )} + {isCreate && currentStep < totalSteps - 1 ? ( + + ) : ( + + )} -> = { - refined: RefinedStatusPage, - modern: ModernStatusPage, - bold: BoldStatusPage, - editorial: EditorialStatusPage, +const THEME_CONFIGS: Record> = { + refined: { + createStyles: refinedStyles, + HeaderSlot: RefinedHeader, + HeroSlot: RefinedHero, + }, + modern: { + createStyles: modernStyles, + HeaderSlot: ModernHeader, + HeroSlot: ModernHero, + overallStatusOptions: { iconSize: 20 }, + }, + bold: { + createStyles: boldStyles, + HeaderSlot: BoldHeader, + HeroSlot: BoldHero, + overallStatusOptions: { iconSize: 18 }, + }, + editorial: { + createStyles: editorialStyles, + HeaderSlot: EditorialHeader, + HeroSlot: EditorialHero, + overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" }, + }, }; const StatusPageView = () => { @@ -90,11 +111,12 @@ const StatusPageView = () => { ); } - const ThemedRenderer = THEMED_RENDERERS[resolveStatusPageTheme(statusPage.theme)]; + const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)]; const themedRenderer = ( - ); @@ -104,6 +126,7 @@ const StatusPageView = () => { {themedRenderer} @@ -127,6 +150,7 @@ const StatusPageView = () => { {themedRenderer} diff --git a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx index 2b10b0d9a..f79991c3b 100644 --- a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx +++ b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx @@ -13,6 +13,7 @@ import { type StatusPageTheme, type StatusPageThemeMode, } from "@/Types/StatusPage"; +import { resolveTimezone } from "@/Utils/TimeUtils"; import { themeTokens, type StatusPageThemeTokens } from "./tokens"; type ResolvedMode = "light" | "dark"; @@ -21,6 +22,7 @@ interface StatusPageThemeContextValue { theme: StatusPageTheme; mode: ResolvedMode; tokens: StatusPageThemeTokens; + timezone: string; } const StatusPageThemeContext = createContext(null); @@ -33,6 +35,7 @@ const resolveSystemMode = (): ResolvedMode => { interface Props { theme?: StatusPageTheme; themeMode?: StatusPageThemeMode; + timezone?: string; /** * When true, paint the document body with the theme background so it * covers beyond the provider's wrapper (useful on the public route @@ -54,12 +57,14 @@ interface Props { export const StatusPageThemeProvider = ({ theme, themeMode, + timezone, paintBody = false, transparent = false, children, }: Props) => { const resolvedTheme = resolveStatusPageTheme(theme); const resolvedThemeMode = resolveStatusPageThemeMode(themeMode); + const resolvedTimezone = resolveTimezone(timezone); const [systemMode, setSystemMode] = useState(resolveSystemMode); @@ -107,8 +112,13 @@ export const StatusPageThemeProvider = ({ const tokens = themeTokens[resolvedTheme][resolvedMode]; const value = useMemo( - () => ({ theme: resolvedTheme, mode: resolvedMode, tokens }), - [resolvedTheme, resolvedMode, tokens] + () => ({ + theme: resolvedTheme, + mode: resolvedMode, + tokens, + timezone: resolvedTimezone, + }), + [resolvedTheme, resolvedMode, tokens, resolvedTimezone] ); return ( @@ -136,6 +146,7 @@ export const useStatusPageTheme = (): StatusPageThemeContextValue => { theme: DEFAULT_STATUS_PAGE_THEME, mode: resolveSystemMode(), tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()], + timezone: resolveTimezone(), }; } return ctx; diff --git a/client/src/Pages/StatusPage/Status/themes/bold/BoldHeader.tsx b/client/src/Pages/StatusPage/Status/themes/bold/BoldHeader.tsx new file mode 100644 index 000000000..7b483b474 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/bold/BoldHeader.tsx @@ -0,0 +1,20 @@ +import Box from "@mui/material/Box"; +import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles"; + +export const BoldHeader = ({ statusPage, logoSrc, styles }: SlotProps) => ( + + {logoSrc ? ( + + ) : ( + {monoFirstChar(statusPage.companyName)} + )} + {statusPage.companyName} + +); diff --git a/client/src/Pages/StatusPage/Status/themes/bold/BoldHero.tsx b/client/src/Pages/StatusPage/Status/themes/bold/BoldHero.tsx new file mode 100644 index 000000000..3d378ddaf --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/bold/BoldHero.tsx @@ -0,0 +1,32 @@ +import Box from "@mui/material/Box"; +import { useTranslation } from "react-i18next"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles"; + +export const BoldHero = ({ overall, monitorCount, styles }: SlotProps) => { + const { t } = useTranslation(); + return ( + + + + {overall.icon} + + {overall.message} + + + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitorCount, + })} + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx deleted file mode 100644 index 32959a2ed..000000000 --- a/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { useMemo, useState } from "react"; -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import { useTranslation } from "react-i18next"; -import type { Monitor } from "@/Types/Monitor"; -import type { StatusPage } from "@/Types/StatusPage"; -import { getMonitorTypeLabel } from "@/Types/StatusPage"; -import { ThemedHeatmap } from "../shared/ThemedHeatmap"; -import { ThemedHistogram } from "../shared/ThemedHistogram"; -import { ThemedInfrastructure } from "../shared/ThemedInfrastructure"; -import { - monitorBadgeTone, - monoFirstChar, - resolveOverallStatus, - statusBadgeKey, -} from "../shared/overallStatus"; -import { useStatusPageTheme } from "../StatusPageThemeProvider"; -import { boldStyles } from "./styles"; - -type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; - -interface Props { - statusPage: StatusPage; - monitors: StatusPageMonitor[]; -} - -export const BoldStatusPage = ({ statusPage, monitors }: Props) => { - const { t } = useTranslation(); - const { tokens, mode } = useStatusPageTheme(); - const styles = useMemo(() => boldStyles(tokens, mode === "dark"), [tokens, mode]); - const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); - - const overall = resolveOverallStatus(monitors, t, { iconSize: 18 }); - const logoSrc = statusPage.logo?.data - ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` - : null; - - return ( - - - - {logoSrc ? ( - - ) : ( - {monoFirstChar(statusPage.companyName)} - )} - {statusPage.companyName} - - - - - - - {overall.icon} - - {overall.message} - - - {t("pages.statusPages.statusBar.monitoringSummary", { - count: monitors.length, - })} - - - - {statusPage.showCharts && ( - - - setChartMode("heatmap")} - sx={styles.chartSwitchButton(chartMode === "heatmap")} - > - {t("pages.statusPages.monitorsList.chartTypeHeatmap")} - - setChartMode("histogram")} - sx={styles.chartSwitchButton(chartMode === "histogram")} - > - {t("pages.statusPages.monitorsList.chartTypeHistogram")} - - - - )} - - - {monitors.map((monitor) => { - const isHardware = monitor.type === "hardware"; - const showInfra = isHardware && statusPage.showInfrastructure !== false; - const showChart = !isHardware && statusPage.showCharts !== false; - const badgeTone = monitorBadgeTone(monitor.status); - - return ( - - - - {monitor.name} - - - {getMonitorTypeLabel(monitor.type, t)} - - {monitor.url && ( - - {monitor.url} - - )} - - - - {t(statusBadgeKey[monitor.status])} - - - - {showInfra && ( - - )} - {showChart && - (chartMode === "heatmap" ? ( - - ) : ( - - ))} - - ); - })} - - - - {t("pages.statusPages.footer.poweredBy")}{" "} - - Checkmate - - - - ); -}; diff --git a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts index ac2eac9c0..9153ab994 100644 --- a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts @@ -2,6 +2,7 @@ import type { SxProps, Theme } from "@mui/material/styles"; import type { StatusPageThemeTokens } from "../tokens"; import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty"; export type BoldBarKind = "up" | "down" | "empty"; @@ -297,7 +298,7 @@ export const boldStyles = ( heatmap: { padding: "0 28px 22px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "3px", height: 48, }, @@ -313,7 +314,7 @@ export const boldStyles = ( histogram: { padding: "0 28px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "3px", alignItems: "flex-end", height: 48, diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHeader.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHeader.tsx new file mode 100644 index 000000000..56a2a9af2 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHeader.tsx @@ -0,0 +1,36 @@ +import Box from "@mui/material/Box"; +import { useTranslation } from "react-i18next"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles"; + +export const EditorialHeader = ({ + statusPage, + logoSrc, + styles, +}: SlotProps) => { + const { t } = useTranslation(); + return ( + + {logoSrc && ( + + )} + + {statusPage.companyName} + + + {t("pages.statusPages.editorial.reportTitle")} + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHero.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHero.tsx new file mode 100644 index 000000000..cd6a06406 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHero.tsx @@ -0,0 +1,46 @@ +import { useMemo } from "react"; +import Box from "@mui/material/Box"; +import { useTranslation } from "react-i18next"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles"; + +export const EditorialHero = ({ + overall, + monitorCount, + styles, +}: SlotProps) => { + const { t } = useTranslation(); + const todayLabel = useMemo( + () => + new Date().toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }), + [] + ); + + return ( + <> + + + {overall.message} + + + {todayLabel} ·{" "} + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitorCount, + })} + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx deleted file mode 100644 index ee8fa6f99..000000000 --- a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { useMemo, useState } from "react"; -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import { useTranslation } from "react-i18next"; -import type { Monitor } from "@/Types/Monitor"; -import type { StatusPage } from "@/Types/StatusPage"; -import { getMonitorTypeLabel } from "@/Types/StatusPage"; -import { ThemedHeatmap } from "../shared/ThemedHeatmap"; -import { ThemedHistogram } from "../shared/ThemedHistogram"; -import { ThemedInfrastructure } from "../shared/ThemedInfrastructure"; -import { - monitorBadgeTone, - resolveOverallStatus, - statusBadgeKey, -} from "../shared/overallStatus"; -import { useStatusPageTheme } from "../StatusPageThemeProvider"; -import { editorialStyles } from "./styles"; - -type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; - -interface Props { - statusPage: StatusPage; - monitors: StatusPageMonitor[]; -} - -export const EditorialStatusPage = ({ statusPage, monitors }: Props) => { - const { t } = useTranslation(); - const { tokens } = useStatusPageTheme(); - const styles = useMemo(() => editorialStyles(tokens), [tokens]); - const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); - - const todayLabel = useMemo( - () => - new Date().toLocaleDateString(undefined, { - year: "numeric", - month: "long", - day: "numeric", - }), - [] - ); - - const overall = resolveOverallStatus(monitors, t, { - allUpKey: "pages.statusPages.editorial.allUp", - }); - const logoSrc = statusPage.logo?.data - ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` - : null; - - return ( - - - - {logoSrc && ( - - )} - - {statusPage.companyName} - - - {t("pages.statusPages.editorial.reportTitle")} - - - - - - - {overall.message} - - - {todayLabel} ·{" "} - {t("pages.statusPages.statusBar.monitoringSummary", { - count: monitors.length, - })} - - - {statusPage.showCharts && ( - - - setChartMode("heatmap")} - sx={styles.chartSwitchButton(chartMode === "heatmap")} - > - {t("pages.statusPages.monitorsList.chartTypeHeatmap")} - - setChartMode("histogram")} - sx={styles.chartSwitchButton(chartMode === "histogram")} - > - {t("pages.statusPages.monitorsList.chartTypeHistogram")} - - - - )} - - - {monitors.map((monitor) => { - const isHardware = monitor.type === "hardware"; - const showInfra = isHardware && statusPage.showInfrastructure !== false; - const showChart = !isHardware && statusPage.showCharts !== false; - const badgeTone = monitorBadgeTone(monitor.status); - - return ( - - - - {monitor.name} - - - {getMonitorTypeLabel(monitor.type, t)} - - {monitor.url && ( - - {monitor.url} - - )} - - - - {t(statusBadgeKey[monitor.status])} - - - - {showInfra && ( - - )} - {showChart && - (chartMode === "heatmap" ? ( - - ) : ( - - ))} - - ); - })} - - - - {t("pages.statusPages.footer.poweredBy")}{" "} - - Checkmate - - - - ); -}; diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts index bed3e72aa..57128180e 100644 --- a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts @@ -6,6 +6,7 @@ import { MONO_STACK, SERIF_STACK, } from "../shared/fontStacks"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty"; export type EditorialBarKind = "up" | "down" | "empty"; @@ -50,7 +51,10 @@ export interface EditorialStyles { footer: SxProps; } -export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles => { +export const editorialStyles = ( + tokens: StatusPageThemeTokens, + _isDark: boolean +): EditorialStyles => { const heatCellBg: Record = { fast: tokens.up, med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`, @@ -245,7 +249,7 @@ export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles heatmap: { mt: "16px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "2px", height: 40, }, @@ -257,7 +261,7 @@ export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles histogram: { mt: "16px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "2px", alignItems: "flex-end", height: 40, diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernHeader.tsx b/client/src/Pages/StatusPage/Status/themes/modern/ModernHeader.tsx new file mode 100644 index 000000000..c7f01bc4e --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/modern/ModernHeader.tsx @@ -0,0 +1,24 @@ +import Box from "@mui/material/Box"; +import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles"; + +export const ModernHeader = ({ + statusPage, + logoSrc, + styles, +}: SlotProps) => ( + + {logoSrc ? ( + + ) : ( + {monoFirstChar(statusPage.companyName)} + )} + {statusPage.companyName} + +); diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx b/client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx new file mode 100644 index 000000000..a7cf93bc7 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx @@ -0,0 +1,34 @@ +import Box from "@mui/material/Box"; +import { useTranslation } from "react-i18next"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles"; + +export const ModernHero = ({ + overall, + monitorCount, + styles, +}: SlotProps) => { + const { t } = useTranslation(); + return ( + + + + + {overall.message} + + + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitorCount, + })} + + + {overall.icon} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts index ad7356451..1ee3af741 100644 --- a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts @@ -3,6 +3,7 @@ import { keyframes } from "@mui/system"; import type { StatusPageThemeTokens } from "../tokens"; import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty"; export type ModernBarKind = "up" | "down" | "empty"; @@ -320,7 +321,7 @@ export const modernStyles = ( heatmap: { padding: "0 24px 20px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "3px", height: 46, }, @@ -335,7 +336,7 @@ export const modernStyles = ( histogram: { padding: "0 24px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "3px", alignItems: "flex-end", height: 46, diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedHeader.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHeader.tsx new file mode 100644 index 000000000..43e164f71 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHeader.tsx @@ -0,0 +1,29 @@ +import Box from "@mui/material/Box"; +import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles"; + +export const RefinedHeader = ({ + statusPage, + logoSrc, + styles, +}: SlotProps) => ( + + {logoSrc ? ( + + ) : ( + {monoFirstChar(statusPage.companyName)} + )} + + {statusPage.companyName} + + +); diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx new file mode 100644 index 000000000..c9a59ad77 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx @@ -0,0 +1,34 @@ +import Box from "@mui/material/Box"; +import { useTranslation } from "react-i18next"; +import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles"; + +export const RefinedHero = ({ + overall, + monitorCount, + styles, +}: SlotProps) => { + const { t } = useTranslation(); + return ( + + + + + {overall.message} + + + {t("pages.statusPages.statusBar.monitoringSummary", { + count: monitorCount, + })} + + + {overall.icon} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx deleted file mode 100644 index 5c5055b4c..000000000 --- a/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { useMemo, useState } from "react"; -import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import { useTranslation } from "react-i18next"; -import type { Monitor } from "@/Types/Monitor"; -import type { StatusPage } from "@/Types/StatusPage"; -import { getMonitorTypeLabel } from "@/Types/StatusPage"; -import { ThemedHeatmap } from "../shared/ThemedHeatmap"; -import { ThemedHistogram } from "../shared/ThemedHistogram"; -import { ThemedInfrastructure } from "../shared/ThemedInfrastructure"; -import { - monitorBadgeTone, - monoFirstChar, - resolveOverallStatus, - statusBadgeKey, -} from "../shared/overallStatus"; -import { useStatusPageTheme } from "../StatusPageThemeProvider"; -import { refinedStyles } from "./styles"; - -type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; - -interface Props { - statusPage: StatusPage; - monitors: StatusPageMonitor[]; -} - -export const RefinedStatusPage = ({ statusPage, monitors }: Props) => { - const { t } = useTranslation(); - const { tokens } = useStatusPageTheme(); - const styles = useMemo(() => refinedStyles(tokens), [tokens]); - const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); - - const overall = resolveOverallStatus(monitors, t); - const logoSrc = statusPage.logo?.data - ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` - : null; - - return ( - - - - {logoSrc ? ( - - ) : ( - {monoFirstChar(statusPage.companyName)} - )} - - {statusPage.companyName} - - - - - - - - - {overall.message} - - - {t("pages.statusPages.statusBar.monitoringSummary", { - count: monitors.length, - })} - - - {overall.icon} - - - {statusPage.showCharts && ( - - - setChartMode("heatmap")} - sx={styles.chartSwitchButton(chartMode === "heatmap")} - > - {t("pages.statusPages.monitorsList.chartTypeHeatmap")} - - setChartMode("histogram")} - sx={styles.chartSwitchButton(chartMode === "histogram")} - > - {t("pages.statusPages.monitorsList.chartTypeHistogram")} - - - - )} - - - {monitors.map((monitor) => { - const isHardware = monitor.type === "hardware"; - const showInfra = isHardware && statusPage.showInfrastructure !== false; - const showChart = !isHardware && statusPage.showCharts !== false; - const badgeTone = monitorBadgeTone(monitor.status); - - return ( - - - - {monitor.name} - - - {getMonitorTypeLabel(monitor.type, t)} - - {monitor.url && ( - - {monitor.url} - - )} - - - - {t(statusBadgeKey[monitor.status])} - - - - {showInfra && ( - - )} - {showChart && - (chartMode === "heatmap" ? ( - - ) : ( - - ))} - - ); - })} - - - - {t("pages.statusPages.footer.poweredBy")}{" "} - - Checkmate - - - - ); -}; diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts index 52626b20c..a6a52c021 100644 --- a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts @@ -6,6 +6,7 @@ import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty"; export type RefinedBarKind = "up" | "down" | "empty"; export type RefinedGaugeFill = "ok" | "warm" | "hot"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; export interface RefinedStyles { page: SxProps; @@ -62,7 +63,10 @@ const pillBase = { fontWeight: 600, }; -export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => { +export const refinedStyles = ( + tokens: StatusPageThemeTokens, + _isDark: boolean +): RefinedStyles => { const heatCellBg: Record = { fast: tokens.up, med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`, @@ -259,7 +263,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => { heatmap: { padding: "0 20px 16px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "3px", height: 42, }, @@ -274,7 +278,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => { histogram: { padding: "0 20px", display: "grid", - gridTemplateColumns: "repeat(25, 1fr)", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gap: "3px", alignItems: "flex-end", height: 42, diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx similarity index 59% rename from client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx rename to client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index 461c5e0f5..71aa85a89 100644 --- a/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -2,80 +2,125 @@ import { useMemo, useState } from "react"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import { useTranslation } from "react-i18next"; +import type { SxProps, Theme } from "@mui/material/styles"; import type { Monitor } from "@/Types/Monitor"; import type { StatusPage } from "@/Types/StatusPage"; import { getMonitorTypeLabel } from "@/Types/StatusPage"; -import { ThemedHeatmap } from "../shared/ThemedHeatmap"; -import { ThemedHistogram } from "../shared/ThemedHistogram"; -import { ThemedInfrastructure } from "../shared/ThemedInfrastructure"; +import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens"; import { + ThemedHeatmap, + type HeatCellKind, +} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap"; +import { + ThemedHistogram, + type BarKind, +} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram"; +import { + ThemedInfrastructure, + type GaugeFillLevel, +} from "@/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure"; +import { + type OverallStatus, + type OverallTone, monitorBadgeTone, - monoFirstChar, resolveOverallStatus, statusBadgeKey, -} from "../shared/overallStatus"; -import { useStatusPageTheme } from "../StatusPageThemeProvider"; -import { modernStyles } from "./styles"; +} from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; +import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; +export interface BaseStyles { + page: SxProps; + top: SxProps; + chartSwitchWrap: SxProps; + chartSwitch: SxProps; + chartSwitchButton: (active: boolean) => SxProps; + monitorList: SxProps; + card: SxProps; + cardRow: SxProps; + cardLeft: SxProps; + monitorName: SxProps; + monitorMeta: SxProps; + pill: SxProps; + pillHardware: SxProps; + monitorUrl: SxProps; + badge: (tone: OverallTone) => SxProps; + heatmap: SxProps; + heatmapCell: (kind: HeatCellKind) => SxProps; + histogram: SxProps; + bar: (kind: BarKind, heightPct: number) => SxProps; + chartStats: SxProps; + infra: SxProps; + infraEmpty: SxProps; + gauge: SxProps; + gaugeLabel: SxProps; + gaugeValue: SxProps; + gaugeBar: SxProps; + gaugeFill: (level: GaugeFillLevel, widthPct: number) => SxProps; + gaugeSub: SxProps; + footer: SxProps; +} + +export interface SlotProps { + statusPage: StatusPage; + logoSrc: string | null; + overall: OverallStatus; + monitorCount: number; + styles: S; +} + +export interface ThemeConfig { + createStyles: (tokens: StatusPageThemeTokens, isDark: boolean) => S; + HeaderSlot: React.ComponentType>; + HeroSlot: React.ComponentType>; + overallStatusOptions?: { iconSize?: number; allUpKey?: string }; +} + interface Props { statusPage: StatusPage; monitors: StatusPageMonitor[]; + config: ThemeConfig; } -export const ModernStatusPage = ({ statusPage, monitors }: Props) => { +export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => { const { t } = useTranslation(); const { tokens, mode } = useStatusPageTheme(); - const styles = useMemo(() => modernStyles(tokens, mode === "dark"), [tokens, mode]); + const styles = useMemo( + () => config.createStyles(tokens, mode === "dark"), + [config, tokens, mode] + ); const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); - const overall = resolveOverallStatus(monitors, t, { iconSize: 20 }); + const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions); const logoSrc = statusPage.logo?.data ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` : null; + const { HeaderSlot, HeroSlot } = config; + return ( - - {logoSrc ? ( - - ) : ( - {monoFirstChar(statusPage.companyName)} - )} - {statusPage.companyName} - + - - - - - {overall.message} - - - {t("pages.statusPages.statusBar.monitoringSummary", { - count: monitors.length, - })} - - - {overall.icon} - + {statusPage.showCharts && ( diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx index 165722530..fe56155c3 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -4,12 +4,12 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; -import type { RootState } from "@/store"; import type { CheckSnapshot } from "@/Types/Check"; import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; +import { useStatusPageTheme } from "../StatusPageThemeProvider"; -const CELLS = 25; +const CELLS = MAX_RECENT_CHECKS; export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty"; @@ -29,7 +29,7 @@ const classify = (check: CheckSnapshot): Exclude => { export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { const { t } = useTranslation(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const { timezone } = useStatusPageTheme(); const source = checks.slice(-CELLS); const padded: (CheckSnapshot | null)[] = [ @@ -67,11 +67,7 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { variant="caption" sx={{ opacity: 0.8 }} > - {formatDateWithTz( - check.createdAt, - "ddd, MMMM D, YYYY, HH:mm A", - uiTimezone - )} + {formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)} ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index f91ad73e5..9d06a99ac 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -5,12 +5,11 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; -import type { RootState } from "@/store"; import type { CheckSnapshot } from "@/Types/Check"; import { formatDateWithTz } from "@/Utils/TimeUtils"; - -const CELLS = 25; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; +import { useStatusPageTheme } from "../StatusPageThemeProvider"; +const CELLS = MAX_RECENT_CHECKS; const MIN_HEIGHT_PCT = 6; export type BarKind = "up" | "down" | "empty"; @@ -34,7 +33,7 @@ export const ThemedHistogram = ({ statsGap = 1, }: Props) => { const { t } = useTranslation(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const { timezone } = useStatusPageTheme(); const { padded, max, avg, peak } = useMemo(() => { const source = checks.slice(-CELLS); @@ -83,7 +82,7 @@ export const ThemedHistogram = ({ {formatDateWithTz( check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", - uiTimezone + timezone )} diff --git a/client/src/Pages/Tags/components/TagsTable.tsx b/client/src/Pages/Tags/components/TagsTable.tsx new file mode 100644 index 000000000..ef7ebefaf --- /dev/null +++ b/client/src/Pages/Tags/components/TagsTable.tsx @@ -0,0 +1,109 @@ +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: ( + + {t("pages.common.monitors.actions.delete")} + + ), + action: async () => { + setSelectedTag(tag); + }, + closeMenu: true, + }, + ]; + }; + + const getHeaders = () => { + const headers: Header[] = [ + { + id: "name", + content: t("common.table.headers.name"), + render: (row) => { + return {row?.name}; + }, + }, + + { + id: "color", + content: t("pages.tags.table.headers.color"), + render: (row) => { + return {row?.color}; + }, + }, + { + id: "chip", + content: t("pages.tags.table.headers.appearance"), + render: (row) => { + return ( + + + + ); + }, + }, + + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => { + return ; + }, + }, + ]; + return headers; + }; + + return ( + <> +
{ + navigate(`/tags/configure/${row.id}`); + }} + /> + {tags.length > 0 && } + + ); +}; diff --git a/client/src/Pages/Tags/create/index.tsx b/client/src/Pages/Tags/create/index.tsx new file mode 100644 index 000000000..5e29096d0 --- /dev/null +++ b/client/src/Pages/Tags/create/index.tsx @@ -0,0 +1,118 @@ +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(isEditMode ? `/tags/${tagId}` : null); + + const { post, loading: isSubmitting } = usePost(); + const { patch, loading: isPatching } = usePatch(); + + const { schema, defaults } = useTagsForm({ data: existingTag }); + + const form = useForm({ + 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 ( + + ( + + )} + /> + } + /> + ( + + )} + /> + } + /> + + + + + + ); +}; + +export default TagsCreatePage; diff --git a/client/src/Pages/Tags/index.tsx b/client/src/Pages/Tags/index.tsx new file mode 100644 index 000000000..9509cfc4b --- /dev/null +++ b/client/src/Pages/Tags/index.tsx @@ -0,0 +1,72 @@ +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(null); + const isDialogOpen = Boolean(selectedTag); + + const { + data: tags, + isLoading, + isValidating, + error, + refetch, + } = useGet("/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 ( + + + + + + ); +}; + +export default TagsPage; diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index 0aaede394..b100c28c4 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -1,4 +1,9 @@ -import { Table, Pagination, StatusLabel } from "@/Components/design-elements"; +import { + Table, + Pagination, + StatusLabel, + StatusCodeLabel, +} from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { Check } from "@/Types/Check"; @@ -9,7 +14,25 @@ import { formatDateWithTz } from "@/Utils/TimeUtils"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; -const getHeaders = (t: Function, uiTimezone: string) => { +export const ChecksTable = ({ + checks, + count, + page, + setPage, + rowsPerPage, + setRowsPerPage, +}: { + checks: Check[]; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; +}) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const headers: Header[] = [ { id: "status", @@ -36,32 +59,15 @@ const getHeaders = (t: Function, uiTimezone: string) => { id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - return row.statusCode || "N/A"; + return ( + + ); }, }, ]; - return headers; -}; - -export const ChecksTable = ({ - checks, - count, - page, - setPage, - rowsPerPage, - setRowsPerPage, -}: { - checks: Check[]; - count: number; - page: number; - setPage: (page: number) => void; - rowsPerPage: number; - setRowsPerPage: (rowsPerPage: number) => void; -}) => { - const navigate = useNavigate(); - const { t } = useTranslation(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); - const headers = getHeaders(t, uiTimezone); const handlePageChange = ( _e: React.MouseEvent | null, diff --git a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx index 31ad6a3dd..373e09da2 100644 --- a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx @@ -1,4 +1,9 @@ -import { Table, Pagination, StatusLabel } from "@/Components/design-elements"; +import { + Table, + Pagination, + StatusLabel, + StatusCodeLabel, +} from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { FlatGeoCheck } from "@/Types/GeoCheck"; @@ -8,7 +13,24 @@ import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; import prettyMilliseconds from "pretty-ms"; -const getHeaders = (t: Function, uiTimezone: string) => { +export const GeoChecksTable = ({ + geoChecks, + count, + page, + setPage, + rowsPerPage, + setRowsPerPage, +}: { + geoChecks: FlatGeoCheck[]; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; +}) => { + const { t } = useTranslation(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const headers: Header[] = [ { id: "status", @@ -29,7 +51,7 @@ const getHeaders = (t: Function, uiTimezone: string) => { id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - return row.statusCode || "N/A"; + return ; }, }, { @@ -51,27 +73,6 @@ const getHeaders = (t: Function, uiTimezone: string) => { }, }, ]; - return headers; -}; - -export const GeoChecksTable = ({ - geoChecks, - count, - page, - setPage, - rowsPerPage, - setRowsPerPage, -}: { - geoChecks: FlatGeoCheck[]; - count: number; - page: number; - setPage: (page: number) => void; - rowsPerPage: number; - setRowsPerPage: (rowsPerPage: number) => void; -}) => { - const { t } = useTranslation(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); - const headers = getHeaders(t, uiTimezone); const handlePageChange = ( _e: React.MouseEvent | null, diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx index 77ce161f5..2c026c35d 100644 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -6,6 +6,7 @@ import { type Header, Pagination, StatusLabel, + ColoredLabel, } from "@/Components/design-elements"; import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/common"; import { ActionsMenu } from "@/Components/actions-menu"; @@ -16,13 +17,36 @@ import { useTheme } from "@mui/material/styles"; 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"; + +interface MonitorTableProps { + monitors: Monitor[]; + tags: Tag[]; + refetch: () => void; + setSelectedMonitor: (monitor: Monitor | null) => void; + sortField: string; + setSortField: (field: string) => void; + sortOrder: "asc" | "desc"; + setSortOrder: (order: "asc" | "desc") => void; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; + selectedRows?: string[]; + onSelectionChange?: (selected: string[]) => void; +} export const MonitorTable = ({ monitors, + tags, refetch, setSelectedMonitor, sortField, @@ -34,31 +58,35 @@ export const MonitorTable = ({ setPage, rowsPerPage, setRowsPerPage, -}: { - monitors: Monitor[]; - refetch: Function; - setSelectedMonitor: (monitor: Monitor | null) => void; - sortField: string; - setSortField: (field: string) => void; - sortOrder: "asc" | "desc"; - setSortOrder: (order: "asc" | "desc") => void; - count: number; - page: number; - setPage: (page: number) => void; - rowsPerPage: number; - setRowsPerPage: (rowsPerPage: number) => void; -}) => { + selectedRows = [], + onSelectionChange, +}: MonitorTableProps) => { const { t } = useTranslation(); const theme = useTheme(); const navigate = useNavigate(); const chartType = useSelector((state: RootState) => state.ui?.chartType ?? "histogram"); - const { - post, - // loading: isPosting, - // error: postError, - } = usePost(); + const { post } = usePost, Monitor>(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); - const handleSort = (e: any, field: string) => { + const selectedSet = new Set(selectedRows); + const isAllSelected = + monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id)); + const isSomeSelected = selectedRows.length > 0 && !isAllSelected; + + const handleSelectAll = (checked: boolean) => { + onSelectionChange?.(checked ? monitors.map((m) => m.id) : []); + }; + + const handleSelectRow = (id: string, checked: boolean) => { + if (!onSelectionChange) return; + onSelectionChange( + checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id) + ); + }; + + const isRowSelected = (id: string) => selectedSet.has(id); + + const handleSort = (e: React.MouseEvent, field: string) => { e.preventDefault(); e.stopPropagation(); if (sortField === field) { @@ -110,13 +138,6 @@ export const MonitorTable = ({ navigate(`/uptime/configure/${monitor.id}`); }, }, - // { - // id: 5, - // label: "Clone", - // action: () => { - - // }, - // }, { id: 6, label: @@ -160,8 +181,35 @@ export const MonitorTable = ({ ); const headers: Header[] = [ + { + id: "selection", + content: ( + handleSelectAll(e.target.checked)} + /> + ), + hideMobileLabel: true, + render: (row) => ( + { + handleSelectRow(row.id, e.target.checked); + }} + onClick={(e) => e.stopPropagation()} + /> + ), + onClick: (e) => e?.stopPropagation(), + }, { id: "name", + align: "left", + mobileLabel: t("common.table.headers.name"), content: ( { + if (row.tags.length === 0) return "-"; + + return ( + + {row.tags.map((tag) => { + const fullTag = tags.find((t) => t.id === tag); + if (!fullTag) return null; + return ( + + ); + })} + + ); + }, + }, { id: "type", + mobileLabel: t("common.table.headers.type"), content: ( { navigate(`/uptime/${row.id}`); }} + getRowSx={(row) => ({ + backgroundColor: isRowSelected(row.id) + ? theme.palette.action.selected + : "inherit", + })} /> { const { t } = useTranslation(); @@ -32,11 +41,13 @@ const UptimeMonitorsPage = () => { const [selectedTypes, setSelectedTypes] = useState([]); const [selectedStatus, setSelectedStatus] = useState(""); const [selectedState, setSelectedState] = useState(""); + const [selectedTags, setSelectedTags] = useState([]); const [page, setPage] = useState(0); const [search, setSearch] = useState(""); const [sortField, setSortField] = useState(""); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [selectedMonitor, setSelectedMonitor] = useState(null); + const isDialogOpen = Boolean(selectedMonitor); const debouncedSearch = useDebounce(search, 300); @@ -70,12 +81,13 @@ const UptimeMonitorsPage = () => { const effectiveTypes = selectedTypes.length > 0 ? selectedTypes - : ["http", "ping", "docker", "port", "game", "grpc", "websocket"]; + : ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"]; // Build URL for monitors with checks 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)); @@ -83,7 +95,7 @@ const UptimeMonitorsPage = () => { if (field) params.append("field", field); if (sortOrder) params.append("order", sortOrder); return `/monitors/team/with-checks?${params.toString()}`; - }, [effectiveTypes, page, rowsPerPage, filter, field, sortOrder]); + }, [effectiveTypes, selectedTags, page, rowsPerPage, filter, field, sortOrder]); const { data: monitorsWithChecksData, @@ -96,12 +108,23 @@ const UptimeMonitorsPage = () => { { refreshInterval: 5000, keepPreviousData: true } ); + const { data: tags } = useGet("/tags/team"); + const { monitors: monitorsWithChecks, summary, count, } = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 }; + // Bulk actions + const { + selectedRows, + setSelectedRows, + handleBulkPause, + handleBulkResume, + handleCancelSelection, + } = useBulkMonitorActions(refetch, page); + // Delete hook const { deleteFn, loading: isDeleting } = useDelete(); @@ -110,6 +133,7 @@ const UptimeMonitorsPage = () => { setSelectedTypes([]); setSelectedStatus(""); setSelectedState(""); + setSelectedTags([]); setSearch(""); }, []); @@ -128,7 +152,11 @@ const UptimeMonitorsPage = () => { // Check if any filters are active const hasActiveFilters = Boolean( - selectedTypes.length > 0 || selectedStatus || selectedState || search + selectedTypes.length > 0 || + selectedStatus || + selectedState || + selectedTags.length > 0 || + search ); // Show empty state only when there are truly no monitors (not just filtered out) @@ -167,6 +195,9 @@ const UptimeMonitorsPage = () => { setSelectedStatus={setSelectedStatus} selectedState={selectedState} setSelectedState={setSelectedState} + tagOptions={tags ?? []} + selectedTags={selectedTags} + setSelectedTags={setSelectedTags} onClearFilters={handleClearFilters} /> { }} /> + + {selectedTags.length > 0 && ( + + + {t("pages.uptime.filters.activeTags")} + + {selectedTags.map((tagId) => { + const tag = tags?.find((t) => t.id === tagId); + if (!tag) return null; + return ( + + ); + })} + + )} + + {!isLoading && ( + + + + + )} + { ); setPage(0); }} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} /> { path="notifications/configure/:notificationId" element={} /> + } + /> + } + /> + + } + /> { @@ -38,6 +44,10 @@ export type MonitorStatus = (typeof MonitorStatuses)[number]; export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; +export const PageSpeedStrategies = ["desktop", "mobile"] as const; +export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number]; +export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop"; + export interface Monitor { id: string; userId: string; @@ -60,6 +70,7 @@ export interface Monitor { interval: number; uptimePercentage?: number; notifications: string[]; + tags: string[]; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; @@ -72,10 +83,13 @@ export interface Monitor { selectedDisks: string[]; gameId?: string; grpcServiceName?: string; + strategy?: PageSpeedStrategy; group: string | null; geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + dnsServer?: string; + dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts index 9d5b914c0..0e389f9e2 100644 --- a/client/src/Types/Notification.ts +++ b/client/src/Types/Notification.ts @@ -9,6 +9,7 @@ export const NotificationChannels = [ "telegram", "pushover", "twilio", + "ntfy", ] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; @@ -25,6 +26,7 @@ export interface Notification { accessToken?: string; accountSid?: string; twilioPhoneNumber?: string; + topic?: string; createdAt: string; updatedAt: string; } diff --git a/client/src/Types/Queue.ts b/client/src/Types/Queue.ts index 77a770aeb..5a9ae7cdf 100644 --- a/client/src/Types/Queue.ts +++ b/client/src/Types/Queue.ts @@ -1,5 +1,14 @@ 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; @@ -9,21 +18,26 @@ export interface QueueJobFailure { failReason: string | null; } -export interface QueueMetrics { +export type QueueMetrics = { jobs: number; activeJobs: number; failingJobs: number; jobsWithFailures: QueueJobFailure[]; totalRuns: number; totalFailures: number; -} + workers: QueueWorker[]; +}; -export interface QueueJobSummary { +export type QueueJobSummary = { monitorId: string | number; monitorUrl: string | null; - monitorType: MonitorType | null; + monitorType: string | null; monitorInterval: number | null; + monitorGeoInterval: number | null; + monitorActive: boolean | null; active: boolean; + lockedBy: string | null; + lockedUntil: number | null; lockedAt: number | null; runCount: number; failCount: number; @@ -32,9 +46,11 @@ export interface QueueJobSummary { lastFinishedAt: number | null; lastRunTook: number | null; lastFailedAt: number | null; -} + repeat: number | null; +}; export interface QueueData { jobs: QueueJobSummary[]; + count: number; metrics: QueueMetrics; } diff --git a/client/src/Types/Settings.ts b/client/src/Types/Settings.ts index 28f7bf5ed..1338d49cb 100644 --- a/client/src/Types/Settings.ts +++ b/client/src/Types/Settings.ts @@ -12,6 +12,7 @@ export interface Settings { systemEmailHost?: string; systemEmailPort?: number; systemEmailAddress?: string; + systemEmailDisplayName?: string; systemEmailUser?: string; systemEmailConnectionHost?: string; systemEmailTLSServername?: string; diff --git a/client/src/Types/Tag.ts b/client/src/Types/Tag.ts new file mode 100644 index 000000000..bab6f740a --- /dev/null +++ b/client/src/Types/Tag.ts @@ -0,0 +1,8 @@ +export interface Tag { + id: string; + teamId: string; + name: string; + color: string; + createdAt: string; + updatedAt: string; +} diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index d0ede67e1..256d523de 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -10,6 +10,7 @@ export const getMonitorPath = (type: MonitorType): string => { game: "uptime", grpc: "uptime", websocket: "uptime", + dns: "uptime", unknown: "uptime", docker: "uptime", hardware: "infrastructure", diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts index 92c08cd2f..55dc1e26e 100644 --- a/client/src/Utils/Theme/Palette.ts +++ b/client/src/Utils/Theme/Palette.ts @@ -1,3 +1,5 @@ +import { alpha } from "@mui/material/styles"; + const typographyBase = 13; export const typographyLevels = { @@ -17,6 +19,9 @@ export const colors = { gray850: "#1c1c21", brandGreen: "#13715B", brandGreenLight: "#4DAF94", + green200: "#ECF7F2", + yellow200: "#FFF4E5", + orange500: "#FFA500", }; export const lightPalette = { @@ -29,6 +34,10 @@ export const lightPalette = { sidebar: { accent: colors.brandGreen, }, + rowStatus: { + running: colors.green200, + paused: colors.yellow200, + }, }; export const darkPalette = { @@ -45,4 +54,8 @@ export const darkPalette = { sidebar: { accent: colors.brandGreenLight, }, + rowStatus: { + running: alpha(colors.brandGreen, 0.18), + paused: alpha(colors.orange500, 0.18), + }, }; diff --git a/client/src/Utils/Theme/Theme.ts b/client/src/Utils/Theme/Theme.ts index 7c712d66d..04f4c94eb 100644 --- a/client/src/Utils/Theme/Theme.ts +++ b/client/src/Utils/Theme/Theme.ts @@ -15,9 +15,11 @@ declare module "@mui/material/styles" { } interface Palette { sidebar: { accent: string }; + rowStatus: { running: string; paused: string }; } interface PaletteOptions { sidebar?: { accent: string }; + rowStatus?: { running: string; paused: string }; } } diff --git a/client/src/Utils/Theme/constants.ts b/client/src/Utils/Theme/constants.ts index d718d61dc..518657183 100644 --- a/client/src/Utils/Theme/constants.ts +++ b/client/src/Utils/Theme/constants.ts @@ -1,4 +1,5 @@ export const SPACING = { + XXS: 0.25, XS: 0.5, SM: 1, MD: 1.5, diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index abd56ce44..5b63de87e 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -15,12 +15,33 @@ export const MS_PER_HOUR = 60 * MS_PER_MINUTE; export const MS_PER_DAY = 24 * MS_PER_HOUR; export const MS_PER_WEEK = MS_PER_DAY * 7; -export const formatDateWithTz = (timestamp: string, format: string, timezone: string) => { +const resolveSystemTimezone = (): string => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +}; + +export const SYSTEM_TIMEZONE = resolveSystemTimezone(); + +export const resolveTimezone = (preferred?: string | null): string => + preferred?.trim() || SYSTEM_TIMEZONE; + +export const formatDateWithTz = ( + timestamp: string, + format: string, + timezone?: string +) => { if (!timestamp) { return "Unknown time"; } - const formattedDate = dayjs(timestamp).tz(timezone).format(format); - return formattedDate; + const tz = resolveTimezone(timezone); + try { + return dayjs(timestamp).tz(tz).format(format); + } catch { + return dayjs(timestamp).utc().format(format); + } }; export const tickDateFormatLookup = (range: string) => { diff --git a/client/src/Utils/statusCode.ts b/client/src/Utils/statusCode.ts new file mode 100644 index 000000000..33a5f3de1 --- /dev/null +++ b/client/src/Utils/statusCode.ts @@ -0,0 +1,76 @@ +import type { ValueType } from "@/Components/design-elements/StatusLabel"; + +export const NETWORK_ERROR_CODE = 5000; + +export const NETWORK_ERROR_SUBTYPES = [ + "timeout", + "dns", + "refused", + "tls", + "reset", + "unknown", +] as const; +export type NetworkErrorSubtype = (typeof NETWORK_ERROR_SUBTYPES)[number]; + +type TranslateFn = (key: string) => string; + +export const isNetworkError = (code?: number | null): boolean => + code === NETWORK_ERROR_CODE; + +const ERROR_CODE_PATTERN = + /\b(E[A-Z_]{2,}|CERT_[A-Z_]+|UNABLE_TO_VERIFY[A-Z_]*|DEPTH_ZERO_SELF_SIGNED[A-Z_]*|SELF_SIGNED[A-Z_]*)\b/; + +export const extractErrorCode = (message?: string | null): string | null => { + if (!message) return null; + const match = message.match(ERROR_CODE_PATTERN); + return match ? match[1] : null; +}; + +export const getNetworkErrorSubtype = (message?: string | null): NetworkErrorSubtype => { + if (!message) return "unknown"; + const m = message.toUpperCase(); + if (m.includes("ETIMEDOUT") || m.includes("TIMEOUT") || m.includes("ESOCKETTIMEDOUT")) + return "timeout"; + if (m.includes("ENOTFOUND") || m.includes("EAI_AGAIN") || m.includes("DNS")) + return "dns"; + if (m.includes("ECONNREFUSED")) return "refused"; + if ( + m.includes("CERT_") || + m.includes("UNABLE_TO_VERIFY") || + m.includes("DEPTH_ZERO_SELF_SIGNED") || + m.includes("SELF_SIGNED") || + m.includes("TLS") || + m.includes("SSL") + ) + return "tls"; + if (m.includes("ECONNRESET")) return "reset"; + return "unknown"; +}; + +export const formatStatusCode = ( + code: number | null | undefined, + t: TranslateFn +): string => { + if (isNetworkError(code)) return t("common.statusCode.down"); + return String(code); +}; + +export const getStatusCodeTooltip = ( + code: number | null | undefined, + message: string | null | undefined, + t: TranslateFn +): string | null => { + if (!isNetworkError(code)) return null; + const description = t( + `common.statusCode.networkError.${getNetworkErrorSubtype(message)}` + ); + const errorCode = extractErrorCode(message); + return errorCode ? `${description} (${errorCode})` : description; +}; + +export const getStatusCodeValueType = (code: number): ValueType => { + if (isNetworkError(code)) return "negative"; + if (code < 300) return "positive"; + if (code < 400) return "neutral"; + return "negative"; +}; diff --git a/client/src/Validation/maintenanceWindow.ts b/client/src/Validation/maintenanceWindow.ts index 61ccf3c8d..dbe447e87 100644 --- a/client/src/Validation/maintenanceWindow.ts +++ b/client/src/Validation/maintenanceWindow.ts @@ -14,30 +14,32 @@ export const durationUnitOptions = [ { id: "days", name: "days", multiplier: 86400000 }, ] as const; -export const maintenanceWindowSchema = z - .object({ - name: z - .string() - .min(1, "Name is required") - .max(100, "Name must be at most 100 characters"), - repeat: z.string(), - startDate: z.string().min(1, "Start date is required"), - startTime: z.string().min(1, "Start time is required"), - duration: z.number().int().min(1, "Duration must be at least 1"), - durationUnit: z.string(), - monitors: z.array(z.string()).min(1, "At least one monitor is required"), - }) - .refine( - (data) => { - const startDateTime = dayjs(data.startDate) - .set("hour", parseInt(data.startTime.split(":")[0], 10)) - .set("minute", parseInt(data.startTime.split(":")[1], 10)); - return startDateTime.isAfter(dayjs()); - }, - { - message: "Start date and time must be in the future", - path: ["startDate"], - } - ); +const maintenanceWindowBaseSchema = z.object({ + name: z + .string() + .min(1, "Name is required") + .max(100, "Name must be at most 100 characters"), + repeat: z.string(), + startDate: z.string().min(1, "Start date is required"), + startTime: z.string().min(1, "Start time is required"), + duration: z.number().int().min(1, "Duration must be at least 1"), + durationUnit: z.string(), + monitors: z.array(z.string()).min(1, "At least one monitor is required"), +}); + +export const maintenanceWindowSchema = maintenanceWindowBaseSchema.refine( + (data) => { + const startDateTime = dayjs(data.startDate) + .set("hour", parseInt(data.startTime.split(":")[0], 10)) + .set("minute", parseInt(data.startTime.split(":")[1], 10)); + return startDateTime.isAfter(dayjs()); + }, + { + message: "Start date and time must be in the future", + path: ["startDate"], + } +); + +export const maintenanceWindowEditSchema = maintenanceWindowBaseSchema; export type MaintenanceWindowFormData = z.infer; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 9acffe6fe..80d0d77c4 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -1,5 +1,12 @@ import { z } from "zod"; +import type { FieldPath } from "react-hook-form"; import { GeoContinents } from "@/Types/GeoCheck"; +import { DnsRecordTypes, PageSpeedStrategies, type MonitorType } from "@/Types/Monitor"; + +// 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 }>(); // URL schema with custom error message const urlSchema = z.url({ message: "Please enter a valid URL" }); @@ -11,39 +18,60 @@ 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"), - notifications: z.array(z.string()), + 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 }), 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"), + .max(25, "Status window size must be at most 25") + .register(monitorStepRegistry, { step: 1 }), 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"), - geoCheckEnabled: z.boolean().optional(), - geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), + .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 }), geoCheckInterval: z .number() .min(300000, "Interval must be at least 5 minutes") - .optional(), -}); + .optional() + .register(monitorStepRegistry, { step: 2 }), +}; // HTTP monitor schema const httpSchema = baseSchema.extend({ type: z.literal("http"), url: urlSchema, - ignoreTlsErrors: z.boolean(), - useAdvancedMatching: z.boolean(), - matchMethod: z.enum(["equal", "include", "regex", ""]).optional(), - expectedValue: z.string().optional(), - jsonPath: z.string().optional(), + 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 }), + ...geoCheckFields, }); // Ping monitor schema const pingSchema = baseSchema.extend({ type: z.literal("ping"), url: z.string().min(1, "Host is required"), + ...geoCheckFields, }); // Port monitor schema @@ -82,13 +110,14 @@ 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(), + ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }), }); // PageSpeed monitor schema const pagespeedSchema = baseSchema.extend({ type: z.literal("pagespeed"), url: urlSchema, + strategy: z.enum(PageSpeedStrategies), }); // Hardware/Infrastructure monitor schema @@ -99,19 +128,23 @@ 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"), + .max(100, "CPU threshold must be at most 100") + .register(monitorStepRegistry, { step: 1 }), memoryAlertThreshold: z .number() .min(0, "Memory threshold must be at least 0") - .max(100, "Memory threshold must be at most 100"), + .max(100, "Memory threshold must be at most 100") + .register(monitorStepRegistry, { step: 1 }), diskAlertThreshold: z .number() .min(0, "Disk threshold must be at least 0") - .max(100, "Disk threshold must be at most 100"), + .max(100, "Disk threshold must be at most 100") + .register(monitorStepRegistry, { step: 1 }), tempAlertThreshold: z .number() .min(0, "Temperature threshold must be at least 0") - .max(150, "Temperature threshold must be at most 150"), + .max(150, "Temperature threshold must be at most 150") + .register(monitorStepRegistry, { step: 1 }), selectedDisks: z.array(z.string()), }); @@ -119,7 +152,30 @@ const hardwareSchema = baseSchema.extend({ const websocketSchema = baseSchema.extend({ type: z.literal("websocket"), url: z.string().min(1, "WebSocket URL is required"), - ignoreTlsErrors: z.boolean(), + ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }), +}); + +// Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, +// optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp). +// No scheme, port, path, or whitespace. Total length ≤ 253. +const hostnameRegex = + /^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; + +// DNS monitor schema +const dnsSchema = baseSchema.extend({ + type: z.literal("dns"), + url: z + .string() + .min(1, "Domain is required") + .regex(hostnameRegex, "Enter a valid domain (e.g. www.example.com)"), + dnsServer: z + .string() + .min(1, "DNS server is required") + .refine( + (v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, + "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)" + ), + dnsRecordType: z.enum(DnsRecordTypes), }); // Discriminated union of all monitor types @@ -133,10 +189,39 @@ export const monitorSchema = z.discriminatedUnion("type", [ pagespeedSchema, hardwareSchema, websocketSchema, + dnsSchema, ]); export type MonitorFormData = z.infer; +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, step: number][] => + Object.entries(optionForType(type)?.shape ?? {}).map(([name, field]) => [ + name as FieldPath, + 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[] => + 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, diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts index 9fa2b011a..6526fa0dd 100644 --- a/client/src/Validation/notifications.ts +++ b/client/src/Validation/notifications.ts @@ -70,6 +70,12 @@ 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, @@ -81,6 +87,7 @@ export const notificationSchema = z.discriminatedUnion("type", [ telegramSchema, pushoverSchema, twilioSchema, + ntfySchema, ]); export type NotificationFormData = z.infer; diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts index cf122f69d..edacd9e4d 100644 --- a/client/src/Validation/settings.ts +++ b/client/src/Validation/settings.ts @@ -32,6 +32,11 @@ export const settingsSchema = z.object({ .or(z.literal("")) .transform((val) => (val === "" ? null : val.toLowerCase().trim())) .optional(), + systemEmailDisplayName: z + .string() + .max(100, "Display name must be 100 characters or fewer") + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), systemEmailUser: z .string() .transform((val) => (val.trim() === "" ? null : val.trim())) diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index 8b41b7780..542f6b2ae 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -1,6 +1,14 @@ import { z } from "zod"; +import type { FieldPath } from "react-hook-form"; import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage"; +// 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 }>(); + export const statusPageSchema = z.object({ companyName: z .string() @@ -18,23 +26,51 @@ export const statusPageSchema = z.object({ type: z .array(z.enum(["uptime", "infrastructure"])) .min(1, "At least one type is required"), - color: z.string().min(1, "Color is required"), + color: z.string().min(1, "Color is required").register(statusPageStepRegistry, { + step: 1, + }), monitors: z.array(z.string()).min(1, "At least one monitor is required"), isPublished: z.boolean(), - 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(), + 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().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 }), logo: z .object({ data: z.string(), contentType: z.string(), }) .nullable() - .optional(), + .optional() + .register(statusPageStepRegistry, { step: 1 }), }); export type StatusPageFormData = z.infer; + +// The step each field is validated on, from the per-field +// `statusPageStepRegistry` metadata (unannotated fields default to step 0). +const fieldSteps = (): [name: FieldPath, step: number][] => + Object.entries(statusPageSchema.shape).map(([name, field]) => [ + name as FieldPath, + 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[] => + 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; diff --git a/client/src/Validation/tag.ts b/client/src/Validation/tag.ts new file mode 100644 index 000000000..96d4c6c94 --- /dev/null +++ b/client/src/Validation/tag.ts @@ -0,0 +1,10 @@ +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; diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 11fa81309..504fa5b49 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -73,7 +73,9 @@ "paused": "متوقف مؤقتاً", "na": "غير متوفر", "resolved": "تم الحل", - "responseTime": "وقت الاستجابة" + "responseTime": "وقت الاستجابة", + "idle": "خامل", + "running": "قيد التشغيل" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "نشط", "responseTime": "وقت الاستجابة" } + }, + "statusCode": { + "down": "متوقف", + "networkError": { + "dns": "تعذر تحليل اسم المضيف (فشل بحث DNS).", + "refused": "رفض الخادم الاتصال (المنفذ مغلق أو الخدمة لا تستمع).", + "reset": "تمت إعادة تعيين الاتصال قبل تلقي استجابة.", + "timeout": "لم يستجب الخادم في الوقت المحدد (انتهت مهلة الطلب).", + "tls": "فشل تأكيد اتصال TLS أو SSL (مشكلة في الشهادة أو التشفير).", + "unknown": "لم يتم تلقي أي استجابة HTTP من الهدف." + } } }, "components": { @@ -306,28 +319,28 @@ "common": { "passwordRules": { "length": { - "beginning": "", - "highlighted": "" + "beginning": "يجب أن يكون على الأقل", + "highlighted": "8 أحرف" }, "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": { @@ -421,7 +434,11 @@ "inviteMember": "دعوة عضو", "openSite": "فتح الموقع", "pause": "إيقاف مؤقت", - "resume": "استئناف" + "resume": "استئناف", + "selectAll": "تحديد جميع الشاشات", + "selectMonitor": "تحديد {{name}}", + "bulkSelected_one": "{{count}} شاشة محددة", + "bulkSelected_other": "{{count}} شاشات محددة" }, "statBoxes": { "activeFor": "نشط منذ", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "البنية التحتية", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "تم إيقاف {{count}} شاشة مؤقتاً", + "paused_other": "تم إيقاف {{count}} شاشات مؤقتاً", + "resumed_one": "تم استئناف {{count}} شاشة", + "resumed_other": "تم استئناف {{count}} شاشات", + "alreadyPaused_one": "الشاشة المحددة متوقفة مؤقتاً بالفعل", + "alreadyPaused_other": "جميع الشاشات المحددة متوقفة مؤقتاً بالفعل", + "alreadyRunning_one": "الشاشة المحددة قيد التشغيل بالفعل", + "alreadyRunning_other": "جميع الشاشات المحددة قيد التشغيل بالفعل", + "partialFailure": "تم تحديث {{successCount}} من أجهزة المراقبة، ولكن فشلت مزامنة {{failedCount}} مع قائمة انتظار الوظائف" } } }, @@ -469,9 +498,9 @@ }, "matchMethod": { "label": "طريقة المطابقة", - "equal": "", - "include": "", - "regex": "" + "equal": "يساوي", + "include": "يحتوي", + "regex": "تعبير عادي" } }, "title": "إعدادات متقدمة" @@ -483,15 +512,15 @@ "label": "تكرار الفحص", "value": { "fifteenMinutes": "15 دقيقة", - "fifteenSeconds": "", + "fifteenSeconds": "15 ثانية", "fiveMinutes": "5 دقائق", - "fourMinutes": "", - "oneMinute": "", + "fourMinutes": "4 دقائق", + "oneMinute": "دقيقة واحدة", "tenMinutes": "10 دقائق", "thirtyMinutes": "30 دقيقة", - "thirtySeconds": "", - "threeMinutes": "", - "twoMinutes": "" + "thirtySeconds": "30 ثانية", + "threeMinutes": "3 دقائق", + "twoMinutes": "دقيقتان" } } }, @@ -520,8 +549,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "اختر اللعبة", + "placeholder": "حدد لعبة" }, "port": { "label": "المنفذ المراد مراقبته", @@ -534,19 +563,44 @@ "wsUrl": { "label": "رابط WebSocket", "placeholder": "wss://example.com/socket" + }, + "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": "", - "port": "", - "docker": "", + "ping": "أدخل عنوان IP أو اسم المضيف للاتصال (على سبيل المثال، 192.168.1.100 أو example.com) وأضف اسماً واضحاً يظهر في لوحة المعلومات.", + "port": "أدخل عنوان URL أو IP للخادم ورقم المنفذ واسماً واضحاً يظهر في لوحة المعلومات.", + "docker": "أدخل اسم حاوية Docker أو المعرف. يمكنك استخدام اسم الحاوية (مثل my-app) أو معرف الحاوية (معرف كامل 64 حرف أو معرف قصير).", "game": "أدخل عنوان IP أو اسم المضيف ورقم المنفذ للاختبار (مثل 192.168.1.100 أو example.com) واختر نوع اللعبة. تتطلب بعض الألعاب تفعيل استعلامات الخادم في ملف الإعدادات الخاص بها — يُرجى مراجعة وثائق خادم اللعبة. اطّلع على قائمة الألعاب المدعومة لمعرفة تفاصيل البروتوكول.", "pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.", "grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.", "websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.", - "hardware": "مراقبة استخدام CPU والذاكرة والقرص ودرجة الحرارة للبنية التحتية الخاصة بك." + "hardware": "راقب استخدام وحدة المعالجة المركزية والذاكرة والقرص ودرجة الحرارة لبنيتك التحتية. يجب أن يتضمن عنوان URL لنقطة نهاية Capture المسار الكامل ‎/api/v1/metrics‎ (مثال: ‎http://192.168.1.10:5001/api/v1/metrics‎). بالنسبة لمفتاح التفويض، الصق سلسلة الرمز فقط — لا تُضمّن كلمة 'Bearer'.", + "dns": "راقب ما إذا كان خادم DNS محدد يستجيب." } }, "ignoreTls": { @@ -583,7 +637,8 @@ "optionHttpDescription": "استخدم HTTP(S) لمراقبة موقعك الإلكتروني أو نقطة نهاية API.", "optionPingDescription": "استخدم ICMP Ping لمراقبة ما إذا كان الخادم متصلاً.", "optionPortDescription": "مراقبة ما إذا كان منفذ معين على الخادم مفتوحاً.", - "title": "النوع" + "title": "النوع", + "optionDnsDescription": "راقب ما إذا كان خادم DNS محدد يستجيب." }, "thresholds": { "title": "حدود التنبيه", @@ -747,7 +802,7 @@ "headers": { "endTime": "وقت الانتهاء", "resolutionType": "نوع الحل", - "startTime": "", + "startTime": "وقت البدء", "statusCode": "رمز الحالة" }, "resolvedIncidents": "الحوادث المحلولة", @@ -791,13 +846,13 @@ } }, "statBoxes": { - "avgCpuTemperature": "", - "cpuFrequency": "", - "cpuLogical": "", - "cpuPhysical": "", + "avgCpuTemperature": "متوسط درجة حرارة المعالج", + "cpuFrequency": "تردد المعالج", + "cpuLogical": "المعالج (المنطقي)", + "cpuPhysical": "المعالج (الفعلي)", "disk": "القرص", "memory": "الذاكرة", - "os": "" + "os": "نظام التشغيل" }, "table": { "headers": { @@ -808,7 +863,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "الشبكة", "overview": "نظرة عامة" } }, @@ -891,7 +946,8 @@ "lastFinishedAt": "آخر انتهاء في", "lastRunTook": "مدة آخر تشغيل", "lastFailedAt": "آخر فشل في", - "failReason": "سبب الفشل" + "failReason": "سبب الفشل", + "state": "الحالة" } }, "queueFallback": { @@ -911,8 +967,8 @@ }, "table": { "headers": { - "nextWindow": "", - "repeat": "" + "nextWindow": "النافذة التالية", + "repeat": "تكرار" } }, "form": { @@ -1014,7 +1070,7 @@ }, "telegram": { "title": "تهيئة Telegram", - "description": "", + "description": "قم بتكوين بوت Telegram الخاص بك لإرسال الإشعارات.", "optionBotToken": "رمز البوت", "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", "optionChatId": "معرّف المحادثة", @@ -1081,6 +1137,10 @@ "header": { "title": "مراقبات PageSpeed", "description": "تابع Core Web Vitals ودرجات Lighthouse عبر الزمن لاكتشاف التراجع قبل أن يلاحظه المستخدمون." + }, + "strategy": { + "desktop": "سطح المكتب", + "mobile": "الجوال" } }, "settings": { @@ -1183,7 +1243,7 @@ "email": { "title": "إعدادات البريد الإلكتروني", "description": "تهيئة إعدادات البريد الإلكتروني لنظامك. يُستخدم لإرسال الإشعارات والتنبيهات.", - "descriptionTransport": "", + "descriptionTransport": "هذا يبني نقل SMTP لـ NodeMailer", "descriptionTransportLink": "اطلع على المواصفات هنا", "option": { "host": { @@ -1229,6 +1289,10 @@ }, "rejectUnauthorized": { "label": "رفض الشهادات غير الصالحة: رفض الاتصالات ذات الشهادات الموقعة ذاتياً أو غير الموثوقة" + }, + "displayName": { + "label": "اسم المرسل (اختياري) - الاسم الودي المعروض في حقل المرسل", + "placeholder": "إشعارات Checkmate" } } }, @@ -1289,6 +1353,20 @@ "uptime": { "title": "وقت التشغيل", "responseTime": "وقت الاستجابة" + }, + "chart": { + "avg": "متوسط {{value}} مللي ثانية", + "downTooltip": "متوقف", + "heatmapAria": "خريطة حرارية لزمن الاستجابة", + "max": "أقصى {{value}} مللي ثانية" + }, + "status": { + "breached": "تم تجاوز الحد", + "down": "متوقف", + "initializing": "قيد التهيئة", + "maintenance": "صيانة", + "paused": "متوقف مؤقتاً", + "up": "قيد التشغيل" } }, "statusBar": { @@ -1306,7 +1384,9 @@ "maintenance": "بعض الأنظمة قيد الصيانة", "partiallyPaused": "بعض الأنظمة متوقفة مؤقتًا", "initializing": "بعض الأنظمة قيد التهيئة", - "noMonitors": "لم يتم تكوين أي شاشة" + "noMonitors": "لم يتم تكوين أي شاشة", + "monitoringSummary": "مراقبة {{count}} خدمة · يتم التحديث كل 10 ثوانٍ", + "monitoringSummary_other": "مراقبة {{count}} خدمات · يتم التحديث كل 10 ثوانٍ" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "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": { diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json new file mode 100644 index 000000000..9760e7157 --- /dev/null +++ b/client/src/locales/ca.json @@ -0,0 +1,1545 @@ +{ + "common": { + "auth": { + "roles": { + "admin": "Administrador", + "demo": "Demo", + "superadmin": "Superadministrador", + "user": "Usuari" + } + }, + "alerts": { + "pageSpeedApiKey": { + "content": "Avís: Encara no heu afegit una clau API de Google PageSpeed. Aneu a Configuració per afegir-ne una. Sense ella, el monitor PageSpeed no funcionarà." + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "Detalls", + "home": "Inici" + }, + "buttons": { + "addMember": "Afegir membre", + "cancel": "Cancel·lar", + "close": "Tancar", + "configure": "Configurar", + "confirm": "Confirmar", + "create": "Crear", + "delete": "Eliminar", + "generateToken": "Generar token", + "incidents": "Incidents", + "inviteMember": "Convidar membre", + "pause": "Pausar", + "resume": "Reprendre", + "save": "Desar", + "sendInvite": "Enviar convit", + "test": "Provar", + "testNotifications": "Provar notificacions", + "toggleTheme": "Canviar tema clar/fosc", + "flushQueue": "Buidar cua", + "notFound": "Anar al tauler principal", + "resetPassword": "Restablir contrasenya", + "reset": "Restablir", + "clear": "Netejar", + "addDemo": "Afegir monitors de demostració", + "removeMonitors": "Eliminar monitors", + "sendTestEmail": "Enviar correu de prova", + "exportToJSON": "Exportar a JSON", + "importFromJSON": "Importar des de JSON", + "clearFilters": "Netejar filtres", + "removeUser": "Eliminar usuari" + }, + "charts": { + "labels": { + "averageResponseTime": "Temps de resposta mitjà", + "downtime": "Temps d'inactivitat", + "high": "Alt", + "low": "Baix", + "uptime": "Temps en línia" + }, + "histogram": { + "avg": "Mitjana: {{value}} ms", + "max": "Màx: {{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "Aquesta acció no es pot desfer.", + "title": "Esteu segur que voleu eliminar això?" + } + }, + "labels": { + "active": "Actiu", + "paused": "pausat", + "na": "N/D", + "resolved": "Resolt", + "responseTime": "Temps de resposta", + "idle": "Inactiu", + "running": "En execució" + }, + "form": { + "role": { + "option": { + "role": { + "label": "Rols" + } + } + }, + "name": { + "option": { + "firstName": { + "label": "Nom", + "placeholder": "Introduïu el vostre nom" + }, + "lastName": { + "label": "Cognoms", + "placeholder": "Introduïu els vostres cognoms" + } + } + }, + "email": { + "option": { + "email": { + "label": "Correu electrònic", + "placeholder": "vosaltres@exemple.com" + } + } + } + }, + "table": { + "empty": "Encara no hi ha res per mostrar", + "headers": { + "actions": "Accions", + "dateTime": "Data i hora", + "message": "Missatge", + "monitor": "Monitor", + "monitorId": "ID del monitor", + "name": "Nom", + "status": "Estat", + "type": "Tipus", + "url": "URL", + "interval": "Interval", + "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": { + "pageHeader": { + "links": { + "support": "Suport", + "discussions": "Discussions", + "docs": "Documentació", + "changelog": "Registre de canvis" + } + }, + "imageUpload": { + "clickToUpload": "Feu clic per pujar", + "dragAndDrop": "o arrossegueu i deixeu anar", + "supportedFormats": "Formats admesos", + "maxSize": "Mida màxima", + "orDragAndDrop": "o arrossegueu i deixeu anar", + "errors": { + "invalidFileSize": "La mida del fitxer és massa gran!", + "invalidFileFormat": "Format de fitxer no admès!" + } + }, + "headerStatusPageControls": { + "publicLink": "Enllaç públic" + }, + "headerTimeRange": { + "labels": { + "day": "Dia", + "month": "Mes", + "recent": "Recent", + "week": "Setmana" + }, + "description": { + "recent": "Mostrant estadístiques de les darreres 2 hores.", + "day": "Mostrant estadístiques de les darreres 24 hores.", + "week": "Mostrant estadístiques dels darrers 7 dies.", + "month": "Mostrant estadístiques dels darrers 30 dies." + } + }, + "offlineBanner": { + "serverUnreachable": "No es pot arribar al servidor", + "retry": "Tornar a intentar", + "retrying": "Reintentant...", + "reconnected": "Connexió restablerta" + }, + "sidebar": { + "menu": { + "uptime": "Temps en línia", + "pagespeed": "Velocitat de pàgina", + "infrastructure": "Infraestructura", + "notifications": "Notificacions", + "checks": "Comprovacions", + "incidents": "Incidents", + "statusPages": "Pàgines d'estat", + "maintenance": "Manteniment", + "logs": "Registres", + "settings": "Configuració" + }, + "bottomMenu": { + "support": "Suport", + "discussions": "Discussions", + "docs": "Docs", + "changelog": "Novetats" + }, + "accountMenu": { + "profile": "Perfil", + "password": "Contrasenya", + "team": "Equip" + }, + "starPrompt": { + "title": "Dona una estrella a Checkmate", + "description": "Veu les darreres versions i ajuda a fer créixer la comunitat a GitHub" + }, + "authFooter": { + "navControls": "Controls", + "logOut": "Tancar sessió", + "roles": { + "superAdmin": "Superadministrador", + "admin": "Administrador", + "user": "Usuari", + "demoUser": "Usuari de demostració" + } + } + }, + "starPrompt": { + "title": "Dona una estrella a Checkmate", + "description": "Veu les darreres versions i ajuda a fer créixer la comunitat a GitHub" + } + }, + "pages": { + "notFound": { + "title": "Oh no! Has deixat caure el teu sushi!", + "subtitle": "O bé la URL no existeix, o bé no tens accés." + }, + "account": { + "tabs": { + "profile": "Perfil", + "password": "Contrasenya", + "team": "Equip" + }, + "form": { + "name": { + "title": "Nom", + "description": "Actualitza la teva informació personal" + }, + "photo": { + "title": "Foto de perfil", + "description": "Puja una foto de perfil" + }, + "currentPassword": { + "title": "Contrasenya actual", + "description": "Introdueix la teva contrasenya actual per verificar la teva identitat", + "option": { + "label": "Contrasenya actual", + "placeholder": "Introdueix la contrasenya actual" + } + }, + "newPassword": { + "title": "Nova contrasenya", + "description": "Tria una contrasenya forta amb almenys 8 caràcters", + "option": { + "newPassword": { + "label": "Nova contrasenya", + "placeholder": "Introdueix la nova contrasenya" + }, + "confirm": { + "label": "Confirmar contrasenya", + "placeholder": "Confirma la nova contrasenya" + } + } + }, + "deleteAccount": { + "title": "Eliminar compte", + "description": "Aquesta acció és permanent i no es pot desfer" + } + }, + "team": { + "fallback": { + "title": "Encara no hi ha membres a l'equip", + "description": "Convida companys per correu electrònic o afegeix-los directament. Un cop s'uneixin, apareixeran aquí amb el seu rol." + }, + "filter": { + "placeholder": "Filtrar per rol", + "all": "Tots els rols", + "admin": "Administrador", + "member": "Membre" + }, + "table": { + "headers": { + "email": "Correu electrònic", + "role": "Rol", + "created": "Creat el" + } + }, + "addMember": { + "title": "Registrar nou membre de l'equip", + "description": "Crea un nou compte d'usuari. Comparteix les credencials de forma segura després de la creació." + }, + "invite": { + "title": "Convidar membre a l'equip", + "description": "Envia una invitació per unir-se al teu equip", + "email": { + "label": "Adreça de correu electrònic", + "placeholder": "Introdueix l'adreça de correu" + }, + "role": { + "label": "Rol", + "placeholder": "Selecciona un rol" + }, + "linkLabel": "Enllaç d'invitació" + } + }, + "header": { + "title": "Compte", + "description": "Gestiona el teu perfil, contrasenya, equip i preferències de l'espai de treball." + } + }, + "auth": { + "brandPanel": { + "eyebrow": "Monitoratge d'infraestructura de codi obert", + "tagline": "Vigila cada moviment dels teus servidors.", + "description": "Seguiment en temps real del temps en línia, temps de resposta i incidents — allotjat per tu i sempre teu." + }, + "common": { + "passwordRules": { + "length": { + "beginning": "Ha de tenir almenys", + "highlighted": "8 caràcters" + }, + "lowercase": { + "beginning": "Ha de contenir almenys", + "highlighted": "una lletra minúscula" + }, + "match": { + "beginning": "Les contrasenyes", + "highlighted": "han de coincidir" + }, + "number": { + "beginning": "Ha de contenir almenys", + "highlighted": "un número" + }, + "special": { + "beginning": "Ha de contenir almenys", + "highlighted": "un caràcter especial (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" + }, + "uppercase": { + "beginning": "Ha de contenir almenys", + "highlighted": "una lletra majúscula" + } + }, + "form": { + "option": { + "email": { + "label": "Correu electrònic", + "placeholder": "jo@exemple.com" + }, + "password": { + "label": "Contrasenya", + "placeholder": "********" + }, + "confirmPassword": { + "label": "Confirmar contrasenya" + } + } + } + }, + "login": { + "title": "Benvingut de nou a Checkmate!", + "subtitle": "Inicia sessió per continuar", + "submit": "Iniciar sessió", + "links": { + "forgotPassword": { + "text": "Has oblidat la contrasenya?", + "linkText": "Restablir contrasenya" + }, + "register": { + "text": "No tens un compte?", + "linkText": "Registra't aquí" + } + } + }, + "register": { + "title": "Benvingut a Checkmate!", + "subtitle": "Registra't per començar", + "setupNotice": "Aquesta pàgina només és accessible durant la configuració inicial. Un cop registrat el superadministrador, els usuaris addicionals es poden convidar des de la configuració de l'equip.", + "submit": "Registrar" + }, + "forgotPassword": { + "title": "Has oblidat la teva contrasenya?", + "subtitle": "No et preocupis, t'enviarem instruccions per restablir-la.", + "submit": "Sol·licitar recuperació", + "links": { + "login": { + "text": "Torna a", + "linkText": "iniciar sessió" + } + } + }, + "setNewPassword": { + "title": "Restableix la teva contrasenya", + "subtitle": "La teva nova contrasenya ha de ser diferent de les contrasenyes usades anteriorment." + } + }, + "checks": { + "fallback": { + "title": "Encara no hi ha comprovacions", + "description": "Un cop creïs un monitor, cada sonda d'estat i temps de resposta apareixerà aquí com a comprovació." + }, + "selects": { + "monitor": { + "all": "Tots els monitors" + }, + "status": { + "all": "Tots", + "down": "Caigut", + "up": "En línia" + } + }, + "table": { + "empty": "Cap comprovació caiguda en aquest interval de temps", + "headers": { + "statusCode": "Codi d'estat", + "location": "Ubicació" + } + }, + "header": { + "title": "Comprovacions", + "description": "Cada sonda que Checkmate executa contra els teus monitors apareix aquí, amb codi d'estat i temps de resposta." + } + }, + "common": { + "monitors": { + "actions": { + "configure": "Configurar", + "delete": "Eliminar", + "generateToken": "Generar token", + "details": "Detalls", + "incidents": "Incidents", + "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}}" + }, + "statBoxes": { + "activeFor": "Actiu des de", + "certificateExpiry": "Caducitat del certificat", + "lastCheck": "Última comprovació", + "lastResponseTime": "Últim temps de resposta", + "serviceIsDown": "El servei és caigut" + }, + "status": { + "down": "caigut", + "breached": "llindar superat", + "initializing": "inicialitzant", + "maintenance": "manteniment", + "paused": "pausat", + "total": "total", + "up": "en línia" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "Joc", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "Port", + "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" + } + } + }, + "createMonitor": { + "form": { + "advanced": { + "description": "Configuració opcional per a casos d'ús avançats", + "option": { + "advancedMatching": { + "label": "Usar coincidència avançada" + }, + "expectedValue": { + "label": "Valor esperat" + }, + "jsonPath": { + "description": "Aquesta expressió s'avaluarà contra les dades JSON de resposta i el resultat s'usarà per coincidir amb el valor esperat. Vegeu jmespath.org per a la documentació del llenguatge de consulta.", + "label": "Expressió JSONPath" + }, + "matchMethod": { + "label": "Mètode de coincidència", + "equal": "Igual", + "include": "Inclou", + "regex": "Regex" + } + }, + "title": "Configuració avançada" + }, + "frequency": { + "description": "Amb quina freqüència voleu comprovar l'estat d'aquest monitor?", + "option": { + "frequency": { + "label": "Freqüència de comprovació", + "value": { + "fifteenMinutes": "15 minuts", + "fifteenSeconds": "15 segons", + "fiveMinutes": "5 minuts", + "fourMinutes": "4 minuts", + "oneMinute": "1 minut", + "tenMinutes": "10 minuts", + "thirtyMinutes": "30 minuts", + "thirtySeconds": "30 segons", + "threeMinutes": "3 minuts", + "twoMinutes": "2 minuts" + } + } + }, + "title": "Freqüència de comprovació" + }, + "general": { + "option": { + "container": { + "label": "Nom/ID del contenidor", + "placeholder": "my-app o abcd1234" + }, + "host": { + "label": "Host", + "placeholder": "192.168.1.100 o example.com" + }, + "name": { + "label": "Nom de visualització", + "placeholder": "p. ex. El meu lloc web" + }, + "secret": { + "label": "Secret d'autorització", + "placeholder": "Introdueix la teva clau secreta" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "Escull el joc", + "placeholder": "Selecciona un joc" + }, + "port": { + "label": "Port a monitorar", + "placeholder": 80 + }, + "grpcServiceName": { + "label": "Nom del servei", + "placeholder": "p. ex. my.service.v1 (deixeu buit per a la salut general)" + }, + "wsUrl": { + "label": "URL de WebSocket", + "placeholder": "wss://example.com/socket" + }, + "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", + "description": { + "http": "Introdueix la URL o IP a monitorar (p. ex., https://example.com/ o 192.168.1.100) i afegeix un nom de visualització clar que aparegui al tauler.", + "ping": "Introdueix l'adreça IP o el nom d'host per fer ping (p. ex., 192.168.1.100 o example.com) i afegeix un nom de visualització clar que aparegui al tauler.", + "port": "Introdueix la URL o IP del servidor, el número de port i un nom de visualització clar que aparegui al tauler.", + "docker": "Introdueix el nom o ID del contenidor Docker. Pots usar el nom del contenidor (p. ex., my-app) o l'ID del contenidor (ID complet de 64 caràcters o ID curt).", + "game": "Introdueix l'adreça IP o el nom d'host i el número de port per fer ping (p. ex., 192.168.1.100 o example.com) i escull el tipus de joc. Alguns jocs requereixen activar les consultes del servidor al seu fitxer de configuració — consulta la documentació del teu servidor de joc. Consulta la llista de jocs admesos per obtenir detalls del protocol.", + "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." + } + }, + "ignoreTls": { + "description": "Configura la validació del certificat TLS/SSL per a connexions HTTPS.", + "option": { + "tls": { + "label": "Ignorar errors TLS/SSL" + } + }, + "title": "Configuració TLS/SSL" + }, + "incidents": { + "description": "S'usa una finestra lliscant per determinar quan un monitor cau. L'estat d'un monitor només canviarà quan el percentatge de comprovacions a la finestra lliscant arribi al valor especificat.", + "option": { + "checks": { + "label": "Nombre de comprovacions a la finestra lliscant" + }, + "percentage": { + "label": "Quin percentatge de comprovacions a la finestra lliscant fallen/réïxen abans que canviï l'estat del monitor?" + } + }, + "title": "Incidents" + }, + "notifications": { + "description": "Selecciona els canals de notificació que vols usar", + "title": "Notificacions" + }, + "type": { + "description": "Selecciona el tipus de comprovació a realitzar", + "optionDockerDescription": "Usa Docker per monitorar si un contenidor s'executa.", + "optionGameDescription": "Monitoritza si un servidor de joc específic està en línia.", + "optionGrpcDescription": "Monitoritza serveis gRPC usant el protocol estàndard de comprovació de salut.", + "optionWebSocketDescription": "Monitoritza endpoints de WebSocket per a la salut de la connexió i el temps de resposta.", + "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." + }, + "thresholds": { + "title": "Llindars d'alerta", + "description": "Defineix els llindars als quals s'han de desencadenar alertes per a aquest monitor de maquinari.", + "option": { + "cpuThreshold": { + "label": "Llindar d'alerta de CPU (%)" + }, + "memoryThreshold": { + "label": "Llindar d'alerta de memòria (%)" + }, + "diskThreshold": { + "label": "Llindar d'alerta de disc (%)" + }, + "tempThreshold": { + "label": "Llindar d'alerta de temperatura (°C)" + } + } + }, + "geoChecks": { + "title": "Comprovacions geodistribuïdes", + "description": "Executa comprovacions des de múltiples ubicacions geogràfiques per monitorar la disponibilitat i el rendiment globals.", + "option": { + "enabled": { + "label": "Activar comprovacions geodistribuïdes" + }, + "locations": { + "label": "Ubicacions", + "placeholder": "Selecciona ubicacions", + "options": { + "EU": "Europa", + "NA": "Amèrica del Nord", + "AS": "Àsia", + "SA": "Amèrica del Sud", + "AF": "Àfrica", + "OC": "Oceania" + } + }, + "interval": { + "label": "Interval de comprovació", + "value": { + "fiveMinutes": "5 minuts", + "tenMinutes": "10 minuts", + "fifteenMinutes": "15 minuts", + "thirtyMinutes": "30 minuts" + } + } + } + }, + "url": { + "title": "IP/URL del monitor a la pàgina d'estat", + "description": "Mostra l'adreça IP o URL del monitor a la pàgina d'estat pública. Si està desactivat, només es mostrarà el nom del monitor per protegir informació sensible.", + "option": { + "showURL": { + "label": "Mostrar IP/URL a la pàgina d'estat", + "enabled": "Activat", + "disabled": "Desactivat" + } + } + }, + "stats": { + "title": "Historial del monitor", + "description": "Neteja tot l'historial de monitoratge i les estadístiques del teu equip. Aquesta acció és irreversible.", + "buttonText": "Netejar totes les estadístiques", + "dialog": { + "title": "Netejar tot l'historial de monitoratge?", + "description": "Això eliminarà permanentment tot l'historial de monitoratge, les estadístiques i les dades de comprovació del teu equip. Aquesta acció no es pot desfer.", + "confirm": "Sí, netejar totes les estadístiques" + } + } + } + }, + "editUser": { + "form": { + "roles": { + "title": "Rols", + "description": "Assigna rols a l'usuari. Es poden seleccionar múltiples rols." + } + }, + "dialog": { + "removeUser": { + "title": "Eliminar usuari", + "content": "Esteu segur que voleu eliminar {{name}} del vostre equip? Aquesta acció no es pot desfer." + } + } + }, + "incidents": { + "fallback": { + "title": "Cap incident — tots els sistemes sans", + "description": "Quan un monitor cau o es recupera, l'incident apareixerà aquí amb el moment, els detalls del monitor i l'historial de resolució." + }, + "dialog": { + "resolveIncident": { + "title": "Resoldre incident", + "option": { + "comment": { + "label": "Comentari (opcional)", + "placeholder": "Afegeix un comentari sobre la resolució..." + } + } + }, + "details": { + "analysis": "Anàlisi de l'incident", + "comment": "Comentari:", + "detailsLabel": "Detalls", + "downtime": "Temps d'inactivitat:", + "message": "Missatge:", + "monitor": "Monitor:", + "overview": "Visió general", + "resolutionDetails": "Detalls de la resolució", + "resolutionType": "Tipus:", + "resolutionTypes": { + "automatic": "Automàtica", + "manual": "Manual" + }, + "resolve": "Resoldre incident", + "resolvedAt": "Resolt el:", + "resolvedBy": "Resolt per:", + "startedAt": "Iniciat el:", + "status": "Estat:", + "statusCode": "Codi d'estat:", + "timeline": "Línia de temps", + "title": "Detalls de l'incident", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "Tots els monitors", + "monitor": "Monitor", + "resolutionType": "Tipus de resolució", + "resolutionTypes": { + "manual": "Manual", + "automatic": "Automàtica", + "all": "Tots" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "Incidents actius", + "active_zero": "Cap incident actiu", + "active_one": "{{count}} incident actiu", + "active_other": "{{count}} incidents actius" + }, + "incidentStats": { + "avgResolutionTime": "Temps de resolució mitjà", + "mostAffectedMonitor": "Monitor més afectat", + "title": "Estadístiques d'incidents", + "totalIncidents": "Total d'incidents" + }, + "latestIncidents": { + "title": "Darrers incidents" + } + }, + "table": { + "actions": { + "details": "Detalls", + "goToMonitor": "Anar al monitor", + "resolveManually": "Resoldre manualment" + }, + "activeIncidents": "Incidents actius", + "headers": { + "endTime": "Hora de fi", + "resolutionType": "Tipus de resolució", + "startTime": "Hora d'inici", + "statusCode": "Codi d'estat" + }, + "resolvedIncidents": "Incidents resolts", + "status": { + "active": "Actiu", + "resolved": "Resolt" + } + }, + "header": { + "title": "Incidents", + "description": "Quan els monitors cauen o es recuperen, els incidents apareixen aquí amb el moment, els detalls del monitor i l'historial de resolució." + } + }, + "infrastructure": { + "charts": { + "labels": { + "cpu": "Ús de CPU", + "disk": "Ús de disc", + "memory": "Ús de memòria", + "netBytesRecv": "{{name}} - Bytes rebuts", + "netBytesSent": "{{name}} - Bytes enviats", + "netNoTraffic": "La captura informa de zero tràfic en totes les interfícies. Això és normal quan la captura s'executa dins d'un contenidor que només pot veure interfícies de loopback — executa la captura directament a l'amfitrió per veure l'activitat de xarxa real.", + "temp": "Temperatura" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "Freqüència màxima", + "title": "Ús de CPU", + "upperLabel": "Freqüència actual" + }, + "disk": { + "lowerLabel": "Lliure", + "title": "Ús del disc {{idx}}", + "upperLabel": "Usat" + }, + "memory": { + "lowerLabel": "Lliure", + "title": "Ús de memòria", + "upperLabel": "Usada" + } + }, + "statBoxes": { + "avgCpuTemperature": "Temperatura mitjana de CPU", + "cpuFrequency": "Freqüència de CPU", + "cpuLogical": "CPU (lògica)", + "cpuPhysical": "CPU (física)", + "disk": "Disc", + "memory": "Memòria", + "os": "SO" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "Disc", + "memory": "Memòria" + } + }, + "tabs": { + "labels": { + "network": "Xarxa", + "overview": "Visió general" + } + }, + "fallback": { + "actionButton": "Crear un monitor", + "description": "Fes un seguiment del rendiment del servidor, identifica colls d'ampolla i garanteix la fiabilitat amb monitoratge d'infraestructura en temps real.", + "title": "Encara no hi ha monitors d'infraestructura" + }, + "header": { + "title": "Monitors d'infraestructura", + "description": "Fes un seguiment del rendiment del servidor, identifica colls d'ampolla i garanteix la fiabilitat amb monitoratge d'infraestructura en temps real." + } + }, + "logs": { + "fallback": { + "title": "Encara no hi ha registres", + "description": "L'activitat del servidor, els avisos i els errors apareixeran aquí mentre s'executa Checkmate." + }, + "queueFallback": { + "title": "Cap feina a la cua", + "description": "Les comprovacions de monitor que estan programades o en execució apareixeran aquí. Una cua buida significa que tot és al dia." + }, + "tabs": { + "diagnostics": "Diagnòstics", + "logs": "Registres", + "queue": "Cua de feines" + }, + "logLevelSelect": { + "label": "Nivell de registre" + }, + "jobQueue": "Cua de feines", + "jobQueueExplainer": "Les files ressaltades són feines que s'estan processant per un treballador.", + "failedJobs": "Feines fallides", + "failedJobsExplainer": "Fallades recents per monitor — darrera vegada de fallada, recompte de fallades i el motiu.", + "noLogs": "No s'han trobat registres", + "metrics": { + "jobs": "Feines", + "activeJobs": "Feines actives", + "failingJobs": "Feines fallides", + "totalRuns": "Total d'execucions", + "totalFailures": "Total de fallades" + }, + "diagnostics": { + "sections": { + "runtimeStats": { + "title": "Estadístiques d'execució", + "description": "Instantània del procés Node.js i de la màquina amfitriona. S'actualitza cada 5 segons." + }, + "memoryCpu": { + "title": "Memòria i CPU", + "description": "Com el procés usa la seva memòria assignada i la CPU de l'amfitrió." + } + }, + "stats": { + "eventLoopDelay": "Retard del bucle d'esdeveniments", + "uptime": "Temps en funcionament", + "usedHeapSize": "Mida del heap usat", + "totalHeapSize": "Mida total del heap", + "osMemoryLimit": "Límit de memòria del SO" + }, + "gauges": { + "heapAllocation": "Assignació de heap", + "heapUsage": "Ús del heap", + "heapUtilization": "Utilització del heap", + "instantCpuUsage": "Ús instantani de CPU", + "availableMemoryPercentage": "% de memòria disponible", + "allocatedPercentage": "% assignat", + "usedSPercentage": "% d'1s usat per la CPU", + "total": "Total", + "used": "Usat" + } + }, + "table": { + "headers": { + "timestamp": "Marca de temps", + "level": "Nivell", + "service": "Servei", + "method": "Mètode", + "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" + } + }, + "header": { + "title": "Registres i cua", + "description": "Inspecciona els registres del servidor, la cua de feines del monitor i els diagnòstics d'execució en un sol lloc." + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "Crear una finestra de manteniment", + "description": "Programa temps d'inactivitat planificat perquè les alertes s'evitin i el teu equip i usuaris sàpiguen què esperar.", + "title": "Cap finestra de manteniment programada" + }, + "table": { + "headers": { + "nextWindow": "Propera finestra", + "repeat": "Repetició" + } + }, + "form": { + "general": { + "title": "Configuració general", + "description": "Estableix un nom i opció de repetició per a la teva finestra de manteniment.", + "option": { + "name": { + "label": "Nom", + "placeholder": "p. ex. Manteniment setmanal" + }, + "repeat": { + "label": "Repetició" + } + } + }, + "startDate": { + "title": "Data d'inici", + "description": "Selecciona la data d'inici per a la teva finestra de manteniment.", + "option": { + "startDate": { + "label": "Data d'inici" + } + } + }, + "startTime": { + "title": "Hora d'inici", + "description": "Estableix l'hora d'inici i la durada per a la teva finestra de manteniment. Tots els valors són en UTC.", + "option": { + "duration": { + "label": "Durada" + }, + "startTime": { + "label": "Hora d'inici" + } + }, + "monitors": { + "title": "Monitors", + "description": "Monitors als quals s'ha d'aplicar la finestra de manteniment", + "option": { + "addMonitors": { + "label": "Afegir monitors" + } + } + } + } + }, + "header": { + "title": "Finestres de manteniment", + "description": "Programa temps d'inactivitat planificat perquè les alertes s'evitin i el teu equip i usuaris sàpiguen què esperar." + } + }, + "notifications": { + "fallback": { + "actionButton": "Crear un canal", + "description": "Connecta Slack, Discord, correu electrònic, webhooks i més perquè les persones adequades s'assabentin dels incidents en el moment en què passen.", + "title": "Encara no hi ha canals de notificació" + }, + "form": { + "accessToken": { + "optionAccessToken": "Token d'accés", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "L'adreça on s'enviaran les notificacions.", + "optionAddress": "Adreça", + "placeholderEmail": "exemple@exemple.com", + "placeholderWebhook": "https://el-teu-servidor.com/webhook", + "title": "Adreça" + }, + "homeServer": { + "optionHomeServer": "Servidor principal", + "placeholder": "example.com" + }, + "matrix": { + "description": "Configura la connexió del teu servidor Matrix per a notificacions.", + "title": "Configuració de Matrix" + }, + "notificationName": { + "description": "Un nom descriptiu per al canal de notificació", + "optionName": "Nom del canal", + "placeholder": "p. ex. Alertes de producció", + "title": "Nom del canal" + }, + "pagerDuty": { + "description": "La teva clau d'integració PagerDuty per rebre alertes.", + "optionIntegrationKey": "Clau d'integració", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "Clau d'integració" + }, + "roomId": { + "optionRoomId": "ID de sala", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "Selecciona el tipus de canal de notificació a crear.", + "optionType": "Tipus", + "title": "Tipus de canal" + }, + "telegram": { + "title": "Configuració de Telegram", + "description": "Configura el teu bot de Telegram per enviar notificacions.", + "optionBotToken": "Token del bot", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "ID del xat", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Configuració de Pushover", + "description": "Configura Pushover per rebre notificacions push als teus dispositius.", + "optionAppToken": "Token de l'API de l'aplicació", + "placeholderAppToken": "azGDORePK8gMaC0QOYAMyEEuzJnyUi", + "optionUserKey": "Clau d'usuari", + "placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG" + }, + "twilio": { + "title": "Configuració de SMS de Twilio", + "description": "Configura Twilio per enviar notificacions SMS a un número de telèfon.", + "optionAccountSid": "SID del compte", + "placeholderAccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "optionAuthToken": "Token d'autenticació", + "placeholderAuthToken": "el_teu_token_auth", + "optionFromNumber": "Número d'origen (Twilio)", + "placeholderFromNumber": "+34931234567", + "optionToNumber": "Número de destinació (receptor)", + "placeholderToNumber": "+34939876543" + } + }, + "table": { + "headers": { + "destination": "Destinació" + } + }, + "header": { + "title": "Notificacions", + "description": "Connecta Slack, Discord, correu electrònic, webhooks i més perquè les persones adequades s'assabentin dels incidents en el moment en què passen." + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "Canvi acumulat de maquetació (CLS)", + "fcp": "Primera pintura amb contingut (FCP)", + "lcp": "Pintura amb contingut més gran (LCP)", + "si": "Índex de velocitat (SI)", + "tbt": "Temps total de bloqueig (TBT)" + }, + "legend": { + "title": "Informe de PageSpeed", + "weight": "Pes" + }, + "pie": { + "title": "Informe de rendiment" + } + }, + "table": { + "headers": { + "pageSpeedScore": "Puntuació de PageSpeed" + } + }, + "fallback": { + "actionButton": "Crear un monitor", + "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.", + "title": "Encara no hi ha monitors de PageSpeed" + }, + "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": { + "form": { + "timezone": { + "title": "Zona horària de visualització", + "description": "Selecciona la zona horària que s'usa per mostrar dates i hores a tota l'aplicació.", + "option": { + "timezone": { + "label": "Zona horària de visualització" + } + } + }, + "ui": { + "title": "Aparença", + "description": "Canvia entre el mode clar i fosc, canvia l'idioma o personalitza el tipus de gràfic.", + "option": { + "theme": { + "label": "Mode del tema", + "light": "Clar", + "dark": "Fosc" + }, + "language": { + "label": "Idioma" + }, + "chartType": { + "label": "Tipus de gràfic", + "histogram": "Histograma", + "heatmap": "Mapa de calor" + } + } + }, + "pagespeed": { + "title": "Clau API de Google PageSpeed", + "description": "Introdueix la teva clau API de Google PageSpeed per activar el monitoratge de Google PageSpeed. Fes clic a Restablir per actualitzar la clau.", + "option": { + "apiKey": { + "label": "Clau API de PageSpeed", + "labelSet": "La clau API està configurada. Fes clic a Restablir per canviar-la.", + "placeholder": "Introdueix la teva clau API de Google PageSpeed" + } + } + }, + "url": { + "title": "IP/URL del monitor a la pàgina d'estat", + "description": "Mostra l'adreça IP o URL del monitor a la pàgina d'estat pública. Si està desactivat, només es mostrarà el nom del monitor per protegir informació sensible.", + "option": { + "showURL": { + "label": "Mostrar IP/URL a la pàgina d'estat", + "enabled": "Activat", + "disabled": "Desactivat" + } + } + }, + "stats": { + "title": "Historial del monitor", + "description": "Neteja tot l'historial de monitoratge i les estadístiques del teu equip. Aquesta acció és irreversible.", + "option": { + "clear": { + "label": "Netejar totes les estadístiques. Això és irreversible." + } + }, + "dialog": { + "title": "Netejar tot l'historial de monitoratge?", + "description": "Això no es pot desfer" + } + }, + "retention": { + "title": "Retenció de comprovacions", + "description": "Estableix quant de temps es retenen les dades de comprovació abans de ser netejades automàticament.", + "option": { + "days": { + "label": "Període de retenció (dies)", + "unlimited": "Il·limitat" + } + } + }, + "thresholds": { + "title": "Llindars globals", + "description": "Estableix llindars globals de CPU, memòria, disc i temperatura per al monitoratge d'infraestructura. S'apliquen a tots els monitors de maquinari tret que se substitueixin.", + "option": { + "cpu": { + "label": "Llindar de CPU (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "Llindar de memòria (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "Llindar de disc (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "Llindar de temperatura (°C)", + "placeholder": "1 - 150" + } + } + }, + "email": { + "title": "Configuració de correu electrònic", + "description": "Configura els paràmetres de correu electrònic del teu sistema. S'usa per enviar notificacions i alertes.", + "descriptionTransport": "Això crea un transport SMTP per a NodeMailer", + "descriptionTransportLink": "Veu les especificacions aquí", + "option": { + "host": { + "label": "Servidor de correu — Nom d'host o adreça IP per connectar", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "Port de correu — Port per connectar", + "placeholder": "587" + }, + "address": { + "label": "Adreça de correu — Usada per a l'autenticació", + "placeholder": "tu@exemple.com" + }, + "displayName": { + "label": "Nom del remitent (opcional) — Nom amigable que apareix al camp De", + "placeholder": "Notificacions de Checkmate" + }, + "user": { + "label": "Usuari de correu — Nom d'usuari per a l'autenticació, substitueix l'adreça de correu si s'especifica", + "placeholder": "Deixa buit si no és necessari" + }, + "password": { + "label": "Contrasenya de correu — Contrasenya per a l'autenticació", + "labelSet": "La contrasenya està configurada. Fes clic a Restablir per canviar-la.", + "placeholder": "Introdueix la teva contrasenya" + }, + "tlsServername": { + "label": "Nom de servidor TLS — Nom d'host opcional per a la validació TLS quan el host és una IP", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "Host de connexió de correu — Nom d'host per usar a la salutació HELO/EHLO", + "placeholder": "localhost" + }, + "secure": { + "label": "Usar SSL (recomanat): Xifrar la connexió usant SSL/TLS" + }, + "pool": { + "label": "Activar agrupació de connexions: Reutilitzar connexions existents per millorar el rendiment" + }, + "ignoreTLS": { + "label": "Deshabilitar STARTTLS: No usar TLS encara que el servidor ho admeti" + }, + "requireTLS": { + "label": "Forçar STARTTLS: Requerir l'actualització TLS, fallar si no s'admet" + }, + "rejectUnauthorized": { + "label": "Rebutjar certificats no vàlids: Rebutjar connexions amb certificats autofirmats o no de confiança" + } + } + }, + "demoMonitors": { + "title": "Monitors de demostració", + "description": "Afegeix monitors de mostra amb finalitats de demostració." + }, + "removeMonitors": { + "title": "Restabliment del sistema", + "description": "Elimina tots els monitors del teu sistema.", + "dialog": { + "title": "Eliminar tots els monitors?", + "description": "Això no es pot desfer.", + "paragraph": "Tot l'historial de comprovacions, incidents, finestres de manteniment programades i qualsevol pàgina d'estat que referenciés aquests monitors també s'eliminarà. Exporta els teus monitors primer per conservar una còpia de seguretat de les seves dades." + } + }, + "exportMonitors": { + "title": "Exportar monitors" + }, + "importExportMonitors": { + "title": "Importar / Exportar monitors", + "description": "Importa o exporta les dades dels teus monitors com a fitxer JSON per a còpia de seguretat o transferència." + }, + "about": { + "title": "Quant a", + "developedBy": "Desenvolupat per Bluewave Labs" + }, + "validation": { + "errorMessage": "Si us plau, corregiu els errors de validació següents:" + } + }, + "header": { + "title": "Configuració", + "description": "Configura SMTP, integracions, retenció i el comportament general de l'aplicació." + } + }, + "statusPages": { + "deleteSuccess": "Pàgina d'estat eliminada correctament", + "fallback": { + "title": "Encara no hi ha pàgines d'estat", + "description": "Publica una pàgina pública que mostri el temps en línia en temps real i l'historial d'incidents als teus clients i parts interessades.", + "actionButton": "Crear una pàgina d'estat" + }, + "monitorsList": { + "chartTypeHeatmap": "Mapa de calor", + "chartTypeHistogram": "Histograma", + "noData": "No hi ha dades disponibles", + "status": { + "up": "Operatiu", + "down": "Caigut", + "breached": "Llindar superat", + "maintenance": "Manteniment", + "paused": "Pausat", + "initializing": "Inicialitzant" + }, + "chart": { + "avg": "mitj {{value}}ms", + "max": "màx {{value}}ms", + "downTooltip": "Caigut", + "heatmapAria": "Mapa de calor del temps de resposta" + }, + "infrastructure": { + "title": "Infraestructura", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "Memòria", + "disk": "Disc", + "usage": "Ús", + "used": "Usat", + "total": "Total" + }, + "uptime": { + "title": "Temps en línia", + "responseTime": "Temps de resposta" + } + }, + "statusBar": { + "monitoringSummary": "Monitorant {{count}} servei · S'actualitza cada 10 segons", + "monitoringSummary_other": "Monitorant {{count}} serveis · S'actualitza cada 10 segons", + "allUp": "Tots els sistemes operatius", + "allBreached": "Tots els sistemes han superat els llindars", + "allMaintenance": "Tots els sistemes estan en manteniment", + "allDown": "Tots els sistemes estan caiguts", + "allPaused": "Tots els sistemes estan pausats", + "allInitializing": "Tots els sistemes s'estan inicialitzant", + "breachedAndDown": "Alguns sistemes han superat els llindars i estan caiguts", + "breached": "Alguns sistemes han superat els llindars", + "degraded": "Alguns sistemes estan experimentant problemes", + "maintenanceAndDown": "Alguns sistemes estan caiguts durant el manteniment", + "maintenance": "Alguns sistemes estan en manteniment", + "partiallyPaused": "Alguns sistemes estan pausats", + "initializing": "Alguns sistemes s'estan inicialitzant", + "noMonitors": "No s'han configurat monitors", + "unknown": "No es pot determinar l'estat del sistema" + }, + "table": { + "headers": { + "name": "Nom de la pàgina d'estat", + "url": "URL pública" + }, + "published": "Publicada", + "unpublished": "No publicada" + }, + "title": "Pàgines d'estat", + "details": { + "empty": { + "title": "Encara no hi ha res aquí", + "addMonitor": "Afegeix un monitor per començar" + } + }, + "footer": { + "poweredBy": "Powered by" + }, + "editorial": { + "reportTitle": "Informe del servei", + "allUp": "Tots els sistemes funcionen amb normalitat." + }, + "form": { + "access": { + "title": "Accés", + "description": "Si la teva pàgina d'estat és llesta, pots marcar-la com a publicada.", + "option": { + "published": { + "name": "Publicada i visible al públic" + } + } + }, + "basicInfo": { + "title": "Informació bàsica", + "description": "Defineix el nom de l'empresa i el subdomini al qual apunta la teva pàgina d'estat.", + "option": { + "name": { + "label": "Nom de l'empresa", + "placeholder": "Acme S.A." + }, + "url": { + "label": "L'adreça de la teva pàgina d'estat", + "placeholder": "la-meva-pagina-destat" + } + } + }, + "monitors": { + "title": "Monitors", + "description": "Selecciona els monitors que es mostraran a la teva pàgina d'estat.", + "noMonitors": "Cap monitor seleccionat", + "option": { + "monitors": { + "label": "Seleccionar monitors", + "placeholder": "Cerca i selecciona monitors..." + } + } + }, + "timezone": { + "title": "Zona horària", + "description": "Selecciona la zona horària en què es mostrarà la teva pàgina d'estat.", + "option": { + "timezone": { + "label": "Zona horària", + "placeholder": "Selecciona la zona horària..." + } + } + }, + "appearance": { + "title": "Aparença", + "description": "Defineix l'aspecte predeterminat de la teva pàgina d'estat pública.", + "option": { + "logo": { + "label": "Logotip" + }, + "color": { + "label": "Color de marca" + } + } + }, + "theme": { + "title": "Tema de la pàgina", + "description": "Tria l'estil visual per a la teva pàgina d'estat pública.", + "label": "Tema", + "options": { + "refined": { + "name": "Refinat", + "description": "Net, professional, proper a l'aspecte clàssic de Checkmate." + }, + "modern": { + "name": "Modern", + "description": "De nivell SaaS amb un punt d'estat pulsant i targetes de desfosa suau." + }, + "bold": { + "name": "Atrevit", + "description": "Tipografia gran i espaiat confident. Ideal per a pàgines orientades a la marca." + }, + "editorial": { + "name": "Editorial", + "description": "Capçaleres amb serifa i separadors de línia fina. Distintiu i tranquil." + } + } + }, + "themeMode": { + "label": "Mode del tema", + "auto": "Automàtic (segueix el dispositiu del visitant)", + "light": "Només clar", + "dark": "Només fosc" + }, + "features": { + "title": "Funcionalitats", + "description": "Configura quina informació es mostra a la teva pàgina d'estat.", + "option": { + "showCharts": { + "label": "Mostrar gràfics de temps de resposta" + }, + "showUptimePercentage": { + "label": "Mostrar percentatge de temps en línia" + }, + "showAdminLoginLink": { + "label": "Mostrar l'enllaç d'inici de sessió de l'administrador" + }, + "showInfrastructure": { + "label": "Mostrar mètriques d'infraestructura" + } + } + } + }, + "header": { + "title": "Pàgines d'estat", + "description": "Publica pàgines públiques que mostrin el temps en línia en temps real i l'historial d'incidents als teus clients i parts interessades." + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "Cerca monitors..." + } + }, + "table": { + "headers": { + "responseTime": "Temps de resposta" + } + }, + "fallback": { + "actionButton": "Crear un monitor", + "description": "Vigila endpoints HTTP, pings, contenidors i ports — i rep alertes en el moment en que alguna cosa cau.", + "title": "Encara no hi ha monitors de temps en línia" + }, + "header": { + "title": "Monitors de temps en línia", + "description": "Vigila endpoints HTTP, pings, contenidors i ports — i rep alertes en el moment en que alguna cosa cau." + } + } + } +} diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index 454f0b673..9f7ba63d5 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -73,7 +73,9 @@ "paused": "pozastaveno", "na": "N/A", "resolved": "Vyřešeno", - "responseTime": "Doba odezvy" + "responseTime": "Doba odezvy", + "idle": "Nečinný", + "running": "Spuštěno" }, "form": { "role": { @@ -120,6 +122,17 @@ "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": { @@ -421,7 +434,11 @@ "inviteMember": "Pozvat člena", "openSite": "Otevřít stránku", "pause": "Pozastavit", - "resume": "Obnovit" + "resume": "Obnovit", + "selectAll": "Vybrat všechny monitory", + "selectMonitor": "Vybrat {{name}}", + "bulkSelected_one": "Vybrán {{count}} monitor", + "bulkSelected_other": "Vybráno {{count}} monitorů" }, "statBoxes": { "activeFor": "Aktivní po dobu", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infrastruktura", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "{{count}} monitor pozastaven", + "paused_other": "{{count}} monitorů pozastaveno", + "resumed_one": "{{count}} monitor obnoven", + "resumed_other": "{{count}} monitorů obnoveno", + "alreadyPaused_one": "Vybraný monitor je již pozastaven", + "alreadyPaused_other": "Všechny vybrané monitory jsou již pozastaveny", + "alreadyRunning_one": "Vybraný monitor již běží", + "alreadyRunning_other": "Všechny vybrané monitory již běží", + "partialFailure": "Bylo aktualizováno {{successCount}} monitorů, ale u {{failedCount}} se nezdařila synchronizace s frontou úloh" } } }, @@ -469,9 +498,9 @@ }, "matchMethod": { "label": "Metoda porovnání", - "equal": "", - "include": "", - "regex": "" + "equal": "Rovná se", + "include": "Obsahuje", + "regex": "Regulární výraz" } }, "title": "Pokročilá nastavení" @@ -483,15 +512,15 @@ "label": "Frekvence kontroly", "value": { "fifteenMinutes": "15 minut", - "fifteenSeconds": "", + "fifteenSeconds": "15 sekund", "fiveMinutes": "5 minut", - "fourMinutes": "", - "oneMinute": "", + "fourMinutes": "4 minuty", + "oneMinute": "1 minuta", "tenMinutes": "10 minut", "thirtyMinutes": "30 minut", - "thirtySeconds": "", - "threeMinutes": "", - "twoMinutes": "" + "thirtySeconds": "30 sekund", + "threeMinutes": "3 minuty", + "twoMinutes": "2 minuty" } } }, @@ -520,8 +549,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "Vybrat hru", + "placeholder": "Vyberte hru" }, "port": { "label": "Port ke sledování", @@ -534,19 +563,44 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "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": "", - "port": "", - "docker": "", + "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).", "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 seznamu podporovaných her.", "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." + "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á." } }, "ignoreTls": { @@ -583,7 +637,8 @@ "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" + "title": "Typ", + "optionDnsDescription": "Sledujte, zda konkrétní DNS server odpovídá." }, "thresholds": { "title": "Prahové hodnoty upozornění", @@ -747,7 +802,7 @@ "headers": { "endTime": "Čas ukončení", "resolutionType": "Typ řešení", - "startTime": "", + "startTime": "Čas zahájení", "statusCode": "Stavový kód" }, "resolvedIncidents": "Vyřešené incidenty", @@ -791,13 +846,13 @@ } }, "statBoxes": { - "avgCpuTemperature": "", - "cpuFrequency": "", - "cpuLogical": "", - "cpuPhysical": "", + "avgCpuTemperature": "Průměrná teplota CPU", + "cpuFrequency": "Frekvence CPU", + "cpuLogical": "CPU (logický)", + "cpuPhysical": "CPU (fyzický)", "disk": "Disk", "memory": "Paměť", - "os": "" + "os": "OS" }, "table": { "headers": { @@ -808,7 +863,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "Síť", "overview": "Přehled" } }, @@ -891,7 +946,8 @@ "lastFinishedAt": "Naposledy dokončeno", "lastRunTook": "Poslední běh trval", "lastFailedAt": "Poslední selhání", - "failReason": "Důvod selhání" + "failReason": "Důvod selhání", + "state": "Stav" } }, "queueFallback": { @@ -911,8 +967,8 @@ }, "table": { "headers": { - "nextWindow": "", - "repeat": "" + "nextWindow": "Další okno", + "repeat": "Opakovat" } }, "form": { @@ -1014,7 +1070,7 @@ }, "telegram": { "title": "Konfigurace Telegramu", - "description": "", + "description": "Nakonfigurujte svého Telegram bota pro odesílání oznámení.", "optionBotToken": "Token bota", "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", "optionChatId": "ID chatu", @@ -1081,6 +1137,10 @@ "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": { @@ -1183,7 +1243,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": "", + "descriptionTransport": "Toto vytvoří SMTP transport pro NodeMailer", "descriptionTransportLink": "Zobrazit specifikace zde", "option": { "host": { @@ -1229,6 +1289,10 @@ }, "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" } } }, @@ -1289,6 +1353,20 @@ "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": { @@ -1306,7 +1384,9 @@ "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" + "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" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "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": { diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 8090aa5f3..2c6a33ae4 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -73,7 +73,9 @@ "paused": "pausiert", "na": "N/A", "resolved": "Gelöst", - "responseTime": "Antwortzeit" + "responseTime": "Antwortzeit", + "idle": "Untätig", + "running": "Läuft" }, "form": { "role": { @@ -120,6 +122,17 @@ "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": { @@ -421,7 +434,11 @@ "inviteMember": "Mitglied einladen", "openSite": "Seite öffnen", "pause": "Pausieren", - "resume": "Fortsetzen" + "resume": "Fortsetzen", + "selectAll": "Alle Monitore auswählen", + "selectMonitor": "{{name}} auswählen", + "bulkSelected_one": "{{count}} Monitor ausgewählt", + "bulkSelected_other": "{{count}} Monitore ausgewählt" }, "statBoxes": { "activeFor": "Aktiv seit", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infrastruktur", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "{{count}} Monitor pausiert", + "paused_other": "{{count}} Monitore pausiert", + "resumed_one": "{{count}} Monitor fortgesetzt", + "resumed_other": "{{count}} Monitore fortgesetzt", + "alreadyPaused_one": "Der ausgewählte Monitor ist bereits pausiert", + "alreadyPaused_other": "Alle ausgewählten Monitore sind bereits pausiert", + "alreadyRunning_one": "Der ausgewählte Monitor wird bereits ausgeführt", + "alreadyRunning_other": "Alle ausgewählten Monitore werden bereits ausgeführt", + "partialFailure": "{{successCount}} Monitore aktualisiert, aber {{failedCount}} konnten nicht mit der Job-Warteschlange synchronisiert werden" } } }, @@ -534,6 +563,30 @@ "wsUrl": { "label": "WebSocket-URL", "placeholder": "wss://example.com/socket" + }, + "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", @@ -546,7 +599,8 @@ "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-, Arbeitsspeicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur." + "hardware": "Überwachen Sie CPU-, Speicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur. Die URL des Capture-Endpunkts muss den vollständigen Pfad /api/v1/metrics enthalten (z. B. http://192.168.1.10:5001/api/v1/metrics). Fügen Sie für das Autorisierungsgeheimnis nur den Token-String ein — geben Sie 'Bearer' nicht mit an.", + "dns": "Überwachen Sie, ob ein bestimmter DNS-Server antwortet." } }, "ignoreTls": { @@ -583,7 +637,8 @@ "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" + "title": "Typ", + "optionDnsDescription": "Überwachen Sie, ob ein bestimmter DNS-Server antwortet." }, "thresholds": { "title": "Alarmschwellenwerte", @@ -891,7 +946,8 @@ "lastFinishedAt": "Zuletzt abgeschlossen am", "lastRunTook": "Letzter Lauf dauerte", "lastFailedAt": "Letzter Fehler am", - "failReason": "Fehlergrund" + "failReason": "Fehlergrund", + "state": "Status" } }, "queueFallback": { @@ -1081,6 +1137,10 @@ "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": { @@ -1229,6 +1289,10 @@ }, "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" } } }, @@ -1289,6 +1353,20 @@ "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": { @@ -1306,7 +1384,9 @@ "maintenance": "Einige Systeme befinden sich in Wartung", "partiallyPaused": "Einige Systeme sind pausiert", "initializing": "Einige Systeme werden initialisiert", - "noMonitors": "Es wurden keine Monitore konfiguriert" + "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" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "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": { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 3c632ef96..0b78ba1e9 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -14,6 +14,17 @@ } }, "appName": "Checkmate", + "statusCode": { + "down": "Down", + "networkError": { + "timeout": "The server didn't respond in time (request timed out).", + "dns": "Couldn't resolve the hostname (DNS lookup failed).", + "refused": "The server refused the connection (port closed or service not listening).", + "tls": "TLS or SSL handshake failed (certificate or encryption issue).", + "reset": "The connection was reset before a response was received.", + "unknown": "No HTTP response was received from the target." + } + }, "breadcrumbs": { "details": "Details", "home": "Home" @@ -32,6 +43,8 @@ "pause": "Pause", "resume": "Resume", "save": "Save", + "next": "Next", + "back": "Back", "sendInvite": "Send invite", "test": "Test", "testNotifications": "Test notifications", @@ -70,7 +83,9 @@ }, "labels": { "active": "Active", - "paused": "paused", + "running": "Running", + "paused": "Paused", + "idle": "Idle", "na": "N/A", "resolved": "Resolved", "responseTime": "Response time" @@ -118,7 +133,8 @@ "url": "Url", "interval": "Interval", "active": "Active", - "responseTime": "Response time" + "responseTime": "Response time", + "tags": "Tags" } } }, @@ -171,6 +187,7 @@ "pagespeed": "Pagespeed", "infrastructure": "Infrastructure", "notifications": "Notifications", + "tags": "Tags", "checks": "Checks", "incidents": "Incidents", "statusPages": "Status pages", @@ -421,7 +438,22 @@ "inviteMember": "Invite member", "openSite": "Open site", "pause": "Pause", - "resume": "Resume" + "resume": "Resume", + "bulkSelected_one": "{{count}} monitor selected", + "bulkSelected_other": "{{count}} monitors selected", + "selectAll": "Select all monitors", + "selectMonitor": "Select {{name}}" + }, + "bulkPause": { + "alreadyPaused_one": "The selected monitor is already paused", + "alreadyPaused_other": "All selected monitors are already paused", + "alreadyRunning_one": "The selected monitor is already running", + "alreadyRunning_other": "All selected monitors are already running", + "paused_one": "{{count}} monitor paused", + "paused_other": "{{count}} monitors paused", + "resumed_one": "{{count}} monitor resumed", + "resumed_other": "{{count}} monitors resumed", + "partialFailure": "{{successCount}} monitors updated, but {{failedCount}} failed to sync with the job queue" }, "statBoxes": { "activeFor": "Active for", @@ -448,7 +480,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infrastructure", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" } } }, @@ -534,6 +567,30 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "strategy": { + "label": "Strategy", + "desktop": "Desktop", + "mobile": "Mobile" + }, + "captureUrl": { + "label": "Capture endpoint", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsServer": { + "label": "DNS server", + "placeholder": "8.8.8.8" + }, + "dnsRecordType": { + "label": "DNS record type", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } } }, "title": "General settings", @@ -546,7 +603,8 @@ "pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.", "grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.", "websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.", - "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure." + "hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure. The Capture endpoint URL must include the full path /api/v1/metrics (e.g. http://192.168.1.10:5001/api/v1/metrics). For the authorization secret, paste only the token string — don't include 'Bearer'.", + "dns": "Monitor if a specific DNS server is responding." } }, "ignoreTls": { @@ -574,6 +632,10 @@ "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.", @@ -583,6 +645,7 @@ "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", "optionPingDescription": "Use ICMP Ping to monitor if a server is online.", "optionPortDescription": "Monitor if a specific port on a server is open.", + "optionDnsDescription": "Monitor if a specific DNS server is responding.", "title": "Type" }, "thresholds": { @@ -822,6 +885,37 @@ "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", @@ -839,6 +933,8 @@ "logLevelSelect": { "label": "Log level" }, + "workers": "Workers", + "workersExplainers": "Shows worker processes. Worker IDs are truncated, hover for full ID", "jobQueue": "Job queue", "jobQueueExplainer": "Highlighted rows are jobs currently being processed by a worker.", "failedJobs": "Failed jobs", @@ -860,6 +956,14 @@ "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": { @@ -869,6 +973,21 @@ "totalHeapSize": "Total heap size", "osMemoryLimit": "OS memory limit" }, + "mongoDBStats": { + "readyState": "Ready state", + "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", @@ -883,6 +1002,12 @@ }, "table": { "headers": { + "host": "Host", + "processId": "Process ID", + "workerId": "Worker ID", + "workerMode": "Worker mode", + "lastSeenAt": "Last seen at", + "state": "State", "timestamp": "Timestamp", "level": "Level", "service": "Service", @@ -892,6 +1017,7 @@ "failCount": "Count", "lastRunAt": "Last run at", "lockedAt": "Locked at", + "lockedBy": "Locked by", "lastFinishedAt": "Last finished at", "lastRunTook": "Last run took", "lastFailedAt": "Last failed", @@ -1039,6 +1165,14 @@ "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": { @@ -1052,6 +1186,10 @@ } }, "pageSpeed": { + "strategy": { + "desktop": "Desktop", + "mobile": "Mobile" + }, "charts": { "common": { "cls": "Cumulative Layout Shift (CLS)", @@ -1198,6 +1336,10 @@ "label": "Email address - Used for authentication", "placeholder": "you@example.com" }, + "displayName": { + "label": "Sender name (optional) - Friendly name shown in the From field", + "placeholder": "Checkmate notifications" + }, "user": { "label": "Email user - Username for authentication, overrides email address if specified", "placeholder": "Leave empty if not required" @@ -1460,7 +1602,8 @@ "filters": { "search": { "placeholder": "Search monitors..." - } + }, + "activeTags": "Active tags" }, "table": { "headers": { diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 506c317e3..70dd557d0 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -47,14 +47,7 @@ "exportToJSON": "Exportar a JSON", "importFromJSON": "Importar desde JSON", "clearFilters": "Limpiar filtros", - "removeUser": "Eliminar usuario", - "testNotification": "Probar notificación", - "invite": "Invitar", - "copyLink": "Copiar enlace", - "regenerateToken": "Regenerar token", - "copyToken": "Copiar token", - "exportLogs": "Exportar registros", - "refresh": "Actualizar" + "removeUser": "Eliminar usuario" }, "charts": { "labels": { @@ -80,7 +73,9 @@ "paused": "pausado", "na": "N/D", "resolved": "Resuelto", - "responseTime": "Tiempo de respuesta" + "responseTime": "Tiempo de respuesta", + "idle": "Inactivo", + "running": "En ejecución" }, "form": { "role": { @@ -127,6 +122,17 @@ "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": { @@ -428,7 +434,11 @@ "inviteMember": "Invitar miembro", "openSite": "Abrir sitio", "pause": "Pausar", - "resume": "Reanudar" + "resume": "Reanudar", + "selectAll": "Seleccionar todos los monitores", + "selectMonitor": "Seleccionar {{name}}", + "bulkSelected_one": "{{count}} monitor seleccionado", + "bulkSelected_other": "{{count}} monitores seleccionados" }, "statBoxes": { "activeFor": "Activo desde hace", @@ -455,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infraestructura", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "{{count}} monitor pausado", + "paused_other": "{{count}} monitores pausados", + "resumed_one": "{{count}} monitor reanudado", + "resumed_other": "{{count}} monitores reanudados", + "alreadyPaused_one": "El monitor seleccionado ya está pausado", + "alreadyPaused_other": "Todos los monitores seleccionados ya están pausados", + "alreadyRunning_one": "El monitor seleccionado ya se está ejecutando", + "alreadyRunning_other": "Todos los monitores seleccionados ya se están ejecutando", + "partialFailure": "Se actualizaron {{successCount}} monitores, pero {{failedCount}} fallaron al sincronizarse con la cola de trabajos" } } }, @@ -541,6 +563,30 @@ "wsUrl": { "label": "URL de WebSocket", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Endpoint de Capture", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "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", @@ -553,7 +599,8 @@ "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": "Monitoree el uso de CPU, memoria, disco y temperatura de su infraestructura." + "hardware": "Supervisa el uso de CPU, memoria, disco y temperatura de tu infraestructura. La URL del endpoint de Capture debe incluir la ruta completa /api/v1/metrics (p. ej., http://192.168.1.10:5001/api/v1/metrics). Para el secreto de autorización, pega solo la cadena del token — no incluyas 'Bearer'.", + "dns": "Monitorea si un servidor DNS específico responde." } }, "ignoreTls": { @@ -590,7 +637,8 @@ "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" + "title": "Tipo", + "optionDnsDescription": "Monitorea si un servidor DNS específico responde." }, "thresholds": { "title": "Umbrales de alerta", @@ -898,7 +946,8 @@ "lastFinishedAt": "Última finalización", "lastRunTook": "Última ejecución tomó", "lastFailedAt": "Última falla", - "failReason": "Razón de falla" + "failReason": "Razón de falla", + "state": "Estado" } }, "queueFallback": { @@ -1088,6 +1137,10 @@ "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": { @@ -1236,6 +1289,10 @@ }, "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" } } }, @@ -1296,6 +1353,20 @@ "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": { @@ -1313,7 +1384,9 @@ "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" + "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" }, "table": { "headers": { @@ -1404,18 +1477,47 @@ "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." }, - "publicPage": { - "maintenance": { - "upcoming": "Mantenimiento programado", - "inProgress": "Mantenimiento en curso", - "completed": "Mantenimiento completado" - } + "editorial": { + "allUp": "Todos los sistemas funcionan con normalidad.", + "reportTitle": "Informe de servicio" + }, + "footer": { + "poweredBy": "Desarrollado por" } }, "uptime": { diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 6238837f2..3c34577a2 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -73,7 +73,9 @@ "paused": "keskeytetty", "na": "Ei saatavilla", "resolved": "Ratkaistu", - "responseTime": "Vasteaika" + "responseTime": "Vasteaika", + "idle": "Toimeton", + "running": "Käynnissä" }, "form": { "role": { @@ -120,6 +122,17 @@ "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": { @@ -306,28 +319,28 @@ "common": { "passwordRules": { "length": { - "beginning": "", - "highlighted": "" + "beginning": "Vähintään", + "highlighted": "8 merkkiä pitkä" }, "lowercase": { - "beginning": "", - "highlighted": "" + "beginning": "Sisällettävä vähintään", + "highlighted": "yksi pieni kirjain" }, "match": { - "beginning": "", - "highlighted": "" + "beginning": "Salasanat", + "highlighted": "täytyy täsmätä" }, "number": { - "beginning": "", - "highlighted": "" + "beginning": "Sisällettävä vähintään", + "highlighted": "yksi numero" }, "special": { - "beginning": "", - "highlighted": "" + "beginning": "Sisällettävä vähintään", + "highlighted": "yksi erikoismerkki (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" }, "uppercase": { - "beginning": "", - "highlighted": "" + "beginning": "Sisällettävä vähintään", + "highlighted": "yksi iso kirjain" } }, "form": { @@ -421,7 +434,11 @@ "inviteMember": "Kutsu jäsen", "openSite": "Avaa sivusto", "pause": "Keskeytä", - "resume": "Jatka" + "resume": "Jatka", + "selectAll": "Valitse kaikki näytöt", + "selectMonitor": "Valitse {{name}}", + "bulkSelected_one": "{{count}} näyttö valittu", + "bulkSelected_other": "{{count}} näyttöä valittu" }, "statBoxes": { "activeFor": "Aktiivinen", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infrastruktuuri", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "{{count}} näyttö keskeytetty", + "paused_other": "{{count}} näyttöä keskeytetty", + "resumed_one": "{{count}} näyttö jatkettu", + "resumed_other": "{{count}} näyttöä jatkettu", + "alreadyPaused_one": "Valittu näyttö on jo keskeytetty", + "alreadyPaused_other": "Kaikki valitut näytöt on jo keskeytetty", + "alreadyRunning_one": "Valittu näyttö on jo käynnissä", + "alreadyRunning_other": "Kaikki valitut näytöt ovat jo käynnissä", + "partialFailure": "{{successCount}} monitoria päivitettiin, mutta {{failedCount}} epäonnistui synkronoinnissa työjonon kanssa" } } }, @@ -469,9 +498,9 @@ }, "matchMethod": { "label": "Vertailutapa", - "equal": "", - "include": "", - "regex": "" + "equal": "Yhtä suuri", + "include": "Sisältää", + "regex": "Säännöllinen lauseke" } }, "title": "Lisäasetukset" @@ -483,15 +512,15 @@ "label": "Tarkistusväli", "value": { "fifteenMinutes": "15 minuuttia", - "fifteenSeconds": "", + "fifteenSeconds": "15 sekuntia", "fiveMinutes": "5 minuuttia", - "fourMinutes": "", - "oneMinute": "", + "fourMinutes": "4 minuuttia", + "oneMinute": "1 minuutti", "tenMinutes": "10 minuuttia", "thirtyMinutes": "30 minuuttia", - "thirtySeconds": "", - "threeMinutes": "", - "twoMinutes": "" + "thirtySeconds": "30 sekuntia", + "threeMinutes": "3 minuuttia", + "twoMinutes": "2 minuuttia" } } }, @@ -520,8 +549,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "Valitse peli", + "placeholder": "Valitse peli" }, "port": { "label": "Monitoroitava portti", @@ -534,19 +563,44 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "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": "", - "port": "", - "docker": "", + "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).", "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 tuettujen pelien luettelosta.", "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": "Monitoroi infrastruktuurisi CPU:n, muistin ja levyn käyttöä sekä lämpötilaa." + "hardware": "Seuraa infrastruktuurisi suorittimen, muistin, levyn käyttöä ja lämpötilaa. Capture-päätepisteen URL-osoitteen on sisällettävä koko polku /api/v1/metrics (esim. http://192.168.1.10:5001/api/v1/metrics). Liitä valtuutusavaimeen vain tokenin merkkijono — älä sisällytä sanaa 'Bearer'.", + "dns": "Tarkkaile, vastaako tietty DNS-palvelin." } }, "ignoreTls": { @@ -583,7 +637,8 @@ "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" + "title": "Tyyppi", + "optionDnsDescription": "Tarkkaile, vastaako tietty DNS-palvelin." }, "thresholds": { "title": "Hälytysrajat", @@ -747,7 +802,7 @@ "headers": { "endTime": "Päättymisaika", "resolutionType": "Ratkaisun tyyppi", - "startTime": "", + "startTime": "Aloitusaika", "statusCode": "Tilakoodi" }, "resolvedIncidents": "Ratkaistut häiriöt", @@ -791,13 +846,13 @@ } }, "statBoxes": { - "avgCpuTemperature": "", - "cpuFrequency": "", - "cpuLogical": "", - "cpuPhysical": "", + "avgCpuTemperature": "Keskimääräinen CPU-lämpötila", + "cpuFrequency": "CPU-taajuus", + "cpuLogical": "CPU (looginen)", + "cpuPhysical": "CPU (fyysinen)", "disk": "Levy", "memory": "Muisti", - "os": "" + "os": "Käyttöjärjestelmä" }, "table": { "headers": { @@ -808,7 +863,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "Verkko", "overview": "Yleiskatsaus" } }, @@ -891,7 +946,8 @@ "lastFinishedAt": "Viimeisin valmistuminen", "lastRunTook": "Viimeisin suoritus kesti", "lastFailedAt": "Viimeisin epäonnistuminen", - "failReason": "Epäonnistumisen syy" + "failReason": "Epäonnistumisen syy", + "state": "Tila" } }, "queueFallback": { @@ -911,8 +967,8 @@ }, "table": { "headers": { - "nextWindow": "", - "repeat": "" + "nextWindow": "Seuraava ikkuna", + "repeat": "Toista" } }, "form": { @@ -1014,7 +1070,7 @@ }, "telegram": { "title": "Telegram-määritykset", - "description": "", + "description": "Määritä Telegram-bottisi lähettämään ilmoituksia.", "optionBotToken": "Bottitunniste", "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", "optionChatId": "Chat-tunnus", @@ -1081,6 +1137,10 @@ "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": { @@ -1183,7 +1243,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": "", + "descriptionTransport": "Tämä rakentaa SMTP-kuljetuksen NodeMailerille", "descriptionTransportLink": "Katso tekniset tiedot täältä", "option": { "host": { @@ -1229,6 +1289,10 @@ }, "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" } } }, @@ -1289,6 +1353,20 @@ "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": { @@ -1306,7 +1384,9 @@ "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" + "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" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "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": { diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 306a2bd61..49e7e94bc 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -73,7 +73,9 @@ "paused": "en pause", "na": "N/A", "resolved": "Résolu", - "responseTime": "Temps de réponse" + "responseTime": "Temps de réponse", + "idle": "Inactif", + "running": "En cours d'exécution" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Actif", "responseTime": "Temps de réponse" } + }, + "statusCode": { + "down": "Hors service", + "networkError": { + "dns": "Impossible de résoudre le nom d'hôte (échec de la recherche DNS).", + "refused": "Le serveur a refusé la connexion (port fermé ou service non à l'écoute).", + "reset": "La connexion a été réinitialisée avant la réception d'une réponse.", + "timeout": "Le serveur n'a pas répondu à temps (délai d'attente dépassé).", + "tls": "Échec de la négociation TLS ou SSL (problème de certificat ou de chiffrement).", + "unknown": "Aucune réponse HTTP reçue de la cible." + } } }, "components": { @@ -421,7 +434,11 @@ "inviteMember": "Inviter un membre", "openSite": "Ouvrir le site", "pause": "Pause", - "resume": "Reprendre" + "resume": "Reprendre", + "selectAll": "Sélectionner tous les moniteurs", + "selectMonitor": "Sélectionner {{name}}", + "bulkSelected_one": "{{count}} moniteur sélectionné", + "bulkSelected_other": "{{count}} moniteurs sélectionnés" }, "statBoxes": { "activeFor": "Actif depuis", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infrastructure", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "{{count}} moniteur en pause", + "paused_other": "{{count}} moniteurs en pause", + "resumed_one": "{{count}} moniteur repris", + "resumed_other": "{{count}} moniteurs repris", + "alreadyPaused_one": "Le moniteur sélectionné est déjà en pause", + "alreadyPaused_other": "Tous les moniteurs sélectionnés sont déjà en pause", + "alreadyRunning_one": "Le moniteur sélectionné est déjà en cours d'exécution", + "alreadyRunning_other": "Tous les moniteurs sélectionnés sont déjà en cours d'exécution", + "partialFailure": "{{successCount}} moniteurs mis à jour, mais {{failedCount}} n'ont pas pu se synchroniser avec la file d'attente des tâches" } } }, @@ -520,8 +549,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "Choisir un jeu", + "placeholder": "Sélectionner un jeu" }, "port": { "label": "Port à surveiller", @@ -534,6 +563,30 @@ "wsUrl": { "label": "URL WebSocket", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Point de terminaison Capture", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "Type d'enregistrement DNS", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "Serveur DNS", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Bureau", + "label": "Stratégie", + "mobile": "Mobile" } }, "title": "Paramètres généraux", @@ -546,7 +599,8 @@ "pagespeed": "Suivez les performances de chargement, les Core Web Vitals et les scores d'optimisation de votre site web.", "grpc": "Saisissez le nom d'hôte et le port du serveur gRPC, spécifiez éventuellement un nom de service pour le bilan de santé, et ajoutez un nom d'affichage.", "websocket": "Saisissez l'URL WebSocket à surveiller (ex. wss://example.com/socket) et ajoutez un nom d'affichage.", - "hardware": "Surveillez l'utilisation du CPU, de la mémoire, du disque et la température de votre infrastructure." + "hardware": "Surveillez l'utilisation du processeur, de la mémoire, du disque et la température de votre infrastructure. L'URL du point de terminaison Capture doit inclure le chemin complet /api/v1/metrics (par ex. http://192.168.1.10:5001/api/v1/metrics). Pour le secret d'autorisation, collez uniquement la chaîne du jeton — n'incluez pas 'Bearer'.", + "dns": "Surveillez si un serveur DNS spécifique répond." } }, "ignoreTls": { @@ -583,7 +637,8 @@ "optionHttpDescription": "Utilisez HTTP(S) pour surveiller votre site web ou point de terminaison API.", "optionPingDescription": "Utilisez le Ping ICMP pour surveiller si un serveur est en ligne.", "optionPortDescription": "Surveillez si un port spécifique sur un serveur est ouvert.", - "title": "Type" + "title": "Type", + "optionDnsDescription": "Surveillez si un serveur DNS spécifique répond." }, "thresholds": { "title": "Seuils d'alerte", @@ -808,7 +863,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "Réseau", "overview": "Aperçu" } }, @@ -891,7 +946,8 @@ "lastFinishedAt": "Dernière fin", "lastRunTook": "Durée dernière exécution", "lastFailedAt": "Dernier échec", - "failReason": "Raison de l'échec" + "failReason": "Raison de l'échec", + "state": "État" } }, "queueFallback": { @@ -1081,6 +1137,10 @@ "header": { "title": "Moniteurs PageSpeed", "description": "Suivez les Core Web Vitals et les scores Lighthouse au fil du temps pour détecter les régressions avant vos utilisateurs." + }, + "strategy": { + "desktop": "Bureau", + "mobile": "Mobile" } }, "settings": { @@ -1229,6 +1289,10 @@ }, "rejectUnauthorized": { "label": "Rejeter les certificats invalides : refuser les connexions avec des certificats auto-signés ou non fiables" + }, + "displayName": { + "label": "Nom de l'expéditeur (facultatif) - Nom convivial affiché dans le champ De", + "placeholder": "Notifications Checkmate" } } }, @@ -1289,6 +1353,20 @@ "uptime": { "title": "Disponibilité", "responseTime": "Temps de réponse" + }, + "chart": { + "avg": "moy {{value}}ms", + "downTooltip": "Hors service", + "heatmapAria": "Carte thermique des temps de réponse", + "max": "max {{value}}ms" + }, + "status": { + "breached": "Seuil dépassé", + "down": "Hors service", + "initializing": "Initialisation", + "maintenance": "Maintenance", + "paused": "En pause", + "up": "Opérationnel" } }, "statusBar": { @@ -1306,7 +1384,9 @@ "maintenance": "Certains systèmes sont en maintenance", "partiallyPaused": "Certains systèmes sont en pause", "initializing": "Certains systèmes sont en cours d'initialisation", - "noMonitors": "Aucun moniteur n'a été configuré" + "noMonitors": "Aucun moniteur n'a été configuré", + "monitoringSummary": "Surveillance de {{count}} service · Mises à jour toutes les 10 secondes", + "monitoringSummary_other": "Surveillance de {{count}} services · Mises à jour toutes les 10 secondes" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "label": "Afficher les métriques d'infrastructure" } } + }, + "theme": { + "description": "Choisissez le style visuel de votre page de statut publique.", + "label": "Thème", + "options": { + "bold": { + "description": "Grande typographie et espacement assuré. Idéal pour les pages axées sur la marque.", + "name": "Audacieux" + }, + "editorial": { + "description": "Titres avec serif et séparateurs fins. Distinctif et calme.", + "name": "Éditorial" + }, + "modern": { + "description": "Qualité SaaS avec un point de statut pulsant et des cartes qui s'estompent doucement.", + "name": "Moderne" + }, + "refined": { + "description": "Propre, professionnel, proche du look classique de Checkmate.", + "name": "Raffiné" + } + }, + "title": "Thème de la page" + }, + "themeMode": { + "auto": "Automatique (selon l'appareil du visiteur)", + "dark": "Sombre uniquement", + "label": "Mode de thème", + "light": "Clair uniquement" } }, "header": { "title": "Pages de statut", "description": "Publiez des pages publiques montrant à vos clients et parties prenantes la disponibilité en temps réel et l'historique des incidents." + }, + "editorial": { + "allUp": "Tous les systèmes fonctionnent normalement.", + "reportTitle": "Rapport de service" + }, + "footer": { + "poweredBy": "Propulsé par" } }, "uptime": { diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 923213a46..eb97dc105 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -73,7 +73,9 @@ "paused": "一時停止中", "na": "N/A", "resolved": "解決済み", - "responseTime": "応答時間" + "responseTime": "応答時間", + "idle": "アイドル", + "running": "実行中" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "アクティブ", "responseTime": "応答時間" } + }, + "statusCode": { + "down": "ダウン", + "networkError": { + "dns": "ホスト名を解決できませんでした(DNSルックアップに失敗)。", + "refused": "サーバーが接続を拒否しました(ポートが閉じているか、サービスがリッスンしていません)。", + "reset": "応答を受信する前に接続がリセットされました。", + "timeout": "サーバーがタイムアウト内に応答しませんでした(リクエストタイムアウト)。", + "tls": "TLSまたはSSLハンドシェイクに失敗しました(証明書または暗号化の問題)。", + "unknown": "ターゲットからのHTTP応答がありませんでした。" + } } }, "components": { @@ -421,7 +434,11 @@ "inviteMember": "メンバーを招待", "openSite": "サイトを開く", "pause": "一時停止", - "resume": "再開" + "resume": "再開", + "selectAll": "すべてのモニターを選択", + "selectMonitor": "{{name}} を選択", + "bulkSelected_one": "{{count}} 個のモニターが選択されました", + "bulkSelected_other": "{{count}} 個のモニターが選択されました" }, "statBoxes": { "activeFor": "稼働期間", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "インフラストラクチャ", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "{{count}} 個のモニターを一時停止しました", + "paused_other": "{{count}} 個のモニターを一時停止しました", + "resumed_one": "{{count}} 個のモニターを再開しました", + "resumed_other": "{{count}} 個のモニターを再開しました", + "alreadyPaused_one": "選択したモニターはすでに一時停止されています", + "alreadyPaused_other": "選択したすべてのモニターはすでに一時停止されています", + "alreadyRunning_one": "選択したモニターはすでに実行中です", + "alreadyRunning_other": "選択したすべてのモニターはすでに実行中です", + "partialFailure": "{{successCount}} 個のモニターが更新されましたが、{{failedCount}} 個はジョブキューとの同期に失敗しました" } } }, @@ -469,9 +498,9 @@ }, "matchMethod": { "label": "マッチ方法", - "equal": "", - "include": "", - "regex": "" + "equal": "等しい", + "include": "含む", + "regex": "正規表現" } }, "title": "詳細設定" @@ -483,15 +512,15 @@ "label": "チェック頻度", "value": { "fifteenMinutes": "15分", - "fifteenSeconds": "", + "fifteenSeconds": "15秒", "fiveMinutes": "5分", - "fourMinutes": "", - "oneMinute": "", + "fourMinutes": "4分", + "oneMinute": "1分", "tenMinutes": "10分", "thirtyMinutes": "30分", - "thirtySeconds": "", + "thirtySeconds": "30秒", "threeMinutes": "3分", - "twoMinutes": "" + "twoMinutes": "2分" } } }, @@ -520,8 +549,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "ゲームを選択", + "placeholder": "ゲームを選択" }, "port": { "label": "監視するポート", @@ -534,6 +563,30 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "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": "一般設定", @@ -546,7 +599,8 @@ "pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。", "grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。", "websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。", - "hardware": "インフラストラクチャのCPU、メモリ、ディスク使用量、温度を監視します。" + "hardware": "インフラの CPU、メモリ、ディスク使用率、温度を監視します。Capture エンドポイント URL には完全なパス /api/v1/metrics を含める必要があります (例: http://192.168.1.10:5001/api/v1/metrics)。認証シークレットにはトークン文字列のみを貼り付けてください — 'Bearer' は含めないでください。", + "dns": "特定のDNSサーバーが応答しているかを監視します。" } }, "ignoreTls": { @@ -583,7 +637,8 @@ "optionHttpDescription": "HTTP(S)を使用してWebサイトまたはAPIエンドポイントを監視します。", "optionPingDescription": "ICMP Pingを使用してサーバーがオンラインかどうかを監視します。", "optionPortDescription": "サーバーの特定のポートが開いているかどうかを監視します。", - "title": "タイプ" + "title": "タイプ", + "optionDnsDescription": "特定のDNSサーバーが応答しているかを監視します。" }, "thresholds": { "title": "アラートしきい値", @@ -808,7 +863,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "ネットワーク", "overview": "概要" } }, @@ -891,7 +946,8 @@ "lastFinishedAt": "最終完了日時", "lastRunTook": "最終実行時間", "lastFailedAt": "最終失敗日時", - "failReason": "失敗理由" + "failReason": "失敗理由", + "state": "状態" } }, "queueFallback": { @@ -1081,6 +1137,10 @@ "header": { "title": "PageSpeed監視", "description": "Core Web VitalsとLighthouseスコアを継続的に追跡し、ユーザーが気付く前に劣化を発見します。" + }, + "strategy": { + "desktop": "デスクトップ", + "mobile": "モバイル" } }, "settings": { @@ -1229,6 +1289,10 @@ }, "rejectUnauthorized": { "label": "無効な証明書を拒否: 自己署名または信頼されていない証明書の接続を拒否" + }, + "displayName": { + "label": "送信者名(任意)- 差出人フィールドに表示されるわかりやすい名前", + "placeholder": "Checkmate通知" } } }, @@ -1289,6 +1353,20 @@ "uptime": { "title": "稼働時間", "responseTime": "応答時間" + }, + "chart": { + "avg": "平均 {{value}}ms", + "downTooltip": "ダウン", + "heatmapAria": "応答時間のヒートマップ", + "max": "最大 {{value}}ms" + }, + "status": { + "breached": "しきい値超過", + "down": "ダウン", + "initializing": "初期化中", + "maintenance": "メンテナンス", + "paused": "一時停止中", + "up": "稼働中" } }, "statusBar": { @@ -1306,7 +1384,9 @@ "maintenance": "一部のシステムがメンテナンス中です", "partiallyPaused": "一部のシステムが一時停止中です", "initializing": "一部のシステムが初期化中です", - "noMonitors": "モニターが設定されていません" + "noMonitors": "モニターが設定されていません", + "monitoringSummary": "{{count}}サービスを監視中 · 10秒ごとに更新", + "monitoringSummary_other": "{{count}}サービスを監視中 · 10秒ごとに更新" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "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": { diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index f7b7c4cbc..39cbf4577 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -73,7 +73,9 @@ "paused": "pausado", "na": "N/D", "resolved": "Resolvido", - "responseTime": "Tempo de resposta" + "responseTime": "Tempo de resposta", + "idle": "Inativo", + "running": "Em execução" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Ativo", "responseTime": "Tempo de resposta" } + }, + "statusCode": { + "down": "Fora do ar", + "networkError": { + "dns": "Não foi possível resolver o nome do host (a busca DNS falhou).", + "refused": "O servidor recusou a conexão (porta fechada ou serviço não escutando).", + "reset": "A conexão foi redefinida antes que uma resposta fosse recebida.", + "timeout": "O servidor não respondeu a tempo (tempo limite da solicitação esgotado).", + "tls": "Falha no handshake TLS ou SSL (problema de certificado ou criptografia).", + "unknown": "Nenhuma resposta HTTP recebida do destino." + } } }, "components": { @@ -421,7 +434,11 @@ "inviteMember": "Convidar membro", "openSite": "Abrir site", "pause": "Pausar", - "resume": "Retomar" + "resume": "Retomar", + "selectAll": "Selecionar todos os monitores", + "selectMonitor": "Selecionar {{name}}", + "bulkSelected_one": "{{count}} monitor selecionado", + "bulkSelected_other": "{{count}} monitores selecionados" }, "statBoxes": { "activeFor": "Ativo há", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infraestrutura", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "{{count}} monitor pausado", + "paused_other": "{{count}} monitores pausados", + "resumed_one": "{{count}} monitor retomado", + "resumed_other": "{{count}} monitores retomados", + "alreadyPaused_one": "O monitor selecionado já está pausado", + "alreadyPaused_other": "Todos os monitores selecionados já estão pausados", + "alreadyRunning_one": "O monitor selecionado já está em execução", + "alreadyRunning_other": "Todos os monitores selecionados já estão em execução", + "partialFailure": "{{successCount}} monitores atualizados, mas {{failedCount}} falharam ao sincronizar com a fila de trabalhos" } } }, @@ -469,8 +498,8 @@ }, "matchMethod": { "label": "Método de correspondência", - "equal": "", - "include": "", + "equal": "Igual", + "include": "Inclui", "regex": "Regex" } }, @@ -483,13 +512,13 @@ "label": "Frequência de verificação", "value": { "fifteenMinutes": "15 minutos", - "fifteenSeconds": "", + "fifteenSeconds": "15 segundos", "fiveMinutes": "5 minutos", "fourMinutes": "4 minutos", "oneMinute": "1 minuto", "tenMinutes": "10 minutos", "thirtyMinutes": "30 minutos", - "thirtySeconds": "", + "thirtySeconds": "30 segundos", "threeMinutes": "3 minutos", "twoMinutes": "2 minutos" } @@ -520,8 +549,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "Escolher jogo", + "placeholder": "Selecione um jogo" }, "port": { "label": "Porta para monitorar", @@ -534,6 +563,30 @@ "wsUrl": { "label": "URL do WebSocket", "placeholder": "wss://exemplo.com/socket" + }, + "captureUrl": { + "label": "Endpoint do Capture", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "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": "Desktop", + "label": "Estratégia", + "mobile": "Mobile" } }, "title": "Configurações gerais", @@ -546,7 +599,8 @@ "pagespeed": "Acompanhe o desempenho de carregamento da página, Core Web Vitals e pontuações de otimização do seu site.", "grpc": "Digite o hostname e a porta do servidor gRPC, opcionalmente especifique um nome de serviço para a verificação de saúde e adicione um nome de exibição.", "websocket": "Digite a URL do WebSocket para monitorar (ex.: wss://exemplo.com/socket) e adicione um nome de exibição.", - "hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura." + "hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura. A URL do endpoint do Capture deve incluir o caminho completo /api/v1/metrics (por exemplo, http://192.168.1.10:5001/api/v1/metrics). Para a chave de autorização, cole apenas a string do token — não inclua 'Bearer'.", + "dns": "Monitore se um servidor DNS específico está respondendo." } }, "ignoreTls": { @@ -583,7 +637,8 @@ "optionHttpDescription": "Use HTTP(S) para monitorar seu site ou endpoint de API.", "optionPingDescription": "Use ICMP Ping para monitorar se um servidor está online.", "optionPortDescription": "Monitore se uma porta específica em um servidor está aberta.", - "title": "Tipo" + "title": "Tipo", + "optionDnsDescription": "Monitore se um servidor DNS específico está respondendo." }, "thresholds": { "title": "Limites de alerta", @@ -808,7 +863,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "Rede", "overview": "Visão geral" } }, @@ -891,7 +946,8 @@ "lastFinishedAt": "Última conclusão em", "lastRunTook": "Última execução durou", "lastFailedAt": "Última falha em", - "failReason": "Motivo da falha" + "failReason": "Motivo da falha", + "state": "Estado" } }, "queueFallback": { @@ -1081,6 +1137,10 @@ "header": { "title": "Monitores PageSpeed", "description": "Acompanhe Core Web Vitals e pontuações do Lighthouse ao longo do tempo para detectar regressões antes dos seus usuários." + }, + "strategy": { + "desktop": "Desktop", + "mobile": "Mobile" } }, "settings": { @@ -1229,6 +1289,10 @@ }, "rejectUnauthorized": { "label": "Rejeitar certificados inválidos: Rejeitar conexões com certificados autoassinados ou não confiáveis" + }, + "displayName": { + "label": "Nome do remetente (opcional) - Nome amigável exibido no campo De", + "placeholder": "Notificações do Checkmate" } } }, @@ -1289,6 +1353,20 @@ "uptime": { "title": "Uptime", "responseTime": "Tempo de resposta" + }, + "chart": { + "avg": "méd {{value}}ms", + "downTooltip": "Fora do ar", + "heatmapAria": "Mapa de calor do tempo de resposta", + "max": "máx {{value}}ms" + }, + "status": { + "breached": "Limite excedido", + "down": "Fora do ar", + "initializing": "Inicializando", + "maintenance": "Manutenção", + "paused": "Pausado", + "up": "Operacional" } }, "statusBar": { @@ -1306,7 +1384,9 @@ "maintenance": "Alguns sistemas estão em manutenção", "partiallyPaused": "Alguns sistemas estão pausados", "initializing": "Alguns sistemas estão inicializando", - "noMonitors": "Nenhum monitor foi configurado" + "noMonitors": "Nenhum monitor foi configurado", + "monitoringSummary": "Monitorando {{count}} serviço · Atualizações a cada 10 segundos", + "monitoringSummary_other": "Monitorando {{count}} serviços · Atualizações a cada 10 segundos" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "label": "Mostrar métricas de infraestrutura" } } + }, + "theme": { + "description": "Escolha o estilo visual da sua página de status pública.", + "label": "Tema", + "options": { + "bold": { + "description": "Tipografia grande e espaçamento confiante. Ótimo para páginas voltadas à marca.", + "name": "Negrito" + }, + "editorial": { + "description": "Títulos com serifa e divisores finos. Distinto e calmo.", + "name": "Editorial" + }, + "modern": { + "description": "Qualidade SaaS com um ponto de status pulsante e cartões que desaparecem suavemente.", + "name": "Moderno" + }, + "refined": { + "description": "Limpo, profissional, próximo ao visual clássico do Checkmate.", + "name": "Refinado" + } + }, + "title": "Tema da página" + }, + "themeMode": { + "auto": "Automático (seguir dispositivo do visitante)", + "dark": "Apenas escuro", + "label": "Modo de tema", + "light": "Apenas claro" } }, "header": { "title": "Páginas de status", "description": "Publique páginas públicas que mostram aos seus clientes e partes interessadas o tempo de atividade em tempo real e o histórico de incidentes." + }, + "editorial": { + "allUp": "Todos os sistemas estão operando normalmente.", + "reportTitle": "Relatório de serviço" + }, + "footer": { + "poweredBy": "Desenvolvido por" } }, "uptime": { diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 1a5d12452..9bfd20481 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -73,7 +73,9 @@ "paused": "приостановлен", "na": "Н/Д", "resolved": "Решён", - "responseTime": "Время ответа" + "responseTime": "Время ответа", + "idle": "Бездействие", + "running": "Выполняется" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Активный", "responseTime": "Время ответа" } + }, + "statusCode": { + "down": "Не работает", + "networkError": { + "dns": "Не удалось разрешить имя хоста (поиск DNS не удался).", + "refused": "Сервер отказал в соединении (порт закрыт или служба не прослушивает).", + "reset": "Соединение было сброшено до получения ответа.", + "timeout": "Сервер не ответил вовремя (тайм-аут запроса).", + "tls": "Сбой рукопожатия TLS или SSL (проблема с сертификатом или шифрованием).", + "unknown": "HTTP-ответ от цели не получен." + } } }, "components": { @@ -421,7 +434,11 @@ "inviteMember": "Пригласить участника", "openSite": "Открыть сайт", "pause": "Пауза", - "resume": "Продолжить" + "resume": "Продолжить", + "selectAll": "Выбрать все мониторы", + "selectMonitor": "Выбрать {{name}}", + "bulkSelected_one": "Выбран {{count}} монитор", + "bulkSelected_other": "Выбрано {{count}} мониторов" }, "statBoxes": { "activeFor": "Активен", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Инфраструктура", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "{{count}} монитор приостановлен", + "paused_other": "{{count}} мониторов приостановлено", + "resumed_one": "{{count}} монитор возобновлен", + "resumed_other": "{{count}} мониторов возобновлено", + "alreadyPaused_one": "Выбранный монитор уже приостановлен", + "alreadyPaused_other": "Все выбранные мониторы уже приостановлены", + "alreadyRunning_one": "Выбранный монитор уже запущен", + "alreadyRunning_other": "Все выбранные мониторы уже запущены", + "partialFailure": "Обновлено {{successCount}} мониторов, но {{failedCount}} не удалось синхронизировать с очередью задач" } } }, @@ -534,6 +563,30 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "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": "Основные настройки", @@ -546,7 +599,8 @@ "pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.", "grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.", "websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.", - "hardware": "Мониторинг CPU, памяти, дисков и температуры Вашей инфраструктуры." + "hardware": "Отслеживайте использование ЦП, памяти, диска и температуру вашей инфраструктуры. URL эндпоинта Capture должен включать полный путь /api/v1/metrics (например, http://192.168.1.10:5001/api/v1/metrics). В секрете авторизации укажите только строку токена — не включайте 'Bearer'.", + "dns": "Отслеживайте, отвечает ли конкретный DNS-сервер." } }, "ignoreTls": { @@ -583,7 +637,8 @@ "optionHttpDescription": "Используйте HTTP(S) для мониторинга Вашего сайта или API-эндпоинта.", "optionPingDescription": "Используйте ICMP Ping для проверки доступности сервера.", "optionPortDescription": "Мониторинг доступности определённого порта на сервере.", - "title": "Тип" + "title": "Тип", + "optionDnsDescription": "Отслеживайте, отвечает ли конкретный DNS-сервер." }, "thresholds": { "title": "Пороги оповещений", @@ -891,7 +946,8 @@ "lastFinishedAt": "Последнее завершение", "lastRunTook": "Последний запуск занял", "lastFailedAt": "Последняя ошибка", - "failReason": "Причина ошибки" + "failReason": "Причина ошибки", + "state": "Состояние" } }, "queueFallback": { @@ -1081,6 +1137,10 @@ "header": { "title": "Мониторы PageSpeed", "description": "Отслеживайте Core Web Vitals и оценки Lighthouse во времени, чтобы заметить регрессии раньше пользователей." + }, + "strategy": { + "desktop": "Десктоп", + "mobile": "Мобильный" } }, "settings": { @@ -1229,6 +1289,10 @@ }, "rejectUnauthorized": { "label": "Отклонять недействительные сертификаты: отклонять соединения с самоподписанными или ненадёжными сертификатами" + }, + "displayName": { + "label": "Имя отправителя (необязательно) - Понятное имя в поле От", + "placeholder": "Уведомления Checkmate" } } }, @@ -1289,6 +1353,20 @@ "uptime": { "title": "Аптайм", "responseTime": "Время ответа" + }, + "chart": { + "avg": "ср {{value}}мс", + "downTooltip": "Не работает", + "heatmapAria": "Тепловая карта времени отклика", + "max": "макс {{value}}мс" + }, + "status": { + "breached": "Превышен порог", + "down": "Не работает", + "initializing": "Инициализация", + "maintenance": "Обслуживание", + "paused": "Приостановлено", + "up": "Работает" } }, "statusBar": { @@ -1306,7 +1384,9 @@ "maintenance": "Некоторые системы на техобслуживании", "partiallyPaused": "Некоторые системы приостановлены", "initializing": "Некоторые системы инициализируются", - "noMonitors": "Мониторы не настроены" + "noMonitors": "Мониторы не настроены", + "monitoringSummary": "Отслеживается {{count}} сервис · Обновление каждые 10 секунд", + "monitoringSummary_other": "Отслеживается {{count}} сервисов · Обновление каждые 10 секунд" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "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": { diff --git a/client/src/locales/th.json b/client/src/locales/th.json index 433ee10cd..5873b65d4 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -73,7 +73,9 @@ "paused": "หยุดชั่วคราว", "na": "ไม่มีข้อมูล", "resolved": "แก้ไขแล้ว", - "responseTime": "เวลาตอบสนอง" + "responseTime": "เวลาตอบสนอง", + "idle": "ไม่ทำงาน", + "running": "กำลังทำงาน" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "ใช้งานอยู่", "responseTime": "เวลาตอบสนอง" } + }, + "statusCode": { + "down": "ไม่พร้อมใช้งาน", + "networkError": { + "dns": "ไม่สามารถแก้ไขชื่อโฮสต์ได้ (การค้นหา DNS ล้มเหลว)", + "refused": "เซิร์ฟเวอร์ปฏิเสธการเชื่อมต่อ (พอร์ตปิดอยู่หรือบริการไม่ได้รับฟัง)", + "reset": "การเชื่อมต่อถูกรีเซ็ตก่อนที่จะได้รับการตอบกลับ", + "timeout": "เซิร์ฟเวอร์ไม่ตอบกลับทันเวลา (คำขอหมดเวลา)", + "tls": "การจับมือ TLS หรือ SSL ล้มเหลว (ปัญหาใบรับรองหรือการเข้ารหัส)", + "unknown": "ไม่ได้รับการตอบกลับ HTTP จากเป้าหมาย" + } } }, "components": { @@ -421,7 +434,11 @@ "inviteMember": "เชิญสมาชิก", "openSite": "เปิดเว็บไซต์", "pause": "หยุดชั่วคราว", - "resume": "ดำเนินการต่อ" + "resume": "ดำเนินการต่อ", + "selectAll": "เลือกจอภาพทั้งหมด", + "selectMonitor": "เลือก {{name}}", + "bulkSelected_one": "เลือกแล้ว {{count}} จอภาพ", + "bulkSelected_other": "เลือกแล้ว {{count}} จอภาพ" }, "statBoxes": { "activeFor": "ทำงานมาแล้ว", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "โครงสร้างพื้นฐาน", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "หยุดจอภาพชั่วคราว {{count}} จอ", + "paused_other": "หยุดจอภาพชั่วคราว {{count}} จอ", + "resumed_one": "กลับมาทำงานต่อ {{count}} จอ", + "resumed_other": "กลับมาทำงานต่อ {{count}} จอ", + "alreadyPaused_one": "จอภาพที่เลือกถูกหยุดชั่วคราวแล้ว", + "alreadyPaused_other": "จอภาพที่เลือกทั้งหมดถูกหยุดชั่วคราวแล้ว", + "alreadyRunning_one": "จอภาพที่เลือกกำลังทำงานอยู่แล้ว", + "alreadyRunning_other": "จอภาพที่เลือกทั้งหมดกำลังทำงานอยู่แล้ว", + "partialFailure": "อัปเดตมอนิเตอร์ {{successCount}} รายการแล้ว แต่ {{failedCount}} รายการไม่สามารถซิงค์กับคิวงานได้" } } }, @@ -534,6 +563,30 @@ "wsUrl": { "label": "URL ของ WebSocket", "placeholder": "wss://example.com/socket" + }, + "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": "ตั้งค่าทั่วไป", @@ -546,7 +599,8 @@ "pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์", "grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง", "websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง", - "hardware": "ตรวจสอบการใช้งาน CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐาน" + "hardware": "ตรวจสอบการใช้ CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐานของคุณ URL ของปลายทาง Capture ต้องรวมเส้นทางแบบเต็ม /api/v1/metrics (ตัวอย่าง: http://192.168.1.10:5001/api/v1/metrics) สำหรับรหัสลับสำหรับยืนยันตัวตน ให้วางเฉพาะสตริงโทเค็น — อย่ารวมคำว่า 'Bearer'", + "dns": "ติดตามว่าเซิร์ฟเวอร์ DNS เฉพาะตอบกลับหรือไม่" } }, "ignoreTls": { @@ -583,7 +637,8 @@ "optionHttpDescription": "ใช้ HTTP(S) เพื่อตรวจสอบเว็บไซต์หรือ API endpoint", "optionPingDescription": "ใช้ ICMP Ping เพื่อตรวจสอบว่าเซิร์ฟเวอร์ออนไลน์อยู่หรือไม่", "optionPortDescription": "ตรวจสอบว่าพอร์ตเฉพาะบนเซิร์ฟเวอร์เปิดอยู่หรือไม่", - "title": "ประเภท" + "title": "ประเภท", + "optionDnsDescription": "ติดตามว่าเซิร์ฟเวอร์ DNS เฉพาะตอบกลับหรือไม่" }, "thresholds": { "title": "เกณฑ์การแจ้งเตือน", @@ -891,7 +946,8 @@ "lastFinishedAt": "เสร็จสิ้นล่าสุดเมื่อ", "lastRunTook": "รันล่าสุดใช้เวลา", "lastFailedAt": "ล้มเหลวล่าสุดเมื่อ", - "failReason": "สาเหตุที่ล้มเหลว" + "failReason": "สาเหตุที่ล้มเหลว", + "state": "สถานะ" } }, "queueFallback": { @@ -1081,6 +1137,10 @@ "header": { "title": "ตัวตรวจสอบ PageSpeed", "description": "ติดตาม Core Web Vitals และคะแนน Lighthouse เพื่อจับความถดถอยก่อนที่ผู้ใช้จะสังเกตเห็น" + }, + "strategy": { + "desktop": "เดสก์ท็อป", + "mobile": "มือถือ" } }, "settings": { @@ -1229,6 +1289,10 @@ }, "rejectUnauthorized": { "label": "ปฏิเสธใบรับรองที่ไม่ถูกต้อง: ปฏิเสธการเชื่อมต่อด้วยใบรับรอง self-signed หรือไม่น่าเชื่อถือ" + }, + "displayName": { + "label": "ชื่อผู้ส่ง (ไม่บังคับ) - ชื่อที่เป็นมิตรซึ่งแสดงในช่องจาก", + "placeholder": "การแจ้งเตือน Checkmate" } } }, @@ -1289,6 +1353,20 @@ "uptime": { "title": "อัปไทม์", "responseTime": "เวลาตอบสนอง" + }, + "chart": { + "avg": "เฉลี่ย {{value}}ms", + "downTooltip": "ไม่พร้อมใช้งาน", + "heatmapAria": "แผนที่ความร้อนของเวลาตอบสนอง", + "max": "สูงสุด {{value}}ms" + }, + "status": { + "breached": "เกินขีดจำกัด", + "down": "ไม่พร้อมใช้งาน", + "initializing": "กำลังเริ่มต้น", + "maintenance": "การบำรุงรักษา", + "paused": "หยุดชั่วคราว", + "up": "ใช้งานได้" } }, "statusBar": { @@ -1306,7 +1384,9 @@ "maintenance": "บางระบบอยู่ระหว่างการบำรุงรักษา", "partiallyPaused": "บางระบบถูกหยุดชั่วคราว", "initializing": "บางระบบกำลังเริ่มต้น", - "noMonitors": "ยังไม่มีการกำหนดค่ามอนิเตอร์" + "noMonitors": "ยังไม่มีการกำหนดค่ามอนิเตอร์", + "monitoringSummary": "กำลังตรวจสอบ {{count}} บริการ · อัปเดตทุก 10 วินาที", + "monitoringSummary_other": "กำลังตรวจสอบ {{count}} บริการ · อัปเดตทุก 10 วินาที" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "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": "เผยแพร่หน้าสาธารณะที่แสดง uptime แบบเรียลไทม์และประวัติเหตุการณ์ให้กับลูกค้าและผู้มีส่วนได้ส่วนเสีย" + }, + "editorial": { + "allUp": "ทุกระบบทำงานเป็นปกติ", + "reportTitle": "รายงานบริการ" + }, + "footer": { + "poweredBy": "ขับเคลื่อนโดย" } }, "uptime": { diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index a77dd8eb3..2d6deaa97 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -73,7 +73,9 @@ "paused": "duraklatıldı", "na": "N/A", "resolved": "Çözüldü", - "responseTime": "Yanıt süresi" + "responseTime": "Yanıt süresi", + "idle": "Boşta", + "running": "Çalışıyor" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Aktif", "responseTime": "Yanıt süresi" } + }, + "statusCode": { + "down": "Çalışmıyor", + "networkError": { + "dns": "Ana bilgisayar adı çözümlenemedi (DNS araması başarısız oldu).", + "refused": "Sunucu bağlantıyı reddetti (bağlantı noktası kapalı veya hizmet dinlemiyor).", + "reset": "Yanıt alınmadan bağlantı sıfırlandı.", + "timeout": "Sunucu zamanında yanıt vermedi (istek zaman aşımına uğradı).", + "tls": "TLS veya SSL el sıkışması başarısız (sertifika veya şifreleme sorunu).", + "unknown": "Hedeften HTTP yanıtı alınamadı." + } } }, "components": { @@ -306,7 +319,7 @@ "common": { "passwordRules": { "length": { - "beginning": "", + "beginning": "En az", "highlighted": "8 karakter uzunluğunda" }, "lowercase": { @@ -314,7 +327,7 @@ "highlighted": "Bir küçük harf" }, "match": { - "beginning": "", + "beginning": "Parolalar", "highlighted": "eşleşmelidir" }, "number": { @@ -421,7 +434,11 @@ "inviteMember": "Üye davet et", "openSite": "Siteyi aç", "pause": "Duraklat", - "resume": "Devam et" + "resume": "Devam et", + "selectAll": "Tüm monitörleri seç", + "selectMonitor": "{{name}}'i seç", + "bulkSelected_one": "{{count}} monitör seçildi", + "bulkSelected_other": "{{count}} monitör seçildi" }, "statBoxes": { "activeFor": "Aktif süresi", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Altyapı", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "{{count}} monitör duraklatıldı", + "paused_other": "{{count}} monitör duraklatıldı", + "resumed_one": "{{count}} monitör sürdürüldü", + "resumed_other": "{{count}} monitör sürdürüldü", + "alreadyPaused_one": "Seçili monitör zaten duraklatıldı", + "alreadyPaused_other": "Seçili tüm monitörler zaten duraklatıldı", + "alreadyRunning_one": "Seçili monitör zaten çalışıyor", + "alreadyRunning_other": "Seçili tüm monitörler zaten çalışıyor", + "partialFailure": "{{successCount}} monitör güncellendi, ancak {{failedCount}} tanesi iş kuyruğuyla senkronize edilemedi" } } }, @@ -469,9 +498,9 @@ }, "matchMethod": { "label": "Eşleştirme yöntemi", - "equal": "", - "include": "", - "regex": "" + "equal": "Eşit", + "include": "İçerir", + "regex": "Regex" } }, "title": "Gelişmiş ayarlar" @@ -483,15 +512,15 @@ "label": "Kontrol sıklığı", "value": { "fifteenMinutes": "15 dakika", - "fifteenSeconds": "", + "fifteenSeconds": "15 saniye", "fiveMinutes": "5 dakika", - "fourMinutes": "", - "oneMinute": "", + "fourMinutes": "4 dakika", + "oneMinute": "1 dakika", "tenMinutes": "10 dakika", "thirtyMinutes": "30 dakika", - "thirtySeconds": "", - "threeMinutes": "", - "twoMinutes": "" + "thirtySeconds": "30 saniye", + "threeMinutes": "3 dakika", + "twoMinutes": "2 dakika" } } }, @@ -520,8 +549,8 @@ "placeholder": "https://www.google.com" }, "game": { - "label": "", - "placeholder": "" + "label": "Oyun seç", + "placeholder": "Bir oyun seçin" }, "port": { "label": "İzlenecek port", @@ -534,19 +563,44 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture uç noktası", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "DNS kayıt türü", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "DNS sunucusu", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Masaüstü", + "label": "Strateji", + "mobile": "Mobil" } }, "title": "Genel ayarlar", "description": { "http": "İzlenecek URL veya IP adresini girin (örn. https://example.com/ veya 192.168.1.100) ve panoda görünecek açık bir ad ekleyin.", - "ping": "", - "port": "", - "docker": "", + "ping": "Ping atılacak IP adresini veya ana bilgisayar adını girin (ör. 192.168.1.100 veya example.com) ve panoda görünen net bir görünen ad ekleyin.", + "port": "Sunucunun URL veya IP adresini, port numarasını ve panoda görünen net bir görünen ad girin.", + "docker": "Docker kapsayıcı adını veya kimliğini girin. Kapsayıcı adını (ör. my-app) veya kapsayıcı kimliğini (tam 64 karakterli kimlik veya kısa kimlik) kullanabilirsiniz.", "game": "Ping atılacak IP adresini veya ana bilgisayar adını ve port numarasını girin (ör. 192.168.1.100 veya example.com) ve oyun türünü seçin. Bazı oyunlar yapılandırma dosyasında sunucu sorgularının etkinleştirilmesini gerektirir — oyun sunucunuzun belgelerine bakın. Protokol ayrıntıları için desteklenen oyunlar listesine bakın.", "pagespeed": "Web sitenizin sayfa yükleme performansını, Core Web Vitals metriklerini ve optimizasyon puanlarını takip edin.", "grpc": "gRPC sunucusunun ana bilgisayar adını ve portunu girin, isteğe bağlı olarak sağlık kontrolü için bir servis adı belirtin ve bir görünen ad ekleyin.", "websocket": "İzlenecek WebSocket URL'sini girin (örn. wss://example.com/socket) ve bir görünen ad ekleyin.", - "hardware": "Altyapınız için CPU, bellek, disk kullanımı ve sıcaklığı izleyin." + "hardware": "Altyapınızın CPU, bellek, disk kullanımını ve sıcaklığını izleyin. Capture uç nokta URL'si tam yolu /api/v1/metrics içermelidir (ör. http://192.168.1.10:5001/api/v1/metrics). Yetkilendirme anahtarına yalnızca token dizesini yapıştırın — 'Bearer' eklemeyin.", + "dns": "Belirli bir DNS sunucusunun yanıt verip vermediğini izleyin." } }, "ignoreTls": { @@ -583,7 +637,8 @@ "optionHttpDescription": "Web sitenizi veya API uç noktanızı izlemek için HTTP(S) kullanın.", "optionPingDescription": "Bir sunucunun çevrimiçi olup olmadığını izlemek için ICMP Ping kullanın.", "optionPortDescription": "Bir sunucuda belirli bir portun açık olup olmadığını izleyin.", - "title": "Tür" + "title": "Tür", + "optionDnsDescription": "Belirli bir DNS sunucusunun yanıt verip vermediğini izleyin." }, "thresholds": { "title": "Uyarı eşikleri", @@ -808,7 +863,7 @@ }, "tabs": { "labels": { - "network": "", + "network": "Ağ", "overview": "Genel bakış" } }, @@ -891,7 +946,8 @@ "lastFinishedAt": "Son tamamlanma zamanı", "lastRunTook": "Son çalışma süresi", "lastFailedAt": "Son başarısızlık zamanı", - "failReason": "Başarısızlık nedeni" + "failReason": "Başarısızlık nedeni", + "state": "Durum" } }, "queueFallback": { @@ -1081,6 +1137,10 @@ "header": { "title": "PageSpeed izleyicileri", "description": "Core Web Vitals ve Lighthouse puanlarını zaman içinde izleyerek kullanıcılarınız fark etmeden gerilemeleri yakalayın." + }, + "strategy": { + "desktop": "Masaüstü", + "mobile": "Mobil" } }, "settings": { @@ -1183,7 +1243,7 @@ "email": { "title": "E-posta ayarları", "description": "Sisteminiz için e-posta ayarlarını yapılandırın. Bu, bildirim ve uyarı göndermek için kullanılır.", - "descriptionTransport": "", + "descriptionTransport": "Bu, NodeMailer için SMTP aktarımı oluşturur", "descriptionTransportLink": "Özellikleri burada görün", "option": { "host": { @@ -1229,6 +1289,10 @@ }, "rejectUnauthorized": { "label": "Geçersiz sertifikaları reddet: Kendinden imzalı veya güvenilmeyen sertifikalı bağlantıları reddet" + }, + "displayName": { + "label": "Gönderen adı (isteğe bağlı) - Gönderen alanında gösterilen kullanıcı dostu ad", + "placeholder": "Checkmate bildirimleri" } } }, @@ -1289,6 +1353,20 @@ "uptime": { "title": "Çalışma süresi", "responseTime": "Yanıt süresi" + }, + "chart": { + "avg": "ort {{value}}ms", + "downTooltip": "Çalışmıyor", + "heatmapAria": "Yanıt süresi ısı haritası", + "max": "maks {{value}}ms" + }, + "status": { + "breached": "Eşik aşıldı", + "down": "Çalışmıyor", + "initializing": "Başlatılıyor", + "maintenance": "Bakım", + "paused": "Duraklatıldı", + "up": "Çalışıyor" } }, "statusBar": { @@ -1306,7 +1384,9 @@ "maintenance": "Bazı sistemler bakımda", "partiallyPaused": "Bazı sistemler duraklatıldı", "initializing": "Bazı sistemler başlatılıyor", - "noMonitors": "Hiç monitör yapılandırılmadı" + "noMonitors": "Hiç monitör yapılandırılmadı", + "monitoringSummary": "{{count}} hizmet izleniyor · 10 saniyede bir güncellenir", + "monitoringSummary_other": "{{count}} hizmet izleniyor · 10 saniyede bir güncellenir" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "label": "Altyapı metriklerini göster" } } + }, + "theme": { + "description": "Genel durum sayfanız için görsel stili seçin.", + "label": "Tema", + "options": { + "bold": { + "description": "Büyük tipografi ve kendinden emin aralıklar. Marka odaklı sayfalar için harika.", + "name": "Kalın" + }, + "editorial": { + "description": "Serifli başlıklar ve ince ayraçlar. Belirgin ve sakin.", + "name": "Editöryal" + }, + "modern": { + "description": "Atan bir durum noktası ve yumuşakça solan kartlarla SaaS kalitesinde.", + "name": "Modern" + }, + "refined": { + "description": "Temiz, profesyonel, klasik Checkmate görünümüne yakın.", + "name": "Zarif" + } + }, + "title": "Sayfa teması" + }, + "themeMode": { + "auto": "Otomatik (ziyaretçinin cihazına göre)", + "dark": "Yalnızca koyu", + "label": "Tema modu", + "light": "Yalnızca açık" } }, "header": { "title": "Durum sayfaları", "description": "Müşterilerinize ve paydaşlarınıza gerçek zamanlı çalışma süresi ve olay geçmişini gösteren herkese açık sayfalar yayınlayın." + }, + "editorial": { + "allUp": "Tüm sistemler normal şekilde çalışıyor.", + "reportTitle": "Hizmet raporu" + }, + "footer": { + "poweredBy": "Sağlayan" } }, "uptime": { diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 5b467e4b4..de40bbf05 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -73,7 +73,9 @@ "paused": "призупинено", "na": "Н/Д", "resolved": "Вирішено", - "responseTime": "Час відповіді" + "responseTime": "Час відповіді", + "idle": "Очікування", + "running": "Виконується" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Активний", "responseTime": "Час відповіді" } + }, + "statusCode": { + "down": "Не працює", + "networkError": { + "dns": "Не вдалося розв'язати ім'я хоста (пошук DNS не вдався).", + "refused": "Сервер відхилив з'єднання (порт закрито або служба не слухає).", + "reset": "З'єднання було скинуто до отримання відповіді.", + "timeout": "Сервер не відповів вчасно (час очікування запиту вичерпано).", + "tls": "Збій рукостискання TLS або SSL (проблема з сертифікатом або шифруванням).", + "unknown": "HTTP-відповідь від цілі не отримана." + } } }, "components": { @@ -306,28 +319,28 @@ "common": { "passwordRules": { "length": { - "beginning": "", - "highlighted": "" + "beginning": "Має бути щонайменше", + "highlighted": "8 символів" }, "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": { @@ -421,7 +434,11 @@ "inviteMember": "Запросити учасника", "openSite": "Відкрити сайт", "pause": "Призупинити", - "resume": "Відновити" + "resume": "Відновити", + "selectAll": "Вибрати всі монітори", + "selectMonitor": "Вибрати {{name}}", + "bulkSelected_one": "Вибрано {{count}} монітор", + "bulkSelected_other": "Вибрано {{count}} моніторів" }, "statBoxes": { "activeFor": "Активний протягом", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Інфраструктура", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "{{count}} монітор призупинено", + "paused_other": "{{count}} моніторів призупинено", + "resumed_one": "{{count}} монітор відновлено", + "resumed_other": "{{count}} моніторів відновлено", + "alreadyPaused_one": "Вибраний монітор уже призупинено", + "alreadyPaused_other": "Всі вибрані монітори уже призупинено", + "alreadyRunning_one": "Вибраний монітор уже запущено", + "alreadyRunning_other": "Всі вибрані монітори уже запущено", + "partialFailure": "Оновлено {{successCount}} моніторів, але {{failedCount}} не вдалося синхронізувати з чергою завдань" } } }, @@ -534,19 +563,44 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "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": "Введіть URL або IP для моніторингу (напр., https://example.com/ або 192.168.1.100) та додайте зрозуміле ім'я для відображення на панелі.", - "ping": "", - "port": "", - "docker": "", + "ping": "Введіть IP-адресу або ім'я хоста для пінгу (наприклад, 192.168.1.100 або example.com) і додайте чітку відображувану назву, що з'явиться на дашборді.", + "port": "Введіть URL або IP сервера, номер порту та чітку відображувану назву, що з'явиться на дашборді.", + "docker": "Введіть назву або ідентифікатор Docker-контейнера. Можна використовувати назву контейнера (наприклад, my-app) або ідентифікатор (повний 64-символьний або скорочений).", "game": "Введіть IP-адресу або ім'я хоста та номер порту для пінгу (наприклад, 192.168.1.100 або example.com) і виберіть тип гри. Деяким іграм потрібно ввімкнути серверні запити у файлі конфігурації — перевірте документацію вашого ігрового сервера. Деталі протоколу див. у списку підтримуваних ігор.", "pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.", "grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.", "websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.", - "hardware": "Моніторинг CPU, пам'яті, використання диска та температури Вашої інфраструктури." + "hardware": "Відстежуйте використання ЦП, пам'яті, диска та температуру вашої інфраструктури. URL точки доступу Capture має містити повний шлях /api/v1/metrics (наприклад, http://192.168.1.10:5001/api/v1/metrics). У секрет авторизації вставте лише рядок токена — не додавайте 'Bearer'.", + "dns": "Стежте, чи відповідає певний DNS-сервер." } }, "ignoreTls": { @@ -583,7 +637,8 @@ "optionHttpDescription": "Використовуйте HTTP(S) для моніторингу Вашого сайту або API-ендпоінту.", "optionPingDescription": "Використовуйте ICMP Ping для моніторингу доступності сервера.", "optionPortDescription": "Моніторинг відкритості конкретного порту на сервері.", - "title": "Тип" + "title": "Тип", + "optionDnsDescription": "Стежте, чи відповідає певний DNS-сервер." }, "thresholds": { "title": "Порогові значення сповіщень", @@ -747,7 +802,7 @@ "headers": { "endTime": "Час завершення", "resolutionType": "Тип вирішення", - "startTime": "", + "startTime": "Час початку", "statusCode": "Код статусу" }, "resolvedIncidents": "Вирішені інциденти", @@ -791,13 +846,13 @@ } }, "statBoxes": { - "avgCpuTemperature": "", - "cpuFrequency": "", - "cpuLogical": "", - "cpuPhysical": "", + "avgCpuTemperature": "Середня температура CPU", + "cpuFrequency": "Частота CPU", + "cpuLogical": "CPU (логічний)", + "cpuPhysical": "CPU (фізичний)", "disk": "Диск", "memory": "Пам'ять", - "os": "" + "os": "ОС" }, "table": { "headers": { @@ -891,7 +946,8 @@ "lastFinishedAt": "Останнє завершення", "lastRunTook": "Останній запуск тривав", "lastFailedAt": "Остання помилка", - "failReason": "Причина помилки" + "failReason": "Причина помилки", + "state": "Стан" } }, "queueFallback": { @@ -911,8 +967,8 @@ }, "table": { "headers": { - "nextWindow": "", - "repeat": "" + "nextWindow": "Наступне вікно", + "repeat": "Повторити" } }, "form": { @@ -1014,7 +1070,7 @@ }, "telegram": { "title": "Налаштування Telegram", - "description": "", + "description": "Налаштуйте свого Telegram-бота для надсилання сповіщень.", "optionBotToken": "Токен бота", "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", "optionChatId": "ID чату", @@ -1081,6 +1137,10 @@ "header": { "title": "Монітори PageSpeed", "description": "Відстежуйте Core Web Vitals і оцінки Lighthouse у часі, щоб помітити регресії раніше за користувачів." + }, + "strategy": { + "desktop": "Десктоп", + "mobile": "Мобільний" } }, "settings": { @@ -1183,7 +1243,7 @@ "email": { "title": "Налаштування пошти", "description": "Налаштуйте параметри електронної пошти для Вашої системи. Використовується для надсилання сповіщень та попереджень.", - "descriptionTransport": "", + "descriptionTransport": "Це створює SMTP-транспорт для NodeMailer", "descriptionTransportLink": "Дивіться специфікації тут", "option": { "host": { @@ -1229,6 +1289,10 @@ }, "rejectUnauthorized": { "label": "Відхиляти недійсні сертифікати: відхиляти з'єднання із самопідписаними або ненадійними сертифікатами" + }, + "displayName": { + "label": "Ім'я відправника (необов'язково) - Дружнє ім'я, що відображається в полі Від", + "placeholder": "Сповіщення Checkmate" } } }, @@ -1289,6 +1353,20 @@ "uptime": { "title": "Час роботи", "responseTime": "Час відповіді" + }, + "chart": { + "avg": "сер {{value}}мс", + "downTooltip": "Не працює", + "heatmapAria": "Теплова карта часу відгуку", + "max": "макс {{value}}мс" + }, + "status": { + "breached": "Перевищено поріг", + "down": "Не працює", + "initializing": "Ініціалізація", + "maintenance": "Обслуговування", + "paused": "Призупинено", + "up": "Працює" } }, "statusBar": { @@ -1306,7 +1384,9 @@ "maintenance": "Деякі системи на техобслуговуванні", "partiallyPaused": "Деякі системи призупинено", "initializing": "Деякі системи ініціалізуються", - "noMonitors": "Не налаштовано жодного монітора" + "noMonitors": "Не налаштовано жодного монітора", + "monitoringSummary": "Відстежується {{count}} сервіс · Оновлення кожні 10 секунд", + "monitoringSummary_other": "Відстежується {{count}} сервісів · Оновлення кожні 10 секунд" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "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": { diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index dc3e804de..13907f21b 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -73,7 +73,9 @@ "paused": "tạm dừng", "na": "N/A", "resolved": "Đã giải quyết", - "responseTime": "Thời gian phản hồi" + "responseTime": "Thời gian phản hồi", + "idle": "Không hoạt động", + "running": "Đang chạy" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "Đang hoạt động", "responseTime": "Thời gian phản hồi" } + }, + "statusCode": { + "down": "Ngừng hoạt động", + "networkError": { + "dns": "Không thể phân giải tên máy chủ (tra cứu DNS thất bại).", + "refused": "Máy chủ từ chối kết nối (cổng đã đóng hoặc dịch vụ không lắng nghe).", + "reset": "Kết nối đã được đặt lại trước khi nhận được phản hồi.", + "timeout": "Máy chủ không phản hồi kịp thời (yêu cầu hết thời gian chờ).", + "tls": "Bắt tay TLS hoặc SSL thất bại (vấn đề chứng chỉ hoặc mã hóa).", + "unknown": "Không nhận được phản hồi HTTP từ mục tiêu." + } } }, "components": { @@ -306,28 +319,28 @@ "common": { "passwordRules": { "length": { - "beginning": "", - "highlighted": "" + "beginning": "Phải có ít nhất", + "highlighted": "8 ký tự" }, "lowercase": { - "beginning": "", - "highlighted": "" + "beginning": "Phải chứa ít nhất", + "highlighted": "một chữ thường" }, "match": { - "beginning": "", - "highlighted": "" + "beginning": "Mật khẩu", + "highlighted": "phải khớp" }, "number": { - "beginning": "", - "highlighted": "" + "beginning": "Phải chứa ít nhất", + "highlighted": "một chữ số" }, "special": { - "beginning": "", - "highlighted": "" + "beginning": "Phải chứa ít nhất", + "highlighted": "một ký tự đặc biệt (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" }, "uppercase": { - "beginning": "", - "highlighted": "" + "beginning": "Phải chứa ít nhất", + "highlighted": "một chữ hoa" } }, "form": { @@ -421,7 +434,11 @@ "inviteMember": "Mời thành viên", "openSite": "Mở trang web", "pause": "Tạm dừng", - "resume": "Tiếp tục" + "resume": "Tiếp tục", + "selectAll": "Chọn tất cả màn hình", + "selectMonitor": "Chọn {{name}}", + "bulkSelected_one": "Đã chọn {{count}} màn hình", + "bulkSelected_other": "Đã chọn {{count}} màn hình" }, "statBoxes": { "activeFor": "Hoạt động từ", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Hạ tầng", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "Đã tạm dừng {{count}} màn hình", + "paused_other": "Đã tạm dừng {{count}} màn hình", + "resumed_one": "Đã tiếp tục {{count}} màn hình", + "resumed_other": "Đã tiếp tục {{count}} màn hình", + "alreadyPaused_one": "Màn hình đã chọn đã được tạm dừng", + "alreadyPaused_other": "Tất cả các màn hình đã chọn đã được tạm dừng", + "alreadyRunning_one": "Màn hình đã chọn đã đang chạy", + "alreadyRunning_other": "Tất cả các màn hình đã chọn đã đang chạy", + "partialFailure": "Đã cập nhật {{successCount}} trình giám sát, nhưng {{failedCount}} trình giám sát không thể đồng bộ hóa với hàng đợi công việc" } } }, @@ -534,19 +563,44 @@ "wsUrl": { "label": "URL WebSocket", "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Điểm cuối Capture", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + }, + "dnsRecordType": { + "label": "Loại bản ghi DNS", + "value": { + "A": "A", + "AAAA": "AAAA", + "CNAME": "CNAME", + "MX": "MX", + "NS": "NS", + "TXT": "TXT" + } + }, + "dnsServer": { + "label": "Máy chủ DNS", + "placeholder": "8.8.8.8" + }, + "strategy": { + "desktop": "Máy tính để bàn", + "label": "Chiến lược", + "mobile": "Di động" } }, "title": "Cài đặt chung", "description": { "http": "Nhập URL hoặc IP cần giám sát (vd: https://example.com/ hoặc 192.168.1.100) và thêm tên hiển thị rõ ràng trên bảng điều khiển.", - "ping": "", - "port": "", - "docker": "", + "ping": "Nhập địa chỉ IP hoặc tên máy chủ để ping (ví dụ: 192.168.1.100 hoặc example.com) và thêm tên hiển thị rõ ràng xuất hiện trên bảng điều khiển.", + "port": "Nhập URL hoặc IP của máy chủ, số cổng và tên hiển thị rõ ràng xuất hiện trên bảng điều khiển.", + "docker": "Nhập tên hoặc ID của container Docker. Bạn có thể sử dụng tên container (ví dụ: my-app) hoặc ID container (ID đầy đủ 64 ký tự hoặc ID ngắn).", "game": "Nhập địa chỉ IP hoặc tên máy chủ và số cổng để ping (ví dụ: 192.168.1.100 hoặc example.com) và chọn loại trò chơi. Một số trò chơi yêu cầu bật truy vấn máy chủ trong tệp cấu hình của chúng — hãy xem tài liệu của máy chủ trò chơi. Xem danh sách các trò chơi được hỗ trợ để biết chi tiết giao thức.", "pagespeed": "Theo dõi hiệu suất tải trang, Core Web Vitals và điểm tối ưu hóa cho trang web của bạn.", "grpc": "Nhập tên máy chủ và cổng của máy chủ gRPC, tùy chọn chỉ định tên dịch vụ cho kiểm tra sức khỏe và thêm tên hiển thị.", "websocket": "Nhập URL WebSocket cần giám sát (vd: wss://example.com/socket) và thêm tên hiển thị.", - "hardware": "Giám sát CPU, bộ nhớ, dung lượng ổ đĩa và nhiệt độ cho hạ tầng của bạn." + "hardware": "Giám sát mức sử dụng CPU, bộ nhớ, ổ đĩa và nhiệt độ của hạ tầng. URL điểm cuối Capture phải bao gồm đường dẫn đầy đủ /api/v1/metrics (ví dụ: http://192.168.1.10:5001/api/v1/metrics). Đối với khóa xác thực, chỉ dán chuỗi token — không bao gồm 'Bearer'.", + "dns": "Giám sát xem một máy chủ DNS cụ thể có phản hồi hay không." } }, "ignoreTls": { @@ -583,7 +637,8 @@ "optionHttpDescription": "Sử dụng HTTP(S) để giám sát trang web hoặc điểm cuối API của bạn.", "optionPingDescription": "Sử dụng ICMP Ping để giám sát xem máy chủ có trực tuyến không.", "optionPortDescription": "Giám sát xem một cổng cụ thể trên máy chủ có mở không.", - "title": "Loại" + "title": "Loại", + "optionDnsDescription": "Giám sát xem một máy chủ DNS cụ thể có phản hồi hay không." }, "thresholds": { "title": "Ngưỡng cảnh báo", @@ -747,7 +802,7 @@ "headers": { "endTime": "Thời gian kết thúc", "resolutionType": "Loại giải quyết", - "startTime": "", + "startTime": "Thời gian bắt đầu", "statusCode": "Mã trạng thái" }, "resolvedIncidents": "Sự cố đã giải quyết", @@ -791,13 +846,13 @@ } }, "statBoxes": { - "avgCpuTemperature": "", - "cpuFrequency": "", - "cpuLogical": "", - "cpuPhysical": "", + "avgCpuTemperature": "Nhiệt độ CPU trung bình", + "cpuFrequency": "Tần số CPU", + "cpuLogical": "CPU (logic)", + "cpuPhysical": "CPU (vật lý)", "disk": "Ổ đĩa", "memory": "Bộ nhớ", - "os": "" + "os": "Hệ điều hành" }, "table": { "headers": { @@ -891,7 +946,8 @@ "lastFinishedAt": "Hoàn thành lần cuối lúc", "lastRunTook": "Lần chạy cuối mất", "lastFailedAt": "Lỗi lần cuối lúc", - "failReason": "Lý do lỗi" + "failReason": "Lý do lỗi", + "state": "Trạng thái" } }, "queueFallback": { @@ -911,8 +967,8 @@ }, "table": { "headers": { - "nextWindow": "", - "repeat": "" + "nextWindow": "Cửa sổ tiếp theo", + "repeat": "Lặp lại" } }, "form": { @@ -1014,7 +1070,7 @@ }, "telegram": { "title": "Cấu hình Telegram", - "description": "", + "description": "Cấu hình bot Telegram của bạn để gửi thông báo.", "optionBotToken": "Token bot", "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", "optionChatId": "ID cuộc trò chuyện", @@ -1081,6 +1137,10 @@ "header": { "title": "Trình giám sát PageSpeed", "description": "Theo dõi Core Web Vitals và điểm Lighthouse theo thời gian để phát hiện sự suy giảm trước khi người dùng nhận ra." + }, + "strategy": { + "desktop": "Máy tính để bàn", + "mobile": "Di động" } }, "settings": { @@ -1183,7 +1243,7 @@ "email": { "title": "Cài đặt email", "description": "Cấu hình cài đặt email cho hệ thống. Được sử dụng để gửi thông báo và cảnh báo.", - "descriptionTransport": "", + "descriptionTransport": "Điều này xây dựng một SMTP transport cho NodeMailer", "descriptionTransportLink": "Xem thông số kỹ thuật tại đây", "option": { "host": { @@ -1229,6 +1289,10 @@ }, "rejectUnauthorized": { "label": "Từ chối chứng chỉ không hợp lệ: Từ chối kết nối với chứng chỉ tự ký hoặc không đáng tin cậy" + }, + "displayName": { + "label": "Tên người gửi (tùy chọn) - Tên thân thiện hiển thị trong trường Từ", + "placeholder": "Thông báo Checkmate" } } }, @@ -1289,6 +1353,20 @@ "uptime": { "title": "Thời gian hoạt động", "responseTime": "Thời gian phản hồi" + }, + "chart": { + "avg": "TB {{value}}ms", + "downTooltip": "Ngừng hoạt động", + "heatmapAria": "Bản đồ nhiệt thời gian phản hồi", + "max": "tối đa {{value}}ms" + }, + "status": { + "breached": "Vượt ngưỡng", + "down": "Ngừng hoạt động", + "initializing": "Đang khởi tạo", + "maintenance": "Bảo trì", + "paused": "Tạm dừng", + "up": "Hoạt động" } }, "statusBar": { @@ -1306,7 +1384,9 @@ "maintenance": "Một số hệ thống đang bảo trì", "partiallyPaused": "Một số hệ thống đã tạm dừng", "initializing": "Một số hệ thống đang khởi tạo", - "noMonitors": "Chưa cấu hình màn hình giám sát nào" + "noMonitors": "Chưa cấu hình màn hình giám sát nào", + "monitoringSummary": "Đang giám sát {{count}} dịch vụ · Cập nhật mỗi 10 giây", + "monitoringSummary_other": "Đang giám sát {{count}} dịch vụ · Cập nhật mỗi 10 giây" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "label": "Hiển thị số liệu hạ tầng" } } + }, + "theme": { + "description": "Chọn kiểu hiển thị cho trang trạng thái công khai của bạn.", + "label": "Chủ đề", + "options": { + "bold": { + "description": "Kiểu chữ lớn và khoảng cách tự tin. Tuyệt vời cho các trang tập trung vào thương hiệu.", + "name": "Đậm" + }, + "editorial": { + "description": "Tiêu đề serif và dải phân cách mỏng. Đặc biệt và tĩnh lặng.", + "name": "Biên tập" + }, + "modern": { + "description": "Chất lượng SaaS với chấm trạng thái nhấp nháy và thẻ mờ dần nhẹ nhàng.", + "name": "Hiện đại" + }, + "refined": { + "description": "Sạch sẽ, chuyên nghiệp, gần với giao diện Checkmate cổ điển.", + "name": "Tinh tế" + } + }, + "title": "Chủ đề trang" + }, + "themeMode": { + "auto": "Tự động (theo thiết bị của khách)", + "dark": "Chỉ tối", + "label": "Chế độ chủ đề", + "light": "Chỉ sáng" } }, "header": { "title": "Trang trạng thái", "description": "Công bố các trang công khai cho khách hàng và các bên liên quan thấy thời gian hoạt động theo thời gian thực và lịch sử sự cố." + }, + "editorial": { + "allUp": "Tất cả các hệ thống đang hoạt động bình thường.", + "reportTitle": "Báo cáo dịch vụ" + }, + "footer": { + "poweredBy": "Được cung cấp bởi" } }, "uptime": { diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index ed5e70c1e..a7166e6c2 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -73,7 +73,9 @@ "paused": "已暂停", "na": "不适用", "resolved": "已解决", - "responseTime": "响应时间" + "responseTime": "响应时间", + "idle": "空闲", + "running": "运行中" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "活跃", "responseTime": "响应时间" } + }, + "statusCode": { + "down": "宕机", + "networkError": { + "dns": "无法解析主机名(DNS查找失败)。", + "refused": "服务器拒绝连接(端口关闭或服务未监听)。", + "reset": "在收到响应之前连接被重置。", + "timeout": "服务器未及时响应(请求超时)。", + "tls": "TLS或SSL握手失败(证书或加密问题)。", + "unknown": "未从目标接收HTTP响应。" + } } }, "components": { @@ -421,7 +434,11 @@ "inviteMember": "邀请成员", "openSite": "打开站点", "pause": "暂停", - "resume": "恢复" + "resume": "恢复", + "selectAll": "选择所有监视器", + "selectMonitor": "选择 {{name}}", + "bulkSelected_one": "已选择 {{count}} 个监视器", + "bulkSelected_other": "已选择 {{count}} 个监视器" }, "statBoxes": { "activeFor": "已活跃", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "基础设施", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "已暂停 {{count}} 个监视器", + "paused_other": "已暂停 {{count}} 个监视器", + "resumed_one": "已恢复 {{count}} 个监视器", + "resumed_other": "已恢复 {{count}} 个监视器", + "alreadyPaused_one": "所选监视器已暂停", + "alreadyPaused_other": "所有选定的监视器均已暂停", + "alreadyRunning_one": "所选监视器已经在运行", + "alreadyRunning_other": "所有选定的监视器均已在运行", + "partialFailure": "已更新 {{successCount}} 个监控,但有 {{failedCount}} 个无法与任务队列同步" } } }, @@ -534,6 +563,30 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "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": "常规设置", @@ -546,7 +599,8 @@ "pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。", "grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。", "websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。", - "hardware": "监控基础设施的 CPU、内存、磁盘使用率和温度。" + "hardware": "监控基础设施的 CPU、内存、磁盘使用情况和温度。Capture 端点 URL 必须包含完整路径 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授权密钥只需粘贴令牌字符串——请勿包含 'Bearer'。", + "dns": "监控特定DNS服务器是否响应。" } }, "ignoreTls": { @@ -583,7 +637,8 @@ "optionHttpDescription": "使用 HTTP(S) 监控您的网站或 API 端点。", "optionPingDescription": "使用 ICMP Ping 监控服务器是否在线。", "optionPortDescription": "监控服务器上的特定端口是否开放。", - "title": "类型" + "title": "类型", + "optionDnsDescription": "监控特定DNS服务器是否响应。" }, "thresholds": { "title": "告警阈值", @@ -891,7 +946,8 @@ "lastFinishedAt": "上次完成于", "lastRunTook": "上次运行耗时", "lastFailedAt": "上次失败于", - "failReason": "失败原因" + "failReason": "失败原因", + "state": "状态" } }, "queueFallback": { @@ -1081,6 +1137,10 @@ "header": { "title": "PageSpeed 监控器", "description": "持续追踪 Core Web Vitals 和 Lighthouse 评分,在用户察觉之前发现性能回退。" + }, + "strategy": { + "desktop": "桌面", + "mobile": "移动端" } }, "settings": { @@ -1229,6 +1289,10 @@ }, "rejectUnauthorized": { "label": "拒绝无效证书:拒绝自签名或不受信任证书的连接" + }, + "displayName": { + "label": "发件人姓名(可选)- 显示在发件人字段中的友好名称", + "placeholder": "Checkmate通知" } } }, @@ -1289,6 +1353,20 @@ "uptime": { "title": "正常运行时间", "responseTime": "响应时间" + }, + "chart": { + "avg": "平均 {{value}}ms", + "downTooltip": "宕机", + "heatmapAria": "响应时间热图", + "max": "最大 {{value}}ms" + }, + "status": { + "breached": "超出阈值", + "down": "宕机", + "initializing": "初始化中", + "maintenance": "维护中", + "paused": "已暂停", + "up": "运行中" } }, "statusBar": { @@ -1306,7 +1384,9 @@ "maintenance": "部分系统正在维护", "partiallyPaused": "部分系统已暂停", "initializing": "部分系统正在初始化", - "noMonitors": "尚未配置任何监控器" + "noMonitors": "尚未配置任何监控器", + "monitoringSummary": "正在监控{{count}}个服务 · 每10秒更新一次", + "monitoringSummary_other": "正在监控{{count}}个服务 · 每10秒更新一次" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "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": { diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index ff6e9f6dd..ba599bb4f 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -73,7 +73,9 @@ "paused": "已暫停", "na": "N/A", "resolved": "已解決", - "responseTime": "回應時間" + "responseTime": "回應時間", + "idle": "閒置", + "running": "執行中" }, "form": { "role": { @@ -120,6 +122,17 @@ "active": "啟用中", "responseTime": "回應時間" } + }, + "statusCode": { + "down": "停機", + "networkError": { + "dns": "無法解析主機名稱(DNS查詢失敗)。", + "refused": "伺服器拒絕連線(連接埠關閉或服務未監聽)。", + "reset": "在收到回應之前連線被重設。", + "timeout": "伺服器未及時回應(請求逾時)。", + "tls": "TLS或SSL交握失敗(憑證或加密問題)。", + "unknown": "未從目標接收HTTP回應。" + } } }, "components": { @@ -421,7 +434,11 @@ "inviteMember": "邀請成員", "openSite": "開啟網站", "pause": "暫停", - "resume": "恢復" + "resume": "恢復", + "selectAll": "選擇所有監視器", + "selectMonitor": "選擇 {{name}}", + "bulkSelected_one": "已選擇 {{count}} 個監視器", + "bulkSelected_other": "已選擇 {{count}} 個監視器" }, "statBoxes": { "activeFor": "已啟用", @@ -448,7 +465,19 @@ "optionPagespeed": "PageSpeed", "optionHardware": "基礎架構", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" + }, + "bulkPause": { + "paused_one": "已暫停 {{count}} 個監視器", + "paused_other": "已暫停 {{count}} 個監視器", + "resumed_one": "已恢復 {{count}} 個監視器", + "resumed_other": "已恢復 {{count}} 個監視器", + "alreadyPaused_one": "所選監視器已暫停", + "alreadyPaused_other": "所有選定的監視器均已暫停", + "alreadyRunning_one": "所選監視器已經在運行", + "alreadyRunning_other": "所有選定的監視器均已在運行", + "partialFailure": "已更新 {{successCount}} 個監控,但有 {{failedCount}} 個無法與工作佇列同步" } } }, @@ -534,6 +563,30 @@ "wsUrl": { "label": "WebSocket URL", "placeholder": "wss://example.com/socket" + }, + "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": "一般設定", @@ -546,7 +599,8 @@ "pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。", "grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。", "websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。", - "hardware": "監控您基礎架構的 CPU、記憶體、磁碟使用量和溫度。" + "hardware": "監控基礎架構的 CPU、記憶體、磁碟使用情況和溫度。Capture 端點 URL 必須包含完整路徑 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授權密鑰僅貼上權杖字串——請勿包含 'Bearer'。", + "dns": "監控特定DNS伺服器是否回應。" } }, "ignoreTls": { @@ -583,7 +637,8 @@ "optionHttpDescription": "使用 HTTP(S) 監控您的網站或 API 端點。", "optionPingDescription": "使用 ICMP Ping 監控伺服器是否上線。", "optionPortDescription": "監控伺服器上的特定連接埠是否開啟。", - "title": "類型" + "title": "類型", + "optionDnsDescription": "監控特定DNS伺服器是否回應。" }, "thresholds": { "title": "警報閾值", @@ -891,7 +946,8 @@ "lastFinishedAt": "最後完成於", "lastRunTook": "最後執行耗時", "lastFailedAt": "最後失敗於", - "failReason": "失敗原因" + "failReason": "失敗原因", + "state": "狀態" } }, "queueFallback": { @@ -1081,6 +1137,10 @@ "header": { "title": "PageSpeed 監視器", "description": "持續追蹤 Core Web Vitals 與 Lighthouse 評分,在使用者察覺前發現效能退化。" + }, + "strategy": { + "desktop": "桌面", + "mobile": "行動裝置" } }, "settings": { @@ -1229,6 +1289,10 @@ }, "rejectUnauthorized": { "label": "拒絕無效憑證:拒絕使用自簽或不受信任憑證的連線" + }, + "displayName": { + "label": "寄件者名稱(選填)- 顯示在寄件者欄位的友善名稱", + "placeholder": "Checkmate通知" } } }, @@ -1289,6 +1353,20 @@ "uptime": { "title": "正常運行時間", "responseTime": "回應時間" + }, + "chart": { + "avg": "平均 {{value}}ms", + "downTooltip": "停機", + "heatmapAria": "回應時間熱圖", + "max": "最大 {{value}}ms" + }, + "status": { + "breached": "超過閾值", + "down": "停機", + "initializing": "初始化中", + "maintenance": "維護中", + "paused": "已暫停", + "up": "運行中" } }, "statusBar": { @@ -1306,7 +1384,9 @@ "maintenance": "部分系統正在維護", "partiallyPaused": "部分系統已暫停", "initializing": "部分系統正在初始化", - "noMonitors": "尚未設定任何監視器" + "noMonitors": "尚未設定任何監視器", + "monitoringSummary": "正在監控{{count}}個服務 · 每10秒更新一次", + "monitoringSummary_other": "正在監控{{count}}個服務 · 每10秒更新一次" }, "table": { "headers": { @@ -1397,11 +1477,47 @@ "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": { diff --git a/docker/dist-arm/docker-compose.yaml b/docker/dist-arm/docker-compose.yaml index 2255de508..b9aba057c 100644 --- a/docker/dist-arm/docker-compose.yaml +++ b/docker/dist-arm/docker-compose.yaml @@ -18,8 +18,6 @@ services: image: mongo:6.0 restart: always command: ["mongod", "--quiet", "--bind_ip_all"] - ports: - - "27017:27017" volumes: - ./mongo/data:/data/db healthcheck: diff --git a/docker/dist-arm/server.Dockerfile b/docker/dist-arm/server.Dockerfile index 208574fe4..a522dabfa 100644 --- a/docker/dist-arm/server.Dockerfile +++ b/docker/dist-arm/server.Dockerfile @@ -40,6 +40,11 @@ RUN npm run build COPY --from=frontend-build /app/client/dist ./public +RUN chown -R node:node ./public \ + && chown -R node:node ./dist \ + && chown -R node:node ./scripts + + EXPOSE 52345 CMD ["sh", "-c", "./scripts/inject-vars.sh && node ./dist/index.js"] diff --git a/docker/dist-mono/server.Dockerfile b/docker/dist-mono/server.Dockerfile index 5e1bf5c13..3f14d8fc1 100644 --- a/docker/dist-mono/server.Dockerfile +++ b/docker/dist-mono/server.Dockerfile @@ -26,8 +26,12 @@ RUN cp -r src/templates dist/templates COPY --from=frontend-build /app/client/dist ./public -RUN chmod +x ./scripts/inject-vars.sh +RUN chmod +x ./scripts/inject-vars.sh \ + && chown -R node:node ./public \ + && chown -R node:node ./dist \ + && chown -R node:node ./scripts + EXPOSE 52345 -CMD ./scripts/inject-vars.sh && node ./dist/index.js \ No newline at end of file +CMD ["sh", "-c", "./scripts/inject-vars.sh && node ./dist/index.js"] diff --git a/docs/coding-conventions.md b/docs/coding-conventions.md new file mode 100644 index 000000000..61f4a5b9c --- /dev/null +++ b/docs/coding-conventions.md @@ -0,0 +1,404 @@ +# Coding conventions + +Style and structural rules the codebase follows. Frontend rules are extracted from review feedback on PR series #3591–#3596; backend rules are extracted from the provider/repository/service layering used across `server/src/service/infrastructure/network/*` and the patterns enforced in code review. These are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent. + +> If you're touching a file in `client/src` the **frontend** rules apply. +> If you're touching a file in `server/src` the **backend** rules apply. +> Rule 0 applies everywhere. + +--- + +## 0. Look at existing implementations and follow the established pattern + +Before writing new code, find one or two files that already do something similar — a peer provider for a new monitor type, a peer page for a new admin screen, a peer repository method for a new query — and follow the same shape. Match imports, helper-function placement, naming, error handling, mock-construction style, and assertion idioms. If you're tempted to do it differently, the burden is on you to justify the divergence. + +**Why:** consistency is more valuable than locally-optimal cleverness. A reader who has internalized the pattern of one peer file should be able to read the new one with no extra mental load. New shapes also make refactors more expensive — when someone wants to change the convention later, every variant has to be visited and reasoned about separately. + +**How to apply:** before writing a new component, page, provider, repository, or test file: + +1. Grep for the closest existing peers (e.g. `WebSocketProvider.ts` if you're writing a new monitor provider). +2. Read at least two of them end-to-end. +3. Mirror their structure: same constants at the top (`SERVICE_NAME`, `TIMEOUT_MS`), same import order, same DI shape, same try/catch envelope, same test-file location and helper imports. +4. If your new code needs a shape no peer has, name it explicitly in the PR description and link to the peer you diverged from. Don't sneak novelty in. + +**Concrete check before opening a PR:** can a reader open a peer file side-by-side with yours and trace each section in the same order? If not, restructure. + +--- + +# Part 1 — Frontend (`client/src`) + +## 1. Prefer MUI native (system) props over `sx` + +MUI components accept layout, spacing, color, border, typography and sizing values as top-level props. Use those. Reach for `sx` only when no native prop exists for the value you need (e.g. `textDecoration`, pseudo-selectors, complex selectors, conditional CSS). + +**Why:** native props are easier to read in JSX, easier to grep, easier to refactor, and don't force the reader to mentally parse a nested object literal for things that are conceptually one attribute. A `sx` block also can't be split-line-edited cleanly when most of its keys are simple primitives. + +**How to apply:** any time you write `sx={{ … }}`, ask whether each key has a native equivalent. If yes, hoist it. + +### Canonical mappings + +| `sx` key | Native prop | +|---|---| +| `color` | `color` | +| `backgroundColor` | `bgcolor` | +| `borderRadius` | `borderRadius` | +| `border`, `borderTop`, … | `border`, `borderTop`, … | +| `width`, `height`, `minHeight`, `maxWidth` | same names as native | +| `p`, `pt`, `pb`, `px`, `py` | same names as native | +| `m`, `mt`, `mb`, `mx`, `my` | same names as native | +| `display` | `display` | +| `textAlign` | `textAlign` | +| `fontWeight`, `fontSize`, `lineHeight` | same names as native | +| `gap` (on Stack) | `gap` | + +### Examples + +```tsx +// BAD — everything inside sx + + +// GOOD — every key that has a native prop is hoisted + +``` + +```tsx +// BAD + + +// GOOD — hoist what you can; drop fontSize:13 because it's the body default + +``` + +### When `sx` is still correct + +Keep `sx` for things that don't have a native prop: + +```tsx + +``` + +Pseudo-selectors (`"&:hover"`), `transition`, `cursor`, `outline`, `overflow` (when not on Box/Stack), media queries, and conditional spread objects all stay in `sx`. + +--- + +## 2. Use the full theme path for colors, never the shorthand string + +Prop forms like `color="text.secondary"`, `color="primary.main"`, `bgcolor="background.paper"` work, but they are **forbidden in this codebase**. + +**Why:** the shorthand is invisible to grep. If a future change needs to find every place that consumes `theme.palette.text.secondary` — to retheme, to swap, to audit — the shorthand strings are missed. That is a maintenance trap. It's worth a few extra characters at every callsite to keep the codebase searchable. + +**How to apply:** always destructure or reference the theme value explicitly. + +```tsx +// BAD +… +… + +// GOOD +const theme = useTheme(); +… +… +``` + +This applies to every theme-aware string prop (`color`, `bgcolor`, `borderColor`, etc.), and inside `sx` if you really need it there. + +--- + +## 3. No hardcoded numeric or string literals — use tokens + +The theme exposes tokens for every kind of magic value. Use them. Reserve raw numbers for values that are genuinely one-off (and even then, push back on yourself first). + +**Why:** tokens are the single source of truth. A literal `2` in a `mt` prop is invisible — it could mean `LAYOUT.XXS`, a rounding correction, or a thinko. The reader can't tell. With a token, intent is in the name. + +**How to apply:** before writing a literal, check whether one of these tokens covers it. If it nearly does but not exactly, extend the token rather than hardcoding (Alex did exactly this — he added `LAYOUT.XXS = 2` rather than letting `mt: "2px"` stand). + +### Token sources + +- **Spacing / layout** — `client/src/Utils/Theme/constants.ts` + - `LAYOUT.XXS = 2` · `LAYOUT.XS = 4` · `LAYOUT.SM = 6` · `LAYOUT.MD = 8` · `LAYOUT.LG = 10` · `LAYOUT.XL = 12` · `LAYOUT.XXL = 16` + - `SPACING` for `theme.spacing(...)` multipliers +- **Typography sizes** — `client/src/Utils/Theme/Palette.ts` → `typographyLevels` + - `xs / s / m / l / xl / xxl` (rem values) +- **Hover / interaction** — `HOVER.DARKEN` etc. +- **Shape** — `theme.shape.borderRadius` (don't write `borderRadius: 1` or `borderRadius: 4`) +- **Colors** — `theme.palette.*` (rule 2 above) + +### Examples + +```tsx +// BAD + + + + + // 13 is the body default — drop the prop entirely + +// GOOD + + + + + // inherits 13px from theme +``` + +### Token naming gotcha + +`typographyLevels.xl` was historically used for both 18px and 23px callsites. Don't reuse a token for two different purposes — split it (`xl` = 18, `xxl` = 23) and update every callsite. If you find an ambiguous token while editing, fix it the same way. + +--- + +## 4. Use `useTheme()` inside components, don't import the theme module + +The `theme` object exported from `@/Utils/Theme/Theme` is the *factory output*, not a live, mode-aware instance. Importing it directly couples a component to the wrong reference and breaks dark-mode reactivity. It also breaks the build at import time in some module-graph configurations (this happened in PR #3596 and required a follow-up commit). + +**Why:** themes can change at runtime (mode toggle), and the hook is the only correct way to read the active theme. Imports also create circular-graph risk because `Theme.ts` itself depends on `Palette.ts`. + +**How to apply:** inside any component or component-helper that needs theme values: + +```tsx +// BAD +import { theme } from "@/Utils/Theme/Theme"; +const SectionHeading = ({ children }) => ( + {children} +); + +// GOOD +import { useTheme } from "@mui/material/styles"; +const SectionHeading = ({ children }) => { + const theme = useTheme(); + return {children}; +}; +``` + +Module-level utility files that don't render React (pure helpers) should accept `theme` as a parameter rather than importing it. + +--- + +## 5. Discriminated unions: pair the runtime list with the type + +When you have a closed set of string literals that's used both as a type and as runtime data (severities, statuses, kinds), declare the runtime tuple first and derive the type from it. Don't declare them as two parallel literals that can drift. + +**Why:** if the list and the type aren't tied to each other, adding a new value to one and forgetting the other compiles fine and breaks at runtime. Deriving the type from the tuple makes them inseparable. + +```tsx +// BAD +type Severity = "info" | "warning" | "error"; + +// GOOD +export const Severities = ["info", "warning", "error"] as const; +export type Severity = (typeof Severities)[number]; +``` + +Export both when callers need to iterate (e.g. for a Storybook stories matrix or a select dropdown). + +--- + +# Part 2 — Backend (`server/src`) + +## 6. Layering: Controller → Service → Repository → Mongoose + +The backend enforces a strict three-layer separation between HTTP handling, business logic, and data access. Every new feature follows this path, no shortcuts. + +``` +Request → Controller → Service → Repository → MongoDB (Mongoose) +``` + +**Why:** this is what makes each layer independently testable. Mongoose calls in services force services to mock the database in tests; raw DB queries in controllers couple HTTP shape to schema shape and turn every refactor into a multi-file rewrite. + +**How to apply:** when adding a new feature, the pattern is — **add a repository method** for any new DB query, **call it from a service**, **expose it via a controller**. If a service constructs a `MonitorModel.findOne({...})`, you've broken the rule; the query belongs in the repository. + +- **Controllers** (`server/src/controllers/`) handle HTTP concerns only: parse params, call the service, return via the `responseHandler` middleware. No business logic. +- **Services** (`server/src/service/business/`) contain all business logic. They throw `AppError` on validation/auth failures; they do not write directly to MongoDB. +- **Repositories** (`server/src/repositories/`) are the sole layer that talks to MongoDB. They expose clean query methods (`findByMonitorId`, `createCheck`, `updateStatusWindowAndChecks`) and return entities — never Mongoose documents — via a `toEntity` private helper. + +--- + +## 7. Provider conventions for `service/infrastructure/network/*` + +Every monitor-type provider implements `IStatusProvider` and follows the same skeleton. + +**Why:** the registry in `services.ts` builds providers identically (`new XxxProvider(deps)`) and `NetworkService` invokes them identically (`provider.handle(monitor)`). If your provider's shape diverges, the registry/dispatch becomes asymmetric and `NetworkService` grows special cases. + +**How to apply:** copy the structure of a recent peer (`WebSocketProvider`, `GrpcProvider`, `PortProvider`). The skeleton: + +```ts +const SERVICE_NAME = "XxxProvider"; +const TIMEOUT_MS = 10000; + +type ClientCtor = typeof SomeNodeStdlibClass; + +export class XxxProvider implements IStatusProvider { + readonly type = "xxx"; + + constructor(private Client: ClientCtor) {} + + supports(type: MonitorType): boolean { + return type === "xxx"; + } + + async handle(monitor: Monitor): Promise> { + try { + // 1. validate inputs → throw AppError(400) on bad input + // 2. set up the client (use this.Client, never imported singleton) + // 3. perform the call inside timeRequest(...), with a setTimeout race + // in the Promise body to enforce TIMEOUT_MS + // 4. return MonitorStatusResponse with payload + } catch (err: unknown) { + if (err instanceof AppError) throw err; + const originalMessage = err instanceof Error ? err.message : String(err); + throw new AppError({ + message: originalMessage || "Error performing xxx check", + status: 500, + service: SERVICE_NAME, + method: "handle", + details: { /* monitor-shaped context */ }, + }); + } + } +} +``` + +### Hard rules + +- **DI for all external dependencies.** `typeof WebSocket`, `typeof net` — pass the constructor in via the provider's constructor. Never `import { Foo } from "some/library"` and call it inside `handle()`. This is the single thing that makes the provider unit-testable without `jest.unstable_mockModule`. +- **`SERVICE_NAME` and `TIMEOUT_MS`** as module-level constants near the top, not magic strings/numbers in the body. +- **Use the shared `timeRequest()` helper** from `service/infrastructure/network/utils.ts` to measure latency. Don't roll your own `process.hrtime()`. +- **Enforce a hard timeout.** Every peer provider inlines a `setTimeout` race in the Promise body around the underlying client call (see `WebSocketProvider.ts`); sockets, DNS resolvers, and external clients can hang indefinitely without it. There is no shared `withTimeout` helper today — match the inline pattern. +- **Outer try/catch** that converts unknown errors to `AppError({ service, method, details })` while letting your own `AppError` pass through. + +--- + +## 8. Mongoose schema fields with closed value sets + +If a field's TypeScript type is a closed union (status, type, record type), the Mongoose schema must declare the same set as `enum`. + +**Why:** the validator and the schema are independent gates. A service-layer Zod check can be bypassed (imports, migrations, direct repo writes). A schema `enum` is the last line of defense and surfaces invalid data as a Mongoose validation error rather than silently accepting `"badtype"` and breaking downstream consumers. + +**How to apply:** when you add a string field whose values are constrained by a const tuple in `types/`, reuse the same const for the schema enum: + +```ts +// types/monitor.ts (existing) +export const MonitorMatchMethods = ["equal", "include", "regex"] as const; + +// db/models/Monitor.ts +{ + matchMethod: { + type: String, + enum: MonitorMatchMethods, + }, +} +``` + +The same applies to `MonitorTypes`, `MonitorStatuses`, and any new closed-set field. + +--- + +## 9. Test conventions for `server/test/unit/providers/network/*` + +Each provider gets a unit test file at `server/test/unit/providers/network/.test.ts`. Follow the structure used by `webSocketProvider.test.ts`, `grpcProvider.test.ts`, `portProvider.test.ts`. + +**Why:** consistency lets reviewers diff a new test file against any peer and only see the actual logic differences, not framework/import noise. It also lets the `testStatusProviderContract` helper guarantee every provider satisfies the `IStatusProvider` contract. + +**How to apply:** + +- **Imports**: `from "@jest/globals"` (not the global `jest`), and `.ts` extensions on every relative import. +- **Contract test**: call `testStatusProviderContract("XxxProvider", { create, supportedType, unsupportedType, makeMonitor })` before the per-test `describe` block. +- **Mock construction**: build the constructor mock as `jest.fn().mockImplementation(() => mock) as unknown as typeof Client` (typed cast, not `as any`). +- **`makeMonitor` factory**: takes `Partial` overrides, spreads onto a baseline, returns `as Monitor`. +- **Inline `setup()` per test** rather than `beforeEach` shared state. Each test should construct its own mock + provider, so a single test can be read in isolation: + + ```ts + const setup = () => { + const mock = createMockClient(); + const Ctor = jest.fn().mockImplementation(() => mock) as unknown as typeof Client; + const provider = new XxxProvider(Ctor); + return { mock, provider }; + }; + + it("does X", async () => { + const { mock, provider } = setup(); + // ... + }); + ``` + +- **Assertion style**: individual `expect(result.field).toBe(...)` lines or `expect(result).toEqual(expect.objectContaining({...}))`. Both are used; pick what reads cleanest for the case. +- **Test naming**: lowercase imperative ("returns success when connection opens", "uses the configured timeout", "throws AppError when host is missing"). + +--- + +## 10. Validation: centralize, don't inline + +Whenever the same string-set or shape constraint appears in two or more validators, lift it into `types/` as a const tuple and `import` it into both validators. Don't inline `z.enum(["A", "AAAA", ...])` in three places. + +**Why:** drift. Three inline copies will eventually disagree — someone adds a new value to one and forgets the other two. The validator silently accepts it on `POST /monitors` and rejects it on `PATCH`, leading to bug reports the author can't reproduce. + +**How to apply:** keep validation source-of-truth in `server/src/types/*.ts` as paired const tuples + derived types (rule 5). The validators in `server/src/validation/*.ts` and the Mongoose schemas in `server/src/db/models/*.ts` import from `types/` and reuse the same tuple. + +--- + +## 11. OpenAPI: response schemas live in the validator file + +The OpenAPI `monitorObject` (and friends) are generated from a `monitorResponseSchema` Zod object exported from the validator file, not hand-built in `server/openapi/routes/`. When you add a field to the data model, add it to the response schema in the same edit. + +**Why:** the spec is auto-generated. If the response schema doesn't list a field, SDK consumers don't see it, even though the server returns it. That's a silent contract gap. + +**How to apply:** when you add an entity field, update three files in the same PR: + +1. `types/.ts` — add the field to the TS interface. +2. `db/models/.ts` — add the Mongoose schema entry (with `enum` if the value set is closed; rule 8). +3. `validation/Validation.ts` — add the field to the response schema (and create/edit body schemas if it's user-settable). + +The repository's `toEntity` helper also needs to surface the new field; that's part of the change, not a separate one. + +--- + +# Pre-PR checklist + +Before opening a PR, grep your diff for the patterns below. Each item describes the **anti-pattern** to find and fix; if your diff has none, that line passes. Frontend section applies to `client/src`; backend to `server/src`. + +## Universal +- [ ] I can name the peer file I used as a template, and my file's structure mirrors it (rule 0). +- [ ] `npm run build` and `npm run format-check` are clean in both `client/` and `server/`. + +## Frontend (`client/src`) +- [ ] No `sx={{ … }}` block containing keys with a native-prop equivalent (rule 1). +- [ ] No `color="text.X"`, `bgcolor="background.X"`, or other short-string palette paths (rule 2). +- [ ] No raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3). +- [ ] No `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4). +- [ ] No `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5). + +## Backend (`server/src`) +- [ ] No `MonitorModel.findOne` (or similar Mongoose call) outside `repositories/` (rule 6). +- [ ] No provider that imports its underlying client at module top instead of receiving it via constructor DI (rule 7). +- [ ] No provider missing `SERVICE_NAME` / `TIMEOUT_MS` / `AppError` outer catch / `timeRequest()` / inline timeout (rule 7). +- [ ] No closed-set string field on a Mongoose schema without an `enum` (rule 8). +- [ ] No provider test that uses `beforeEach` instead of inline `setup()` (rule 9). +- [ ] No duplicated `z.enum([...])` literal that should reference a `types/*` const tuple (rule 10). +- [ ] No new entity field missing from `monitorResponseSchema` (or peer response schema) for OpenAPI (rule 11). + +A clean PR on these axes is a PR that ships without a normalize follow-up. diff --git a/docs/frontend-conventions.md b/docs/frontend-conventions.md deleted file mode 100644 index 4f14c555b..000000000 --- a/docs/frontend-conventions.md +++ /dev/null @@ -1,211 +0,0 @@ -# Frontend conventions - -Style and structural rules the client codebase follows. These are extracted from review feedback on the PR series #3591–#3596 and are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent with the existing theme. - -> If you're touching a file in `client/src`, every rule below applies. The "why" matters as much as the "what" — read the reasoning so you can judge edge cases. - ---- - -## 1. Prefer MUI native (system) props over `sx` - -MUI components accept layout, spacing, color, border, typography and sizing values as top-level props. Use those. Reach for `sx` only when no native prop exists for the value you need (e.g. `textDecoration`, pseudo-selectors, complex selectors, conditional CSS). - -**Why:** native props are easier to read in JSX, easier to grep, easier to refactor, and don't force the reader to mentally parse a nested object literal for things that are conceptually one attribute. A `sx` block also can't be split-line-edited cleanly when most of its keys are simple primitives. - -**How to apply:** any time you write `sx={{ … }}`, ask whether each key has a native equivalent. If yes, hoist it. - -### Canonical mappings - -| `sx` key | Native prop | -|---|---| -| `color` | `color` | -| `backgroundColor` | `bgcolor` | -| `borderRadius` | `borderRadius` | -| `border`, `borderTop`, … | `border`, `borderTop`, … | -| `width`, `height`, `minHeight`, `maxWidth` | same names as native | -| `p`, `pt`, `pb`, `px`, `py` | same names as native | -| `m`, `mt`, `mb`, `mx`, `my` | same names as native | -| `display` | `display` | -| `textAlign` | `textAlign` | -| `fontWeight`, `fontSize`, `lineHeight` | same names as native | -| `gap` (on Stack) | `gap` | - -### Examples - -```tsx -// BAD — everything inside sx - - -// GOOD — every key that has a native prop is hoisted - -``` - -```tsx -// BAD - - -// GOOD — hoist what you can; drop fontSize:13 because it's the body default - -``` - -### When `sx` is still correct - -Keep `sx` for things that don't have a native prop: - -```tsx - -``` - -Pseudo-selectors (`"&:hover"`), `transition`, `cursor`, `outline`, `overflow` (when not on Box/Stack), media queries, and conditional spread objects all stay in `sx`. - ---- - -## 2. Use the full theme path for colors, never the shorthand string - -Prop forms like `color="text.secondary"`, `color="primary.main"`, `bgcolor="background.paper"` work, but they are **forbidden in this codebase**. - -**Why:** the shorthand is invisible to grep. If a future change needs to find every place that consumes `theme.palette.text.secondary` — to retheme, to swap, to audit — the shorthand strings are missed. That is a maintenance trap. It's worth a few extra characters at every callsite to keep the codebase searchable. - -**How to apply:** always destructure or reference the theme value explicitly. - -```tsx -// BAD -… -… - -// GOOD -const theme = useTheme(); -… -… -``` - -This applies to every theme-aware string prop (`color`, `bgcolor`, `borderColor`, etc.), and inside `sx` if you really need it there. - ---- - -## 3. No hardcoded numeric or string literals — use tokens - -The theme exposes tokens for every kind of magic value. Use them. Reserve raw numbers for values that are genuinely one-off (and even then, push back on yourself first). - -**Why:** tokens are the single source of truth. A literal `2` in a `mt` prop is invisible — it could mean `LAYOUT.XXS`, a rounding correction, or a thinko. The reader can't tell. With a token, intent is in the name. - -**How to apply:** before writing a literal, check whether one of these tokens covers it. If it nearly does but not exactly, extend the token rather than hardcoding (Alex did exactly this — he added `LAYOUT.XXS = 2` rather than letting `mt: "2px"` stand). - -### Token sources - -- **Spacing / layout** — `client/src/Utils/Theme/constants.ts` - - `LAYOUT.XXS = 2` · `LAYOUT.XS = 4` · `LAYOUT.SM = 6` · `LAYOUT.MD = 8` · `LAYOUT.LG = 10` · `LAYOUT.XL = 12` · `LAYOUT.XXL = 16` - - `SPACING` for `theme.spacing(...)` multipliers -- **Typography sizes** — `client/src/Utils/Theme/Palette.ts` → `typographyLevels` - - `xs / s / m / l / xl / xxl` (rem values) -- **Hover / interaction** — `HOVER.DARKEN` etc. -- **Shape** — `theme.shape.borderRadius` (don't write `borderRadius: 1` or `borderRadius: 4`) -- **Colors** — `theme.palette.*` (rule 2 above) - -### Examples - -```tsx -// BAD - - - - - // 13 is the body default — drop the prop entirely - -// GOOD - - - - - // inherits 13px from theme -``` - -### Token naming gotcha - -`typographyLevels.xl` was historically used for both 18px and 23px callsites. Don't reuse a token for two different purposes — split it (`xl` = 18, `xxl` = 23) and update every callsite. If you find an ambiguous token while editing, fix it the same way. - ---- - -## 4. Use `useTheme()` inside components, don't import the theme module - -The `theme` object exported from `@/Utils/Theme/Theme` is the *factory output*, not a live, mode-aware instance. Importing it directly couples a component to the wrong reference and breaks dark-mode reactivity. It also breaks the build at import time in some module-graph configurations (this happened in PR #3596 and required a follow-up commit). - -**Why:** themes can change at runtime (mode toggle), and the hook is the only correct way to read the active theme. Imports also create circular-graph risk because `Theme.ts` itself depends on `Palette.ts`. - -**How to apply:** inside any component or component-helper that needs theme values: - -```tsx -// BAD -import { theme } from "@/Utils/Theme/Theme"; -const SectionHeading = ({ children }) => ( - {children} -); - -// GOOD -import { useTheme } from "@mui/material/styles"; -const SectionHeading = ({ children }) => { - const theme = useTheme(); - return {children}; -}; -``` - -Module-level utility files that don't render React (pure helpers) should accept `theme` as a parameter rather than importing it. - ---- - -## 5. Discriminated unions: pair the runtime list with the type - -When you have a closed set of string literals that's used both as a type and as runtime data (severities, statuses, kinds), declare the runtime tuple first and derive the type from it. Don't declare them as two parallel literals that can drift. - -**Why:** if the list and the type aren't tied to each other, adding a new value to one and forgetting the other compiles fine and breaks at runtime. Deriving the type from the tuple makes them inseparable. - -```tsx -// BAD -type Severity = "info" | "warning" | "error"; - -// GOOD -export const Severities = ["info", "warning", "error"] as const; -export type Severity = (typeof Severities)[number]; -``` - -Export both when callers need to iterate (e.g. for a Storybook stories matrix or a select dropdown). - ---- - -## Pre-PR checklist - -Before you open a PR, scan your diff for these: - -- [ ] Any `sx={{ … }}` block that contains keys with a native-prop equivalent (rule 1) -- [ ] Any `color="text.X"`, `bgcolor="background.X"`, or any short-string palette path (rule 2) -- [ ] Any raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3) -- [ ] Any `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4) -- [ ] Any `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5) -- [ ] Run `npm run build` and `npm run format-check` in both `client/` and `server/` (per global instructions) - -A clean PR on these axes is a PR that ships without a normalize follow-up. diff --git a/docs/translations/README.ar.md b/docs/translations/README.ar.md new file mode 100644 index 000000000..81a0fc8d8 --- /dev/null +++ b/docs/translations/README.ar.md @@ -0,0 +1,204 @@ +
+ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +> ⚠️ هذه الترجمة مسودة آلية. مرحبًا بمراجعات الناطقين الأصليين عبر pull request. + + +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

تطبيق مفتوح المصدر لمراقبة الجاهزية والبنية التحتية

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +يحتوي هذا المستودع على واجهة المستخدم والخادم الخلفي معًا لـ Checkmate، وهي أداة مراقبة مفتوحة المصدر وقابلة للاستضافة الذاتية، تتيح تتبع عتاد الخوادم، وحالة التشغيل، وأزمنة الاستجابة، والحوادث في الوقت الحقيقي بتصورات أنيقة. يقوم Checkmate بفحص الخادم/الموقع بشكل دوري للتأكد من إمكانية الوصول إليه وعمله على النحو الأمثل، ويوفر تنبيهات وتقارير فورية حول توافر الخدمات المراقبة، وفترات التوقف، وأزمنة الاستجابة. + +يمتلك Checkmate أيضًا وكيلًا يُسمى [Capture](https://github.com/bluewave-labs/capture) لجلب البيانات من الخوادم البعيدة. ليس Capture مطلوبًا لتشغيل Checkmate، لكنه يوفر معلومات إضافية حول حالة المعالج، والذاكرة، والقرص، ودرجة الحرارة لخوادمك. يعمل Capture على Linux وWindows وMac وRaspberry Pi، أو أي جهاز قادر على تشغيل Go. + +تم اختبار Checkmate تحت الضغط مع أكثر من 1000 مراقبة نشطة دون أي مشاكل أو اختناقات أداء ملحوظة. + +## 📚 المحتويات + +- [📦 العرض التجريبي](#demo) +- [🔗 دليل المستخدم](#users-guide) +- [🛠️ التثبيت](#installation) +- [🚀 الأداء](#performance) +- [💚 الأسئلة والأفكار](#questions--ideas) +- [🧩 المزايا](#features) +- [🏗️ لقطات الشاشة](#screenshots) +- [🏗️ التقنيات المستخدمة](#tech-stack) +- [🔗 بعض الروابط](#a-few-links) +- [🤝 المساهمة](#contributing) + + + +## العرض التجريبي + +يمكنك مشاهدة آخر إصدار من [Checkmate](https://demo.checkmate.so/) أثناء التشغيل. + +اسم المستخدم هو demouser@demo.com وكلمة المرور Demouser1! (ملاحظة: نقوم بتحديث خادم العرض التجريبي من حين لآخر، فإذا لم يعمل، يُرجى إخبارنا في قناة Discussions). + + +## دليل المستخدم + +يمكنك العثور على تعليمات الاستخدام [هنا](https://checkmate.so/docs). + +## المتطلبات المسبقة +- تثبيت [Docker](https://www.docker.com/) +- تثبيت [Git](https://git-scm.com/) + + +## التثبيت + +اطّلع على تعليمات التثبيت في [بوابة وثائق Checkmate](https://checkmate.so/docs). + +بدلاً من ذلك، يمكنك استخدام [Coolify](https://coolify.io/)، أو [Elestio](https://elest.io/open-source/checkmate)، أو [K8s](./charts/helm/checkmate/INSTALLATION.md)، أو [Sive Host](https://sive.host) (جنوب أفريقيا)، أو [Cloudzy](https://cloudzy.com/marketplace/checkmate)، أو [Pikapods](https://www.pikapods.com/) لتشغيل نسخة من Checkmate بسرعة. إن أردت مراقبة البنية التحتية لخوادمك، ستحتاج إلى [وكيل Capture](https://github.com/bluewave-labs/capture). يحتوي مستودع Capture أيضًا على تعليمات التثبيت. + +### استخدام شهادة CA مخصصة + +إذا كنت بحاجة لمراقبة نقاط نهاية HTTPS داخلية بشهادات صادرة عن سلطات شهادات خاصة (مثل Smallstep)، راجع [دليل الثقة بشهادات CA المخصصة](../custom-ca-trust.md) لخيارات إعداد Docker. + +للمزيد من الوثائق، راجع [مجلد docs](../). + + +## الأداء + +بفضل التحسينات الواسعة، يعمل Checkmate ببصمة ذاكرة صغيرة استثنائيًا، ولا يتطلب سوى الحد الأدنى من الذاكرة وموارد المعالج. فيما يلي استخدام الذاكرة لنسخة Node.js على خادم يراقب 323 خادمًا كل دقيقة: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +ويمكنك أيضًا رؤية بصمة الذاكرة لـ MongoDB وRedis على الخادم نفسه (398 ميجابايت و15 ميجابايت) لنفس عدد الخوادم: + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## الأسئلة والأفكار + +إذا كانت لديك أسئلة أو اقتراحات أو تعليقات، فلديك عدة خيارات: + +- [قناة Discord](https://discord.gg/NAb6H3UTjK) (مفضّلة) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (نمر عليها من حين لآخر) + +لا تتردد في طرح الأسئلة أو مشاركة أفكارك — يسعدنا التواصل معك! + + +## المزايا + +- مفتوح المصدر بالكامل، يمكن نشره على خوادمك أو على أجهزتك المنزلية (مثل Raspberry Pi 4 أو 5) +- خيارات مراقبة متعددة: الجاهزية، Docker، Ping، SSL، المنفذ، خادم الألعاب +- مراقبة سرعة الصفحة +- مراقبة البنية التحتية (الذاكرة، استخدام القرص، أداء المعالج، الشبكة، إلخ) — يتطلب وكيل [Capture](https://github.com/bluewave-labs/capture) + - مراقبة انتقائية للأقراص مع اختيار نقاط التحميل +- متابعة الحوادث بنظرة واحدة +- صفحات حالة بأربعة قوالب أنيقة +- إشعارات عبر البريد الإلكتروني، Webhooks، Discord، Slack، PagerDuty، Matrix، Microsoft Teams، Telegram، Pushover، Twilio (SMS) +- صيانة مجدولة +- مراقبة باستخدام استعلام JSON +- دعم متعدد اللغات: العربية، الصينية (المبسطة)، الصينية (التقليدية، تايوان)، التشيكية، الإنجليزية، الفنلندية، الفرنسية، الألمانية، اليابانية، البرتغالية (البرازيلية)، الروسية، الإسبانية، التايلاندية، التركية، الأوكرانية، والفيتنامية + + +## دورة حياة المراقبة + +1. تقوم المراقبة بإجراء فحص (HTTP / Ping / منفذ / فحص عتاد عبر وكيل Capture) +2. يتم تخزين النتيجة (نجاح/فشل + زمن الاستجابة) +3. تُقيَّم النتائج الأخيرة وفقًا لعتبة تغيّر الحالة المضبوطة للمراقبة +4. إذا تحققت العتبة وكانت الحالة الحالية تختلف عن السابقة، تتغير حالة المراقبة (مثل `initializing`, `up`, `down`, `breached`) +5. عند تغير الحالة، يتم إنشاء حادث أو حله بحسب الحالة الحالية للمراقبة +6. تُطلَق الإشعارات استنادًا إلى الإعدادات + + +## لقطات الشاشة + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## التقنيات المستخدمة + +- [ReactJs](https://react.dev/) +- [MUI (إطار عمل React)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- والكثير من المكونات مفتوحة المصدر الأخرى! + + +## بعض الروابط + +- إذا أردت دعمنا، فضع ⭐ على المستودع وانقر على "watch". +- لديك سؤال أو اقتراح يخص خارطة الطريق/المزايا؟ تفضل بزيارة [قناة Discord](https://discord.gg/NAb6H3UTjK) أو منتدى [Discussions](https://github.com/bluewave-labs/checkmate/discussions). +- هل تحتاج إشعارًا عند صدور إصدار جديد؟ استخدم [Newreleases](https://newreleases.io/) — وهي خدمة مجانية لتتبع الإصدارات. +- شاهد [فيديو تثبيت Checkmate واستخدامه](https://www.youtube.com/watch?v=GfFOc0xHIwY) + + +## المساهمة + +نحن [Alex](http://github.com/ajhollid) (قائد الفريق)، و[Gorkem](http://github.com/gorkem-bwl/)، و[Aryaman](https://github.com/Br0wnHammer)، و[Mert](https://github.com/mertssmnoglu)، و[Karen](https://github.com/karenvicent) نساعد الأفراد والشركات على مراقبة بنيتهم التحتية وخوادمهم. + +نفخر ببناء علاقات قوية مع المساهمين على جميع المستويات. وعلى الرغم من حداثة المشروع، فقد حصل Checkmate بالفعل على أكثر من 7000 نجمة، وجذب أكثر من 90 مساهمًا من جميع أنحاء العالم. + +تم تنجيم مستودعنا من قِبل موظفين في **Google، Microsoft، Intel، Cisco، Tencent، Electronic Arts، ByteDance، JP Morgan Chase، Deloitte، Accenture، Foxconn، Broadcom، China Telecom، Barclays، Capgemini، Wipro، Cloudflare، Dassault Systèmes، وNEC** — فلا تتردد، انضم إلينا، وساهم، وتعلّم معنا! + +كيف يمكنك المساهمة: + +0. ضع نجمة على هذا المستودع :) +1. راجع [دليل المساهمين](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). يُشجَّع المبتدئون على تفقد وسم `good-first-issue`. +2. اقرأ بنية [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) المفصّلة إذا أردت التعمق في المعمارية. +3. افتح issue إذا كنت تعتقد أنك واجهت مشكلة. +4. إذا كنت قادمًا جديدًا، فابحث عن وسوم `good-first-issue`. +5. أنشئ pull request لإضافة ميزات جديدة، أو تحسينات في تجربة المستخدم، أو إصلاح الأخطاء. +6. تصفّح الجولة التفاعلية في قاعدة كود `Checkmate` على CodeCanvas [من هنا](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). لتحسين محاكاة تدفق البيانات الحالية أو إنشاء جديدة، اتبع البرنامج التعليمي السريع [هنا](https://docs.code-canvas.com/updating-diagram). + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) + +
diff --git a/docs/translations/README.ca.md b/docs/translations/README.ca.md new file mode 100644 index 000000000..94e46d49d --- /dev/null +++ b/docs/translations/README.ca.md @@ -0,0 +1,200 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +> ⚠️ Aquesta traducció és un esborrany generat de manera assistida. Es valoren les revisions de parlants natius a través d'un pull request. + + +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

Una aplicació de codi obert per a la monitorització de disponibilitat i infraestructura

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +Aquest repositori conté tant el frontend com el backend de Checkmate, una eina de monitorització de codi obert i auto-allotjable per fer el seguiment del maquinari del servidor, la disponibilitat, els temps de resposta i els incidents en temps real amb visualitzacions atractives. Checkmate comprova regularment si un servidor o lloc web és accessible i funciona correctament, i ofereix alertes i informes en temps real sobre la disponibilitat, les caigudes i els temps de resposta dels serveis monitoritzats. + +Checkmate també disposa d'un agent anomenat [Capture](https://github.com/bluewave-labs/capture) per recuperar dades de servidors remots. Tot i que Capture no és necessari per executar Checkmate, proporciona informació addicional sobre l'estat de la CPU, la RAM, el disc i la temperatura dels servidors. Capture pot executar-se a Linux, Windows, Mac, Raspberry Pi o qualsevol dispositiu capaç d'executar Go. + +Checkmate s'ha provat sota càrrega amb més de 1.000 monitors actius sense cap problema o coll d'ampolla notable. + +## 📚 Taula de continguts + +- [📦 Demo](#demo) +- [🔗 Guia d'usuari](#users-guide) +- [🛠️ Instal·lació](#installation) +- [🚀 Rendiment](#performance) +- [💚 Preguntes i idees](#questions--ideas) +- [🧩 Funcionalitats](#features) +- [🏗️ Captures de pantalla](#screenshots) +- [🏗️ Stack tecnològic](#tech-stack) +- [🔗 Alguns enllaços](#a-few-links) +- [🤝 Contribuir](#contributing) + + + +## Demo + +Pots veure la darrera versió de [Checkmate](https://demo.checkmate.so/) en acció. + +L'usuari és demouser@demo.com i la contrasenya és Demouser1! (Nota: actualitzem el servidor de demostració de tant en tant; si no funciona, escriu-nos al canal Discussions). + + +## Guia d'usuari + +Pots trobar les instruccions d'ús [aquí](https://checkmate.so/docs). + +## Requisits previs +- [Docker](https://www.docker.com/) instal·lat +- [Git](https://git-scm.com/) instal·lat + + +## Instal·lació + +Consulta les instruccions d'instal·lació al [portal de documentació de Checkmate](https://checkmate.so/docs). + +Alternativament, també pots utilitzar [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (Sud-àfrica), [Cloudzy](https://cloudzy.com/marketplace/checkmate) o [Pikapods](https://www.pikapods.com/) per posar en marxa ràpidament una instància de Checkmate. Si vols monitoritzar la infraestructura de servidors, necessitaràs l'[agent Capture](https://github.com/bluewave-labs/capture). El repositori de Capture també conté les instruccions d'instal·lació. + +### Utilitzar una CA personalitzada + +Si necessites monitoritzar punts HTTPS interns amb certificats d'autoritats certificadores privades (com Smallstep), consulta la nostra [Guia de confiança per a CA personalitzades](../custom-ca-trust.md) per a les opcions de configuració de Docker. + +Per a més documentació, consulta el [directori docs](../). + + +## Rendiment + +Gràcies a una optimització exhaustiva, Checkmate funciona amb un consum de memòria excepcionalment baix, requerint un mínim de RAM i CPU. Aquí tens l'ús de memòria d'una instància de Node.js en un servidor que monitoritza 323 servidors cada minut: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +Aquí pots veure la petjada de memòria de MongoDB i Redis al mateix servidor (398 MB i 15 MB) per al mateix nombre de servidors: + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## Preguntes i idees + +Si tens preguntes, suggeriments o comentaris, tens diverses opcions: + +- [Canal de Discord](https://discord.gg/NAb6H3UTjK) (preferit) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (hi entrem de tant en tant) + +No dubtis a preguntar o compartir les teves idees — ens encantarà tenir notícies teves! + + +## Funcionalitats + +- Totalment de codi obert, desplegable als teus servidors o dispositius domèstics (per ex. Raspberry Pi 4 o 5) +- Diverses opcions de monitorització: disponibilitat, Docker, ping, SSL, port, servidor de jocs +- Monitorització de velocitat de pàgina +- Monitorització d'infraestructura (memòria, ús de disc, rendiment de CPU, xarxa, etc.) — requereix l'agent [Capture](https://github.com/bluewave-labs/capture) + - Monitorització selectiva de disc amb selecció de punts de muntatge +- Incidents amb un cop d'ull +- Pàgines d'estat amb 4 temes elegants +- Notificacions per correu electrònic, webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) +- Manteniment programat +- Monitorització mitjançant consultes JSON +- Suport multilingüe per a àrab, xinès (simplificat), xinès (tradicional, Taiwan), txec, anglès, finès, francès, alemany, japonès, portuguès (Brasil), rus, espanyol, tailandès, turc, ucraïnès i vietnamita + + +## Cicle de vida d'un monitor + +1. Un monitor executa una comprovació (HTTP / ping / port / maquinari mitjançant l'agent Capture) +2. El resultat es desa (èxit/fracàs + temps de resposta) +3. Els resultats recents s'avaluen contra el llindar de canvi d'estat configurat al monitor +4. Si s'assoleix el llindar i l'estat actual difereix de l'anterior, l'estat del monitor canvia (per ex. `initializing`, `up`, `down`, `breached`) +5. En un canvi d'estat, es crea o resol un incident, segons l'estat actual del monitor +6. Es disparen les notificacions segons la configuració + + +## Captures de pantalla + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## Stack tecnològic + +- [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) +- I molts altres components de codi obert! + + +## Alguns enllaços + +- Si vols donar-nos suport, considera donar-nos una ⭐ i prémer "watch". +- Tens alguna pregunta o suggeriment per al roadmap o les funcionalitats? Visita el nostre [canal de Discord](https://discord.gg/NAb6H3UTjK) o el fòrum de [Discussions](https://github.com/bluewave-labs/checkmate/discussions). +- Vols rebre una notificació quan hi hagi una nova versió? Utilitza [Newreleases](https://newreleases.io/), un servei gratuït per seguir versions. +- Mira un [vídeo d'instal·lació i ús de Checkmate](https://www.youtube.com/watch?v=GfFOc0xHIwY) + + +## Contribuir + +Som l'[Alex](http://github.com/ajhollid) (líder d'equip), en [Gorkem](http://github.com/gorkem-bwl/), l'[Aryaman](https://github.com/Br0wnHammer), en [Mert](https://github.com/mertssmnoglu) i la [Karen](https://github.com/karenvicent), i ajudem persones i empreses a monitoritzar la seva infraestructura i els seus servidors. + +Ens enorgullim de construir vincles forts amb els col·laboradors a tots els nivells. Tot i ser un projecte jove, Checkmate ja ha aconseguit més de 7.000 estrelles i ha atret més de 90 col·laboradors d'arreu del món. + +El nostre repositori té estrelles d'empleats 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 i NEC** — no t'estiguis de res: uneix-te, contribueix i aprèn amb nosaltres! + +Així pots contribuir: + +0. Posa una estrella a aquest repositori :) +1. Consulta la [guia per a col·laboradors](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). Recomanem als principiants revisar l'etiqueta `good-first-issue`. +2. Llegeix una estructura detallada de [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) si vols aprofundir en l'arquitectura. +3. Obre una issue si creus que has trobat un error. +4. Mira els `good-first-issue` si ets nou. +5. Fes una pull request per afegir noves funcionalitats, millores de qualitat o solucions a errors. +6. Fes una ullada al recorregut interactiu del codi de `Checkmate` a CodeCanvas [aquí](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). Per refinar simulacions de flux de dades existents o crear-ne de noves, segueix el tutorial ràpid [aquí](https://docs.code-canvas.com/updating-diagram). + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.cs.md b/docs/translations/README.cs.md new file mode 100644 index 000000000..3faa00334 --- /dev/null +++ b/docs/translations/README.cs.md @@ -0,0 +1,200 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +> ⚠️ Tento překlad je strojový koncept. Revize od rodilých mluvčích jsou vítány formou pull requestu. + + +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

Open source aplikace pro monitorování dostupnosti a infrastruktury

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +Tento repozitář obsahuje jak frontend, tak backend Checkmate, open source, samohostovatelného monitorovacího nástroje pro sledování hardwaru serveru, dostupnosti, doby odezvy a incidentů v reálném čase s krásnými vizualizacemi. Checkmate pravidelně kontroluje, zda je server/web dostupný a zda funguje optimálně, a poskytuje upozornění a zprávy o dostupnosti, výpadcích a době odezvy monitorovaných služeb v reálném čase. + +Checkmate má také agenta zvaného [Capture](https://github.com/bluewave-labs/capture), který získává data ze vzdálených serverů. Capture není pro běh Checkmate povinný, ale poskytuje další informace o stavu CPU, RAM, disku a teploty serverů. Capture lze provozovat na Linuxu, Windows, Macu, Raspberry Pi nebo jakémkoli zařízení, které dokáže spouštět Go. + +Checkmate byl zátěžově otestován s více než 1000 aktivními monitory bez jakýchkoli problémů nebo výkonnostních úzkých míst. + +## 📚 Obsah + +- [📦 Demo](#demo) +- [🔗 Uživatelská příručka](#users-guide) +- [🛠️ Instalace](#installation) +- [🚀 Výkon](#performance) +- [💚 Dotazy & nápady](#questions--ideas) +- [🧩 Funkce](#features) +- [🏗️ Snímky obrazovky](#screenshots) +- [🏗️ Technologie](#tech-stack) +- [🔗 Několik odkazů](#a-few-links) +- [🤝 Přispívání](#contributing) + + + +## Demo + +Nejnovější sestavení [Checkmate](https://demo.checkmate.so/) si můžete vyzkoušet v akci. + +Uživatelské jméno je demouser@demo.com a heslo Demouser1! (Poznámka: demo server čas od času aktualizujeme; pokud něco nefunguje, dejte nám prosím vědět na kanále Discussions). + + +## Uživatelská příručka + +Pokyny k použití najdete [zde](https://checkmate.so/docs). + +## Předpoklady +- Nainstalovaný [Docker](https://www.docker.com/) +- Nainstalovaný [Git](https://git-scm.com/) + + +## Instalace + +Pokyny k instalaci najdete v [dokumentačním portálu Checkmate](https://checkmate.so/docs). + +Případně můžete použít [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (Jižní Afrika), [Cloudzy](https://cloudzy.com/marketplace/checkmate) nebo [Pikapods](https://www.pikapods.com/) pro rychlé spuštění instance Checkmate. Pokud chcete monitorovat infrastrukturu serverů, budete potřebovat [agenta Capture](https://github.com/bluewave-labs/capture). Repozitář Capture obsahuje také pokyny k instalaci. + +### Použití vlastní CA + +Pokud potřebujete monitorovat interní HTTPS endpointy s certifikáty od soukromých certifikačních autorit (jako je Smallstep), přečtěte si naši [příručku pro důvěřování vlastním CA](../custom-ca-trust.md) pro možnosti konfigurace Dockeru. + +Další dokumentaci najdete v [adresáři docs](../). + + +## Výkon + +Díky rozsáhlým optimalizacím funguje Checkmate s mimořádně malým paměťovým otiskem a vyžaduje jen minimum paměti a CPU. Zde je využití paměti instance Node.js na serveru, který každou minutu monitoruje 323 serverů: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +A zde paměťový otisk MongoDB a Redis na stejném serveru (398 MB, resp. 15 MB) pro stejný počet serverů: + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## Dotazy & nápady + +Pokud máte dotazy, návrhy nebo komentáře, máte několik možností: + +- [Discord kanál](https://discord.gg/NAb6H3UTjK) (preferováno) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (kontrolujeme čas od času) + +Neváhejte se ptát nebo sdílet své nápady — moc rádi vás slyšíme! + + +## Funkce + +- Plně open source, nasaditelné na vlastních serverech nebo domácích zařízeních (např. Raspberry Pi 4 nebo 5) +- Několik způsobů monitorování: dostupnost, Docker, ping, SSL, port, herní server +- Monitorování rychlosti stránek +- Monitorování infrastruktury (paměť, využití disku, výkon CPU, síť atd.) — vyžaduje agenta [Capture](https://github.com/bluewave-labs/capture) + - Selektivní monitorování disků s výběrem přípojných bodů +- Incidenty na první pohled +- Stavové stránky se 4 elegantními tématy +- Notifikace přes e-mail, webhooky, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) +- Plánovaná údržba +- Monitorování pomocí JSON dotazů +- Vícejazyčná podpora pro arabštinu, čínštinu (zjednodušenou), čínštinu (tradiční, Tchaj-wan), češtinu, angličtinu, finštinu, francouzštinu, němčinu, japonštinu, portugalštinu (Brazílie), ruštinu, španělštinu, thajštinu, turečtinu, ukrajinštinu a vietnamštinu + + +## Životní cyklus monitoru + +1. Monitor provede kontrolu (HTTP / ping / port / hardware přes agenta Capture) +2. Výsledek se uloží (úspěch/neúspěch + doba odezvy) +3. Nedávné výsledky se vyhodnotí proti nastavenému prahu změny stavu monitoru +4. Pokud je prah dosažen a aktuální stav se liší od předchozího, změní se stav monitoru (např. `initializing`, `up`, `down`, `breached`) +5. Při změně stavu se podle aktuálního stavu vytvoří nebo vyřeší incident +6. Notifikace se spouštějí podle konfigurace + + +## Snímky obrazovky + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## Technologie + +- [ReactJs](https://react.dev/) +- [MUI (React framework)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- A mnoho dalších open source komponent! + + +## Několik odkazů + +- Pokud nás chcete podpořit, zvažte přidělení ⭐ a kliknutí na „watch“. +- Máte dotaz nebo návrh na roadmapu/funkce? Podívejte se na náš [Discord kanál](https://discord.gg/NAb6H3UTjK) nebo fórum [Discussions](https://github.com/bluewave-labs/checkmate/discussions). +- Chcete být upozorněni na nové vydání? Použijte [Newreleases](https://newreleases.io/), bezplatnou službu pro sledování vydání. +- Podívejte se na [instalační a uživatelské video Checkmate](https://www.youtube.com/watch?v=GfFOc0xHIwY) + + +## Přispívání + +Jsme [Alex](http://github.com/ajhollid) (vedoucí týmu), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) a [Karen](https://github.com/karenvicent) a pomáháme jednotlivcům a firmám monitorovat jejich infrastrukturu a servery. + +Jsme hrdí na to, že budujeme silné vztahy s přispěvateli na všech úrovních. Přestože jsme mladý projekt, Checkmate už získal přes 7000 hvězdiček a přilákal více než 90 přispěvatelů z celého světa. + +Náš repozitář mají označený zaměstnanci společností **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes a NEC** — takže neváhejte, zapojte se, přispějte a učte se s námi! + +Jak přispět: + +0. Dejte tomuto repozitáři hvězdičku :) +1. Přečtěte si [pokyny pro přispěvatele](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). Začátečníkům doporučujeme prohlédnout si štítek `good-first-issue`. +2. Pokud chcete proniknout do architektury, přečtěte si podrobnou strukturu [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate). +3. Otevřete issue, pokud se domníváte, že jste narazili na chybu. +4. Pokud jste nováček, podívejte se po `good-first-issue`. +5. Vytvořte pull request pro přidání funkcí, drobná vylepšení nebo opravy chyb. +6. Vyzkoušejte interaktivní průchod kódovou základnou `Checkmate` na CodeCanvas [zde](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). Pro vylepšení existujících simulací datových toků nebo vytvoření nových postupujte podle krátkého tutoriálu [zde](https://docs.code-canvas.com/updating-diagram). + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.de.md b/docs/translations/README.de.md new file mode 100644 index 000000000..59f897368 --- /dev/null +++ b/docs/translations/README.de.md @@ -0,0 +1,197 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

Eine Open-Source-Anwendung zur Überwachung von Verfügbarkeit und Infrastruktur

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +Dieses Repository enthält Frontend und Backend von Checkmate — einem Open-Source-, selbst gehosteten Monitoring-Tool, das Server-Hardware, Verfügbarkeit, Antwortzeiten und Vorfälle in Echtzeit mit klaren Visualisierungen verfolgt. Checkmate prüft regelmäßig, ob ein Server oder eine Website erreichbar ist und sauber läuft, und liefert Echtzeit-Alerts und Reports zu Verfügbarkeit, Ausfallzeiten und Antwortzeiten der überwachten Dienste. + +Checkmate hat außerdem einen Agenten namens [Capture](https://github.com/bluewave-labs/capture), der Daten von entfernten Servern abholt. Capture ist nicht zwingend erforderlich, liefert aber zusätzliche Informationen zu CPU, RAM, Festplatte und Temperatur deiner Server. Capture läuft auf Linux, Windows, macOS, Raspberry Pi oder jedem anderen Gerät, das Go ausführen kann. + +Checkmate wurde mit über 1.000 aktiven Monitoren stresstestet — ohne erkennbare Probleme oder Performance-Engpässe. + +## 📚 Inhaltsverzeichnis + +- [📦 Demo](#demo) +- [🔗 Benutzerhandbuch](#users-guide) +- [🛠️ Installation](#installation) +- [🚀 Leistung](#performance) +- [💚 Fragen & Ideen](#questions--ideas) +- [🧩 Funktionen](#features) +- [🏗️ Screenshots](#screenshots) +- [🏗️ Tech-Stack](#tech-stack) +- [🔗 Einige Links](#a-few-links) +- [🤝 Mitwirken](#contributing) + + + +## Demo + +Den aktuellen Build von [Checkmate](https://demo.checkmate.so/) kannst du live ausprobieren. + +Der Benutzername ist demouser@demo.com, das Passwort Demouser1!. (Wir aktualisieren den Demo-Server ab und zu — sollte er gerade nicht erreichbar sein, sag uns Bescheid im Discussions-Kanal.) + + +## Benutzerhandbuch + +Die Nutzungsanleitung findest du [hier](https://checkmate.so/docs). + +## Voraussetzungen +- [Docker](https://www.docker.com/) installiert +- [Git](https://git-scm.com/) installiert + + +## Installation + +Die Installationsanleitung findest du im [Dokumentationsportal von Checkmate](https://checkmate.so/docs). + +Alternativ kannst du [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (Südafrika), [Cloudzy](https://cloudzy.com/marketplace/checkmate) oder [Pikapods](https://www.pikapods.com/) nutzen, um schnell eine Checkmate-Instanz hochzuziehen. Für das Monitoring deiner Server-Infrastruktur brauchst du den [Capture-Agenten](https://github.com/bluewave-labs/capture); dessen Repository enthält ebenfalls eine Installationsanleitung. + +### Eigene CA verwenden + +Wenn du interne HTTPS-Endpunkte mit Zertifikaten privater Zertifizierungsstellen (z. B. Smallstep) überwachen möchtest, schau in unseren [Leitfaden für eigene CAs](../custom-ca-trust.md) — dort stehen die passenden Docker-Konfigurationen. + +Weitere Dokumentation liegt im [docs-Verzeichnis](../). + + +## Leistung + +Dank umfangreicher Optimierungen läuft Checkmate mit einem außergewöhnlich geringen Speicherverbrauch und braucht nur sehr wenig RAM und CPU. Hier der Speicherverbrauch einer Node.js-Instanz auf einem Server, der jede Minute 323 Server prüft: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +Hier der Speicherverbrauch von MongoDB und Redis auf demselben Server (398 MB bzw. 15 MB) für dieselbe Anzahl von Servern: + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## Fragen & Ideen + +Falls du Fragen, Vorschläge oder Anmerkungen hast, gibt es mehrere Anlaufstellen: + +- [Discord](https://discord.gg/NAb6H3UTjK) (bevorzugt) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (wir schauen regelmäßig vorbei) + +Frag einfach drauflos oder teile deine Ideen — wir freuen uns auf dein Feedback! + + +## Funktionen + +- Vollständig Open Source, auf eigenen Servern oder Heimgeräten (z. B. Raspberry Pi 4 oder 5) bereitstellbar +- Mehrere Monitoring-Optionen: Uptime, Docker, Ping, SSL, Port, Game-Server +- Page-Speed-Monitoring +- Infrastruktur-Monitoring (RAM, Festplattennutzung, CPU-Leistung, Netzwerk etc.) — benötigt den [Capture](https://github.com/bluewave-labs/capture)-Agenten + - Selektives Festplatten-Monitoring mit Mountpoint-Auswahl +- Vorfälle auf einen Blick +- Statusseiten mit 4 schönen Themes +- Benachrichtigungen per E-Mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) +- Geplante Wartungen +- JSON-Abfrage-Monitoring +- Mehrsprachige Unterstützung für Arabisch, Chinesisch (Vereinfacht), Chinesisch (Traditionell, Taiwan), Tschechisch, Englisch, Finnisch, Französisch, Deutsch, Japanisch, Portugiesisch (Brasilien), Russisch, Spanisch, Thailändisch, Türkisch, Ukrainisch und Vietnamesisch + + +## Monitor-Lebenszyklus + +1. Ein Monitor führt eine Prüfung durch (HTTP / Ping / Port / Hardware über den Capture-Agenten) +2. Das Ergebnis wird gespeichert (Erfolg/Fehler + Antwortzeit) +3. Die jüngsten Prüfergebnisse werden gegen den konfigurierten Schwellenwert für Statusänderungen des Monitors ausgewertet +4. Wenn der Schwellenwert erreicht ist und der aktuelle Status vom vorherigen abweicht, ändert sich der Zustand des Monitors (z. B. `initializing`, `up`, `down`, `breached`) +5. Bei einem Zustandswechsel wird je nach aktuellem Status entweder ein Vorfall erstellt oder geschlossen +6. Benachrichtigungen werden je nach Konfiguration ausgelöst + + +## Screenshots + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## Tech-Stack + +- [ReactJs](https://react.dev/) +- [MUI (React-Framework)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- Viele weitere Open-Source-Komponenten! + + +## Einige Links + +- Wenn dir das Projekt gefällt, gib ihm gerne einen ⭐ und klick auf „watch“. +- Frage oder Vorschlag zur Roadmap oder zum Funktionsumfang? Komm in unseren [Discord-Kanal](https://discord.gg/NAb6H3UTjK) oder ins [Discussions](https://github.com/bluewave-labs/checkmate/discussions)-Forum. +- Du willst benachrichtigt werden, wenn ein neues Release rauskommt? [Newreleases](https://newreleases.io/) ist ein kostenloser Release-Tracker. +- Schau dir gerne ein [Installations- und Anwendungs-Video](https://www.youtube.com/watch?v=GfFOc0xHIwY) zu Checkmate an. + + +## Mitwirken + +Wir sind [Alex](http://github.com/ajhollid) (Team-Lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) und [Karen](https://github.com/karenvicent) und helfen Einzelpersonen und Unternehmen dabei, ihre Infrastruktur und Server zu überwachen. + +Wir sind stolz darauf, auf jeder Ebene starke Beziehungen zu unseren Beitragenden zu pflegen. Obwohl Checkmate ein junges Projekt ist, hat es bereits über 7.000 Sterne und mehr als 90 Beitragende aus aller Welt angezogen. + +Mitarbeiterinnen und Mitarbeiter von **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes und NEC** haben unser Repository bereits gesternt — also keine falsche Zurückhaltung: mitmachen, beitragen und mit uns lernen! + +So kannst du beitragen: + +0. Gib dem Repo einen Stern :) +1. Lies den [Beitragsleitfaden](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). Neulinge schauen am besten zuerst nach dem Tag `good-first-issue`. +2. Wenn du tiefer in die Architektur einsteigen willst, lies die ausführliche Struktur von [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate). +3. Öffne ein Issue, wenn du einen Bug findest. +4. Wenn du neu hier bist, schau dir die `good-first-issue`-Tasks an. +5. Mach einen Pull Request, um neue Funktionen einzubringen, die Nutzung zu verbessern oder Bugs zu fixen. +6. Schau dir den interaktiven Walkthrough durch die `Checkmate`-Codebasis auf CodeCanvas an: [hier](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). Um bestehende Datenfluss-Simulationen zu verfeinern oder neue anzulegen, folge dem kurzen Tutorial [hier](https://docs.code-canvas.com/updating-diagram). + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.es.md b/docs/translations/README.es.md new file mode 100644 index 000000000..e72d67cc5 --- /dev/null +++ b/docs/translations/README.es.md @@ -0,0 +1,197 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

Una aplicación de código abierto para monitorización de disponibilidad e infraestructura

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +Este repositorio contiene tanto el frontend como el backend de Checkmate, una herramienta de monitorización de código abierto y auto-alojable que rastrea hardware de servidores, disponibilidad, tiempos de respuesta e incidentes en tiempo real con bonitas visualizaciones. Checkmate comprueba periódicamente si un servidor o sitio web está accesible y funciona de forma óptima, y proporciona alertas e informes en tiempo real sobre la disponibilidad, el tiempo de inactividad y los tiempos de respuesta de los servicios monitorizados. + +Checkmate también dispone de un agente llamado [Capture](https://github.com/bluewave-labs/capture) para obtener datos de servidores remotos. Capture no es necesario para ejecutar Checkmate, pero aporta información adicional sobre el estado de CPU, RAM, disco y temperatura de los servidores. Capture puede ejecutarse en Linux, Windows, Mac, Raspberry Pi o cualquier dispositivo que pueda ejecutar Go. + +Checkmate ha sido sometido a pruebas de carga con más de 1000 monitores activos sin presentar problemas ni cuellos de botella de rendimiento. + +## 📚 Tabla de contenidos + +- [📦 Demo](#demo) +- [🔗 Guía de usuario](#users-guide) +- [🛠️ Instalación](#installation) +- [🚀 Rendimiento](#performance) +- [💚 Preguntas e ideas](#questions--ideas) +- [🧩 Características](#features) +- [🏗️ Capturas de pantalla](#screenshots) +- [🏗️ Stack tecnológico](#tech-stack) +- [🔗 Algunos enlaces](#a-few-links) +- [🤝 Contribuir](#contributing) + + + +## Demo + +Puedes ver la última versión de [Checkmate](https://demo.checkmate.so/) en acción. + +El usuario es demouser@demo.com y la contraseña es Demouser1! (ten en cuenta que actualizamos el servidor de demostración de vez en cuando; si no funciona, escríbenos en el canal Discussions). + + +## Guía de usuario + +Las instrucciones de uso están disponibles [aquí](https://checkmate.so/docs). + +## Prerrequisitos +- [Docker](https://www.docker.com/) instalado +- [Git](https://git-scm.com/) instalado + + +## Instalación + +Consulta las instrucciones de instalación en el [portal de documentación de Checkmate](https://checkmate.so/docs). + +También puedes utilizar [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (Sudáfrica), [Cloudzy](https://cloudzy.com/marketplace/checkmate) o [Pikapods](https://www.pikapods.com/) para levantar rápidamente una instancia de Checkmate. Si quieres monitorizar la infraestructura de tus servidores, necesitarás el [agente Capture](https://github.com/bluewave-labs/capture). El repositorio de Capture también incluye las instrucciones de instalación. + +### Uso de una CA personalizada + +Si necesitas monitorizar endpoints HTTPS internos con certificados emitidos por autoridades certificadoras privadas (como Smallstep), consulta nuestra [guía de confianza para CAs personalizadas](../custom-ca-trust.md) con las opciones de configuración para Docker. + +Para más documentación, consulta el [directorio docs](../). + + +## Rendimiento + +Gracias a optimizaciones extensas, Checkmate funciona con una huella de memoria excepcionalmente baja y requiere un mínimo de memoria y CPU. A continuación se muestra el uso de memoria de una instancia de Node.js en un servidor que monitoriza 323 servidores cada minuto: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +Y aquí la huella de memoria de MongoDB y Redis en el mismo servidor (398 MB y 15 MB) para la misma cantidad de servidores: + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## Preguntas e ideas + +Si tienes preguntas, sugerencias o comentarios, dispones de varias opciones: + +- [Canal de Discord](https://discord.gg/NAb6H3UTjK) (preferido) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (entramos a verlo de vez en cuando) + +No dudes en preguntar o compartir tus ideas — ¡nos encantará saber de ti! + + +## Características + +- Totalmente de código abierto, desplegable en tus propios servidores o dispositivos domésticos (por ejemplo, Raspberry Pi 4 o 5) +- Varias opciones de monitorización: disponibilidad, Docker, Ping, SSL, puerto, servidor de juegos +- Monitorización de velocidad de página +- Monitorización de infraestructura (memoria, uso de disco, rendimiento de CPU, red, etc.) — requiere el agente [Capture](https://github.com/bluewave-labs/capture) + - Monitorización selectiva de discos con selección de puntos de montaje +- Incidentes de un vistazo +- Páginas de estado con 4 temas elegantes +- Notificaciones por correo electrónico, webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) +- Mantenimiento programado +- Monitorización mediante consultas JSON +- Soporte multiidioma para árabe, chino (simplificado), chino (tradicional, Taiwán), checo, inglés, finés, francés, alemán, japonés, portugués (Brasil), ruso, español, tailandés, turco, ucraniano y vietnamita + + +## Ciclo de vida de un monitor + +1. Un monitor ejecuta una comprobación (HTTP / Ping / puerto / hardware vía el agente Capture) +2. El resultado se almacena (éxito/fallo + tiempo de respuesta) +3. Los resultados recientes se evalúan frente al umbral de cambio de estado configurado para el monitor +4. Si se alcanza el umbral y el estado actual difiere del anterior, el estado del monitor cambia (por ejemplo, `initializing`, `up`, `down`, `breached`) +5. Al cambiar de estado, se crea o resuelve un incidente según el estado actual del monitor +6. Las notificaciones se disparan según la configuración + + +## Capturas de pantalla + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## Stack tecnológico + +- [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! + + +## Algunos enlaces + +- Si quieres apoyarnos, dale a ⭐ y pulsa "watch". +- ¿Tienes una pregunta o sugerencia sobre la hoja de ruta o las funcionalidades? Pásate por nuestro [canal de Discord](https://discord.gg/NAb6H3UTjK) o el foro de [Discussions](https://github.com/bluewave-labs/checkmate/discussions). +- ¿Quieres una notificación cuando salga una nueva versión? Usa [Newreleases](https://newreleases.io/), un servicio gratuito para seguir releases. +- Mira un [vídeo de instalación y uso de Checkmate](https://www.youtube.com/watch?v=GfFOc0xHIwY) + + +## Contribuir + +Somos [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) y [Karen](https://github.com/karenvicent), y ayudamos a personas y empresas a monitorizar su infraestructura y sus servidores. + +Nos enorgullecemos de construir relaciones sólidas con la comunidad de contribuidores de cualquier nivel. A pesar de ser un proyecto joven, Checkmate ya ha conseguido más de 7000 estrellas y reúne a 90+ contribuidores de todo el mundo. + +Nuestro repositorio cuenta con estrellas de 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 cortes: únete, contribuye y aprende con nosotros. + +Cómo puedes contribuir: + +0. Pon una estrella a este repo :) +1. Revisa la [guía de contribución](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). Si es tu primera vez, te recomendamos mirar la etiqueta `good-first-issue`. +2. Lee la estructura detallada de [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) si quieres profundizar en la arquitectura. +3. Abre un issue si crees que has encontrado un bug. +4. Busca `good-first-issue` si eres nuevo en el proyecto. +5. Abre un pull request para añadir nuevas funciones, mejoras o correcciones de bugs. +6. Echa un vistazo a este recorrido interactivo del código de `Checkmate` en CodeCanvas [aquí](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). Para mejorar las simulaciones de flujo de datos existentes o crear nuevas, sigue el tutorial rápido [aquí](https://docs.code-canvas.com/updating-diagram). + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.fi.md b/docs/translations/README.fi.md new file mode 100644 index 000000000..91b91058b --- /dev/null +++ b/docs/translations/README.fi.md @@ -0,0 +1,200 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +> ⚠️ Tämä käännös on konekäännöksen luonnos. Äidinkielisten puhujien tarkistuksia otetaan mielellään vastaan pull requestilla. + + +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

Avoimen lähdekoodin sovellus saatavuuden ja infrastruktuurin valvontaan

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +Tämä repositorio sisältää Checkmaten frontendin ja backendin — avoimen lähdekoodin, itse isännöitävän valvontatyökalun, jolla seurataan reaaliaikaisesti palvelinten laitteistoa, saatavuutta, vasteaikoja ja häiriöitä näyttävien visualisointien avulla. Checkmate tarkistaa säännöllisesti, ovatko palvelin tai sivusto tavoitettavissa ja toimivatko ne odotetusti, sekä antaa reaaliaikaisia hälytyksiä ja raportteja valvottujen palvelujen saatavuudesta, käyttökatkoista ja vasteajoista. + +Checkmatella on myös agentti nimeltä [Capture](https://github.com/bluewave-labs/capture), joka kerää tietoja etäpalvelimilta. Capture ei ole välttämätön Checkmaten ajamiseen, mutta se tarjoaa lisätietoja palvelimiesi CPU:n, RAM-muistin, levyn ja lämpötilan tilasta. Capture toimii Linuxissa, Windowsissa, Macissa, Raspberry Pissä ja kaikissa Go-yhteensopivissa laitteissa. + +Checkmate on rasitustestattu yli 1000 aktiivisella valvomolla ilman merkittäviä ongelmia tai suorituskykypullonkauloja. + +## 📚 Sisällysluettelo + +- [📦 Demo](#demo) +- [🔗 Käyttöopas](#users-guide) +- [🛠️ Asennus](#installation) +- [🚀 Suorituskyky](#performance) +- [💚 Kysymykset & ideat](#questions--ideas) +- [🧩 Ominaisuudet](#features) +- [🏗️ Kuvakaappaukset](#screenshots) +- [🏗️ Teknologiat](#tech-stack) +- [🔗 Muutamia linkkejä](#a-few-links) +- [🤝 Osallistuminen](#contributing) + + + +## Demo + +Voit kokeilla uusinta [Checkmate-versiota](https://demo.checkmate.so/) käytännössä. + +Käyttäjätunnus on demouser@demo.com ja salasana Demouser1! (Huom: päivitämme demopalvelinta ajoittain; jos se ei toimi, kerro meille Discussions-kanavalla). + + +## Käyttöopas + +Käyttöohjeet löytyvät [täältä](https://checkmate.so/docs). + +## Esivaatimukset +- [Docker](https://www.docker.com/) asennettuna +- [Git](https://git-scm.com/) asennettuna + + +## Asennus + +Asennusohjeet löytyvät [Checkmaten dokumentaatioportaalista](https://checkmate.so/docs). + +Vaihtoehtoisesti voit käyttää [Coolifya](https://coolify.io/), [Elestiota](https://elest.io/open-source/checkmate), [K8s:ää](./charts/helm/checkmate/INSTALLATION.md), [Sive Hostia](https://sive.host) (Etelä-Afrikka), [Cloudzya](https://cloudzy.com/marketplace/checkmate) tai [Pikapodsia](https://www.pikapods.com/) Checkmate-instanssin nopeaan käynnistämiseen. Jos haluat valvoa palvelininfrastruktuuriasi, tarvitset [Capture-agentin](https://github.com/bluewave-labs/capture). Capturen repositorio sisältää myös sen asennusohjeet. + +### Mukautetun CA:n käyttö + +Jos haluat valvoa sisäisiä HTTPS-päätepisteitä yksityisten varmenneauktoriteettien (kuten Smallstepin) varmenteilla, lue [mukautetun CA:n luottamusopas](../custom-ca-trust.md) Dockerin konfigurointivaihtoehdoista. + +Lisää dokumentaatiota löydät [docs-hakemistosta](../). + + +## Suorituskyky + +Laajojen optimointien ansiosta Checkmate toimii poikkeuksellisen pienellä muistinkulutuksella ja vaatii minimaalisesti muistia ja CPU:ta. Tässä Node.js-instanssin muistinkulutus palvelimella, joka valvoo 323 palvelinta minuutin välein: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +Ja tässä MongoDB:n ja Redisin muistijälki samalla palvelimella (398 Mt ja 15 Mt) samalle palvelinmäärälle: + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## Kysymykset & ideat + +Jos sinulla on kysymyksiä, ehdotuksia tai kommentteja, vaihtoehtoja on useita: + +- [Discord-kanava](https://discord.gg/NAb6H3UTjK) (suositeltu) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (katsomme aika ajoin) + +Kysy rohkeasti tai jaa ideoitasi — kuulemme sinusta mielellämme! + + +## Ominaisuudet + +- Täysin avoimen lähdekoodin, asennettavissa omille palvelimille tai kotilaitteille (esim. Raspberry Pi 4 tai 5) +- Useita valvontavaihtoehtoja: saatavuus, Docker, ping, SSL, portti, pelipalvelin +- Sivunopeuden valvonta +- Infrastruktuurin valvonta (muisti, levyn käyttö, CPU:n suorituskyky, verkko jne.) — vaatii [Capture](https://github.com/bluewave-labs/capture)-agentin + - Valikoitu levyjen valvonta liitospisteen valinnalla +- Häiriöt yhdellä silmäyksellä +- Tilasivut neljällä tyylikkäällä teemalla +- Ilmoitukset sähköpostitse, webhookeilla, Discordilla, Slackilla, PagerDutylla, Matrixilla, Microsoft Teamsilla, Telegramilla, Pushoverilla ja Twiliolla (SMS) +- Ajastettu huolto +- JSON-kyselyihin perustuva valvonta +- Monikielinen tuki: arabia, kiina (yksinkertaistettu), kiina (perinteinen, Taiwan), tšekki, englanti, suomi, ranska, saksa, japani, portugali (Brasilia), venäjä, espanja, thai, turkki, ukraina ja vietnam + + +## Monitorin elinkaari + +1. Monitori suorittaa tarkistuksen (HTTP / ping / portti / laitteisto Capture-agentin kautta) +2. Tulos tallennetaan (onnistui/epäonnistui + vasteaika) +3. Viimeaikaisia tuloksia arvioidaan monitorin asetettua tilamuutoskynnystä vasten +4. Jos kynnys täyttyy ja nykyinen tila eroaa edellisestä, monitorin tila muuttuu (esim. `initializing`, `up`, `down`, `breached`) +5. Tilamuutoksen yhteydessä häiriö joko luodaan tai ratkaistaan monitorin nykyisen tilan mukaan +6. Ilmoitukset laukaistaan asetusten mukaisesti + + +## Kuvakaappaukset + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## Teknologiat + +- [ReactJs](https://react.dev/) +- [MUI (React-kehys)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- Ja monia muita avoimen lähdekoodin komponentteja! + + +## Muutamia linkkejä + +- Jos haluat tukea meitä, harkitse ⭐ antamista ja "watch"-napin painamista. +- Onko kysymyksiä tai ehdotuksia tiekartasta/ominaisuuksista? Tule [Discord-kanavalle](https://discord.gg/NAb6H3UTjK) tai [Discussions-foorumille](https://github.com/bluewave-labs/checkmate/discussions). +- Haluatko ilmoituksen uusista julkaisuista? Käytä [Newreleases](https://newreleases.io/) -palvelua, joka on ilmainen julkaisujen seuraamiseen. +- Katso Checkmaten [asennus- ja käyttövideo](https://www.youtube.com/watch?v=GfFOc0xHIwY) + + +## Osallistuminen + +Olemme [Alex](http://github.com/ajhollid) (tiiminvetäjä), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) ja [Karen](https://github.com/karenvicent) — autamme yksityishenkilöitä ja yrityksiä valvomaan infrastruktuuriaan ja palvelimiaan. + +Olemme ylpeitä siitä, että rakennamme vahvoja suhteita osallistujiin kaikilla tasoilla. Vaikka projekti on nuori, Checkmate on jo saanut yli 7000 tähteä ja yli 90 osallistujaa eri puolilta maailmaa. + +Repoamme tähdittävät työntekijät yhtiöistä **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes ja NEC** — älä siis epäröi, hyppää mukaan, osallistu ja opi kanssamme! + +Näin voit osallistua: + +0. Tähditä tämä repo :) +1. Tutustu [osallistujan oppaaseen](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). Aloittelijoille suositellaan `good-first-issue`-tagia. +2. Lue tarkka [Checkmate-arkkitehtuurikuvaus](https://deepwiki.com/bluewave-labs/Checkmate), jos haluat syventyä rakenteeseen. +3. Avaa issue, jos uskot löytäneesi virheen. +4. Etsi `good-first-issue`-merkintöjä, jos olet uusi. +5. Tee pull request lisätäksesi ominaisuuksia, parannuksia tai virheenkorjauksia. +6. Tutustu `Checkmate`-koodikannan interaktiiviseen läpikäyntiin CodeCanvasissa [täällä](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). Olemassa olevia datavirtasimulaatioita voi viimeistellä tai uusia luoda seuraamalla pikatutorialia [täällä](https://docs.code-canvas.com/updating-diagram). + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.fr.md b/docs/translations/README.fr.md new file mode 100644 index 000000000..fc4341a28 --- /dev/null +++ b/docs/translations/README.fr.md @@ -0,0 +1,197 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

Une application open source de surveillance de la disponibilité et de l'infrastructure

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +Ce dépôt contient à la fois le frontend et le backend de Checkmate, un outil de surveillance open source et auto-hébergeable permettant de suivre le matériel des serveurs, la disponibilité, les temps de réponse et les incidents en temps réel grâce à de belles visualisations. Checkmate vérifie régulièrement la disponibilité et le bon fonctionnement de vos serveurs ou sites web, et fournit des alertes et rapports en temps réel sur la disponibilité, les pannes et les temps de réponse des services surveillés. + +Checkmate dispose aussi d'un agent, appelé [Capture](https://github.com/bluewave-labs/capture), pour récupérer des données depuis des serveurs distants. Capture n'est pas requis pour faire tourner Checkmate, mais il apporte des informations supplémentaires sur l'état du CPU, de la RAM, du disque et de la température de vos serveurs. Capture fonctionne sur Linux, Windows, Mac, Raspberry Pi ou tout appareil capable d'exécuter Go. + +Checkmate a été testé en charge avec plus de 1 000 moniteurs actifs sans aucun problème ni goulet d'étranglement notable. + +## 📚 Table des matières + +- [📦 Démo](#demo) +- [🔗 Guide utilisateur](#users-guide) +- [🛠️ Installation](#installation) +- [🚀 Performance](#performance) +- [💚 Questions & idées](#questions--ideas) +- [🧩 Fonctionnalités](#features) +- [🏗️ Captures d'écran](#screenshots) +- [🏗️ Pile technique](#tech-stack) +- [🔗 Quelques liens](#a-few-links) +- [🤝 Contribuer](#contributing) + + + +## Démo + +Vous pouvez voir la dernière version de [Checkmate](https://demo.checkmate.so/) en action. + +L'identifiant est demouser@demo.com et le mot de passe Demouser1! (Note : nous mettons à jour le serveur de démo de temps en temps ; si cela ne fonctionne pas, faites-nous signe sur le canal Discussions). + + +## Guide utilisateur + +Les instructions d'utilisation sont disponibles [ici](https://checkmate.so/docs). + +## Prérequis +- [Docker](https://www.docker.com/) installé +- [Git](https://git-scm.com/) installé + + +## Installation + +Consultez les instructions d'installation dans le [portail de documentation Checkmate](https://checkmate.so/docs). + +Vous pouvez également utiliser [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (Afrique du Sud), [Cloudzy](https://cloudzy.com/marketplace/checkmate) ou [Pikapods](https://www.pikapods.com/) pour démarrer rapidement une instance Checkmate. Pour surveiller votre infrastructure serveur, vous aurez besoin de l'[agent Capture](https://github.com/bluewave-labs/capture). Le dépôt Capture contient également les instructions d'installation. + +### Utiliser une autorité de certification personnalisée + +Si vous devez surveiller des points d'accès HTTPS internes avec des certificats issus d'autorités de certification privées (comme Smallstep), consultez notre [guide de confiance d'autorité de certification personnalisée](../custom-ca-trust.md) pour les options de configuration Docker. + +Pour plus de documentation, consultez le [répertoire docs](../). + + +## Performance + +Grâce à de nombreuses optimisations, Checkmate fonctionne avec une empreinte mémoire exceptionnellement faible, nécessitant un minimum de RAM et de CPU. Voici l'utilisation mémoire d'une instance Node.js sur un serveur qui surveille 323 serveurs chaque minute : + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +Voici l'empreinte mémoire de MongoDB et Redis sur le même serveur (398 Mo et 15 Mo) pour le même nombre de serveurs : + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## Questions & idées + +Si vous avez des questions, suggestions ou commentaires, plusieurs options s'offrent à vous : + +- [Canal Discord](https://discord.gg/NAb6H3UTjK) (préféré) +- [Discussions GitHub](https://github.com/bluewave-labs/Checkmate/discussions) (nous y passons de temps en temps) + +N'hésitez pas à poser vos questions ou partager vos idées — nous adorerions vous entendre ! + + +## Fonctionnalités + +- Entièrement open source, déployable sur vos serveurs ou appareils personnels (par ex. Raspberry Pi 4 ou 5) +- Plusieurs options de surveillance : disponibilité, Docker, ping, SSL, port, serveur de jeu +- Surveillance de la vitesse des pages +- Surveillance de l'infrastructure (mémoire, utilisation du disque, performance CPU, réseau, etc.) — nécessite l'agent [Capture](https://github.com/bluewave-labs/capture) + - Surveillance sélective du disque avec choix de points de montage +- Incidents en un coup d'œil +- Pages de statut avec 4 thèmes élégants +- Notifications par e-mail, webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) +- Maintenance planifiée +- Surveillance par requête JSON +- Support multilingue : arabe, chinois (simplifié), chinois (traditionnel, Taïwan), tchèque, anglais, finnois, français, allemand, japonais, portugais (Brésil), russe, espagnol, thaï, turc, ukrainien et vietnamien + + +## Cycle de vie d'un moniteur + +1. Un moniteur exécute une vérification (HTTP / ping / port / matériel via l'agent Capture) +2. Le résultat est stocké (succès/échec + temps de réponse) +3. Les résultats récents sont évalués par rapport au seuil de changement de statut configuré pour le moniteur +4. Si le seuil est atteint et que le statut actuel diffère du précédent, l'état du moniteur change (ex. `initializing`, `up`, `down`, `breached`) +5. Lors d'un changement d'état, un incident est créé ou résolu selon le statut actuel +6. Des notifications sont déclenchées selon la configuration + + +## Captures d'écran + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## Pile technique + +- [ReactJs](https://react.dev/) +- [MUI (framework React)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- Beaucoup d'autres composants open source ! + + +## Quelques liens + +- Si vous souhaitez nous soutenir, merci de mettre une ⭐ et de cliquer sur « watch ». +- Une question ou une suggestion pour la roadmap ou les fonctionnalités ? Rejoignez notre [canal Discord](https://discord.gg/NAb6H3UTjK) ou les [Discussions](https://github.com/bluewave-labs/checkmate/discussions). +- Besoin d'être notifié à chaque nouvelle version ? Utilisez [Newreleases](https://newreleases.io/), un service gratuit de suivi des sorties. +- Regardez une [vidéo d'installation et d'utilisation de Checkmate](https://www.youtube.com/watch?v=GfFOc0xHIwY) + + +## Contribuer + +Nous sommes [Alex](http://github.com/ajhollid) (chef d'équipe), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) et [Karen](https://github.com/karenvicent), et nous aidons les particuliers et les entreprises à surveiller leur infrastructure et leurs serveurs. + +Nous sommes fiers de construire des liens solides avec les contributeurs à tous les niveaux. Bien que jeune, Checkmate a déjà obtenu plus de 7 000 étoiles et attiré plus de 90 contributeurs du monde entier. + +Notre dépôt a été suivi par des employés 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 et NEC** — alors n'hésitez pas, rejoignez-nous, contribuez et apprenez avec nous ! + +Voici comment contribuer : + +0. Mettez une étoile à ce dépôt :) +1. Consultez le [guide du contributeur](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). Les nouveaux contributeurs sont encouragés à consulter le tag `good-first-issue`. +2. Lisez une structure détaillée de [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) si vous souhaitez plonger dans l'architecture. +3. Ouvrez une issue si vous pensez avoir rencontré un bug. +4. Cherchez les `good-first-issue` si vous débutez. +5. Faites une pull request pour ajouter de nouvelles fonctionnalités, des améliorations ou corriger des bugs. +6. Découvrez ce parcours interactif de la base de code `Checkmate` sur CodeCanvas [ici](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). Pour affiner les simulations de flux de données existantes ou en créer de nouvelles, suivez le tutoriel rapide [ici](https://docs.code-canvas.com/updating-diagram). + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.ja.md b/docs/translations/README.ja.md new file mode 100644 index 000000000..c5d86e4d0 --- /dev/null +++ b/docs/translations/README.ja.md @@ -0,0 +1,197 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

オープンソースの稼働状況とインフラ監視アプリケーション

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +このリポジトリには、Checkmate のフロントエンドとバックエンドの両方が含まれています。Checkmate は、サーバーのハードウェア、稼働状況、応答時間、インシデントを美しい可視化でリアルタイムに追跡する、オープンソースかつセルフホスト型の監視ツールです。Checkmate はサーバーや Web サイトにアクセスできるか、最適に動作しているかを定期的にチェックし、監視対象サービスの可用性・ダウンタイム・応答時間に関するリアルタイムのアラートとレポートを提供します。 + +Checkmate には、リモートサーバーからデータを取得するためのエージェントとして [Capture](https://github.com/bluewave-labs/capture) もあります。Capture は Checkmate の動作に必須ではありませんが、サーバーの CPU、RAM、ディスク、温度の状態について追加の情報を提供します。Capture は Linux、Windows、Mac、Raspberry Pi、その他 Go を実行できるあらゆるデバイスで動作します。 + +Checkmate は 1000 以上のアクティブなモニターでストレステストされており、目立った問題や性能のボトルネックは発生していません。 + +## 📚 目次 + +- [📦 デモ](#demo) +- [🔗 ユーザーガイド](#users-guide) +- [🛠️ インストール](#installation) +- [🚀 パフォーマンス](#performance) +- [💚 質問とアイデア](#questions--ideas) +- [🧩 機能](#features) +- [🏗️ スクリーンショット](#screenshots) +- [🏗️ 技術スタック](#tech-stack) +- [🔗 関連リンク](#a-few-links) +- [🤝 コントリビューション](#contributing) + + + +## デモ + +[Checkmate](https://demo.checkmate.so/) の最新ビルドを実際に確認できます。 + +ユーザー名は demouser@demo.com、パスワードは Demouser1! です(注: デモサーバーは時々更新されています。動作しない場合は Discussions チャンネルでお知らせください)。 + + +## ユーザーガイド + +使用方法は [こちら](https://checkmate.so/docs) を参照してください。 + +## 前提条件 +- [Docker](https://www.docker.com/) がインストールされていること +- [Git](https://git-scm.com/) がインストールされていること + + +## インストール + +インストール手順は [Checkmate ドキュメントポータル](https://checkmate.so/docs) を参照してください。 + +代わりに、[Coolify](https://coolify.io/)、[Elestio](https://elest.io/open-source/checkmate)、[K8s](./charts/helm/checkmate/INSTALLATION.md)、[Sive Host](https://sive.host)(南アフリカ)、[Cloudzy](https://cloudzy.com/marketplace/checkmate)、[Pikapods](https://www.pikapods.com/) を使って Checkmate インスタンスをすばやく立ち上げることもできます。サーバーインフラを監視したい場合は、[Capture エージェント](https://github.com/bluewave-labs/capture) が必要です。Capture リポジトリにもインストール手順が記載されています。 + +### カスタム CA の利用 + +プライベート認証局(Smallstep など)が発行した証明書で内部 HTTPS エンドポイントを監視する必要がある場合は、Docker 設定のオプションを記載した [カスタム CA 信頼ガイド](../custom-ca-trust.md) をご覧ください。 + +その他のドキュメントは [docs ディレクトリ](../) を参照してください。 + + +## パフォーマンス + +広範な最適化により、Checkmate は非常に小さなメモリフットプリントで動作し、必要なメモリと CPU リソースは最小限です。以下は、毎分 323 台のサーバーを監視するサーバー上の Node.js インスタンスのメモリ使用量です: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +同じサーバー上の MongoDB と Redis のメモリフットプリント(それぞれ 398MB と 15MB、同じサーバー数を監視)も以下の通りです: + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## 質問とアイデア + +質問・提案・コメントがあれば、いくつかの選択肢があります: + +- [Discord チャンネル](https://discord.gg/NAb6H3UTjK)(推奨) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions)(時々確認しています) + +質問やアイデアの共有はお気軽にどうぞ — お声をお聞かせいただけると嬉しいです! + + +## 機能 + +- 完全にオープンソース、自身のサーバーや家庭用デバイス(例: Raspberry Pi 4 / 5)にデプロイ可能 +- 複数の監視オプション: 稼働状況、Docker、Ping、SSL、ポート、ゲームサーバー +- ページスピード監視 +- インフラ監視(メモリ、ディスク使用量、CPU 性能、ネットワークなど) — [Capture](https://github.com/bluewave-labs/capture) エージェントが必要 + - マウントポイントを選択した選択的なディスク監視 +- インシデントを一目で確認 +- 4 種類の美しいテーマを備えたステータスページ +- メール、Webhook、Discord、Slack、PagerDuty、Matrix、Microsoft Teams、Telegram、Pushover、Twilio (SMS) による通知 +- メンテナンスのスケジューリング +- JSON クエリ監視 +- 多言語対応: アラビア語、中国語(簡体字)、中国語(繁体字・台湾)、チェコ語、英語、フィンランド語、フランス語、ドイツ語、日本語、ポルトガル語(ブラジル)、ロシア語、スペイン語、タイ語、トルコ語、ウクライナ語、ベトナム語 + + +## モニターのライフサイクル + +1. モニターがチェックを実行する(HTTP / Ping / ポート / Capture エージェント経由のハードウェア) +2. 結果を保存する(成功・失敗 + 応答時間) +3. 直近の結果を、モニターに設定されたステータス変更のしきい値で評価する +4. しきい値を満たし、現在のステータスが直前と異なる場合、モニターの状態が変わる(例: `initializing`、`up`、`down`、`breached`) +5. 状態変化時に、現在のステータスに応じてインシデントを作成または解決する +6. 設定に基づいて通知をトリガーする + + +## スクリーンショット + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## 技術スタック + +- [ReactJs](https://react.dev/) +- [MUI (React フレームワーク)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- その他多数のオープンソースコンポーネント! + + +## 関連リンク + +- 応援いただける場合は、ぜひ ⭐ を付け、「watch」もクリックしてください。 +- ロードマップや機能セットへの質問・提案がありますか? [Discord チャンネル](https://discord.gg/NAb6H3UTjK) または [Discussions](https://github.com/bluewave-labs/checkmate/discussions) フォーラムをご覧ください。 +- 新しいリリースの通知が欲しいですか? リリース追跡用の無料サービス [Newreleases](https://newreleases.io/) をご利用ください。 +- Checkmate の [インストールおよび利用方法の動画](https://www.youtube.com/watch?v=GfFOc0xHIwY) もご覧ください。 + + +## コントリビューション + +私たちは [Alex](http://github.com/ajhollid)(チームリード)、[Gorkem](http://github.com/gorkem-bwl/)、[Aryaman](https://github.com/Br0wnHammer)、[Mert](https://github.com/mertssmnoglu)、[Karen](https://github.com/karenvicent) で、個人や企業がインフラとサーバーを監視するお手伝いをしています。 + +あらゆるレベルのコントリビューターとの強いつながりを築くことを誇りにしています。若いプロジェクトでありながら、Checkmate はすでに 7000 を超えるスターと、世界中から 90 名以上のコントリビューターを集めています。 + +このリポジトリには **Google、Microsoft、Intel、Cisco、Tencent、Electronic Arts、ByteDance、JP Morgan Chase、Deloitte、Accenture、Foxconn、Broadcom、China Telecom、Barclays、Capgemini、Wipro、Cloudflare、Dassault Systèmes、NEC** の社員からスターが付いています — ぜひ気軽に参加し、貢献し、私たちと一緒に学びましょう! + +コントリビュートの方法: + +0. このリポジトリにスターを付ける :) +1. [コントリビューションガイド](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md) を読む。初参加の方には `good-first-issue` タグの確認をおすすめします。 +2. アーキテクチャを深く知りたい場合は、[Checkmate の詳細構成](https://deepwiki.com/bluewave-labs/Checkmate) を読む。 +3. バグを発見したと思ったら issue を開く。 +4. はじめての方は `good-first-issue` を探す。 +5. 新機能、品質改善、バグ修正のために pull request を作成する。 +6. CodeCanvas 上の `Checkmate` コードベースのインタラクティブなウォークスルー([こちら](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true))を確認する。既存のデータフロー・シミュレーションを改善したり、新しいものを作成したりするには、[こちら](https://docs.code-canvas.com/updating-diagram) のクイックチュートリアルに従ってください。 + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.pt-BR.md b/docs/translations/README.pt-BR.md new file mode 100644 index 000000000..e96f7863e --- /dev/null +++ b/docs/translations/README.pt-BR.md @@ -0,0 +1,197 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

Uma aplicação de código aberto para monitoramento de disponibilidade e infraestrutura

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +Este repositório contém o frontend e o backend do Checkmate, uma ferramenta de monitoramento de código aberto e auto-hospedável para acompanhar hardware de servidores, disponibilidade, tempos de resposta e incidentes em tempo real com visualizações bonitas. O Checkmate verifica regularmente se um servidor ou site está acessível e funcionando de forma ótima, oferecendo alertas e relatórios em tempo real sobre disponibilidade, indisponibilidade e tempos de resposta dos serviços monitorados. + +O Checkmate também possui um agente chamado [Capture](https://github.com/bluewave-labs/capture) para coletar dados de servidores remotos. O Capture não é necessário para rodar o Checkmate, mas fornece informações adicionais sobre o status de CPU, RAM, disco e temperatura dos servidores. O Capture pode rodar em Linux, Windows, Mac, Raspberry Pi ou qualquer dispositivo capaz de executar Go. + +O Checkmate passou por testes de estresse com mais de 1000 monitores ativos sem nenhum problema ou gargalo de desempenho notável. + +## 📚 Sumário + +- [📦 Demo](#demo) +- [🔗 Guia do usuário](#users-guide) +- [🛠️ Instalação](#installation) +- [🚀 Desempenho](#performance) +- [💚 Perguntas & ideias](#questions--ideas) +- [🧩 Funcionalidades](#features) +- [🏗️ Capturas de tela](#screenshots) +- [🏗️ Stack tecnológica](#tech-stack) +- [🔗 Alguns links](#a-few-links) +- [🤝 Contribuindo](#contributing) + + + +## Demo + +Você pode ver a versão mais recente do [Checkmate](https://demo.checkmate.so/) em ação. + +O usuário é demouser@demo.com e a senha é Demouser1! (Observação: atualizamos o servidor de demonstração de tempos em tempos; se não funcionar, fale conosco no canal Discussions). + + +## Guia do usuário + +As instruções de uso podem ser encontradas [aqui](https://checkmate.so/docs). + +## Pré-requisitos +- [Docker](https://www.docker.com/) instalado +- [Git](https://git-scm.com/) instalado + + +## Instalação + +Veja as instruções de instalação no [portal de documentação do Checkmate](https://checkmate.so/docs). + +Alternativamente, você pode usar [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (África do Sul), [Cloudzy](https://cloudzy.com/marketplace/checkmate) ou [Pikapods](https://www.pikapods.com/) para subir rapidamente uma instância do Checkmate. Se quiser monitorar sua infraestrutura de servidores, você precisará do [agente Capture](https://github.com/bluewave-labs/capture). O repositório do Capture também contém as instruções de instalação. + +### Usando uma CA personalizada + +Se você precisa monitorar endpoints HTTPS internos com certificados emitidos por autoridades certificadoras privadas (como Smallstep), veja nosso [Guia de confiança em CA personalizada](../custom-ca-trust.md) para opções de configuração do Docker. + +Para mais documentação, veja o [diretório docs](../). + + +## Desempenho + +Graças a otimizações extensas, o Checkmate opera com uma pegada de memória excepcionalmente pequena, exigindo memória e CPU mínimos. Veja o uso de memória de uma instância Node.js em um servidor monitorando 323 servidores a cada minuto: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +Você pode ver a pegada de memória do MongoDB e Redis no mesmo servidor (398 MB e 15 MB) para a mesma quantidade de servidores: + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## Perguntas & ideias + +Se você tem perguntas, sugestões ou comentários, há várias opções: + +- [Canal do Discord](https://discord.gg/NAb6H3UTjK) (preferido) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (passamos por aqui de vez em quando) + +Sinta-se à vontade para perguntar ou compartilhar suas ideias — adoramos ouvir você! + + +## Funcionalidades + +- Totalmente código aberto, implantável em seus servidores ou dispositivos domésticos (ex.: Raspberry Pi 4 ou 5) +- Várias opções de monitoramento: disponibilidade, Docker, ping, SSL, porta, servidor de jogos +- Monitoramento de velocidade de página +- Monitoramento de infraestrutura (memória, uso de disco, desempenho de CPU, rede, etc.) — requer o agente [Capture](https://github.com/bluewave-labs/capture) + - Monitoramento seletivo de disco com escolha de pontos de montagem +- Incidentes em um relance +- Páginas de status com 4 temas elegantes +- Notificações por e-mail, webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) +- Manutenção agendada +- Monitoramento por consulta JSON +- Suporte a múltiplos idiomas: árabe, chinês (simplificado), chinês (tradicional, Taiwan), tcheco, inglês, finlandês, francês, alemão, japonês, português (Brasil), russo, espanhol, tailandês, turco, ucraniano e vietnamita + + +## Ciclo de vida do monitor + +1. Um monitor executa uma verificação (HTTP / ping / porta / hardware via agente Capture) +2. O resultado é armazenado (sucesso/falha + tempo de resposta) +3. Os resultados recentes são avaliados em relação ao limite de mudança de status configurado +4. Se o limite for atingido e o status atual diferir do anterior, o estado do monitor muda (ex.: `initializing`, `up`, `down`, `breached`) +5. Em uma mudança de estado, um incidente é criado ou resolvido conforme o status atual do monitor +6. As notificações são disparadas com base na configuração + + +## Capturas de tela + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## Stack tecnológica + +- [ReactJs](https://react.dev/) +- [MUI (framework React)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- E muitos outros componentes de código aberto! + + +## Alguns links + +- Se quiser nos apoiar, considere dar uma ⭐ e clicar em "watch". +- Tem alguma pergunta ou sugestão sobre o roadmap/funcionalidades? Confira nosso [canal do Discord](https://discord.gg/NAb6H3UTjK) ou o fórum [Discussions](https://github.com/bluewave-labs/checkmate/discussions). +- Quer receber um aviso a cada novo lançamento? Use o [Newreleases](https://newreleases.io/), um serviço gratuito de acompanhamento de releases. +- Assista a um [vídeo de instalação e uso do Checkmate](https://www.youtube.com/watch?v=GfFOc0xHIwY) + + +## Contribuindo + +Somos o [Alex](http://github.com/ajhollid) (líder de equipe), o [Gorkem](http://github.com/gorkem-bwl/), o [Aryaman](https://github.com/Br0wnHammer), o [Mert](https://github.com/mertssmnoglu) e a [Karen](https://github.com/karenvicent) — ajudamos indivíduos e empresas a monitorar suas infraestruturas e servidores. + +Nos orgulhamos de construir conexões fortes com contribuidores de todos os níveis. Apesar de ser um projeto jovem, o Checkmate já conquistou mais de 7000 estrelas e atraiu mais de 90 contribuidores no mundo todo. + +Nosso repositório recebeu estrelas de funcionários do **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes e NEC** — então não se segure: entre, contribua e aprenda com a gente! + +Como contribuir: + +0. Dê uma estrela neste repositório :) +1. Confira o [guia do contribuidor](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). Iniciantes são incentivados a verificar a tag `good-first-issue`. +2. Leia uma estrutura detalhada do [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) se quiser mergulhar na arquitetura. +3. Abra uma issue se acreditar ter encontrado um bug. +4. Procure por `good-first-issue`s se você é novo. +5. Faça um pull request para adicionar novas funcionalidades, melhorias ou correções de bugs. +6. Confira este passo a passo interativo da base de código do `Checkmate` no CodeCanvas [aqui](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). Para refinar simulações existentes de fluxo de dados ou criar novas, siga o tutorial rápido [aqui](https://docs.code-canvas.com/updating-diagram). + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.ru.md b/docs/translations/README.ru.md new file mode 100644 index 000000000..4f5d20725 --- /dev/null +++ b/docs/translations/README.ru.md @@ -0,0 +1,197 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

Open source приложение для мониторинга доступности и инфраструктуры

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +В этом репозитории находятся как frontend, так и backend Checkmate — open-source-инструмента самостоятельного хостинга для отслеживания оборудования серверов, времени работы, времени отклика и инцидентов в реальном времени с наглядными визуализациями. Checkmate регулярно проверяет, доступен ли сервер/сайт и работает ли он оптимально, предоставляя оповещения и отчёты о доступности, простое и времени отклика отслеживаемых сервисов в реальном времени. + +У Checkmate также есть агент под названием [Capture](https://github.com/bluewave-labs/capture) для получения данных с удалённых серверов. Capture не является обязательным для работы Checkmate, но даёт дополнительную информацию о состоянии CPU, RAM, диска и температуры ваших серверов. Capture работает на Linux, Windows, Mac, Raspberry Pi и любом устройстве, способном запускать Go. + +Checkmate прошёл нагрузочное тестирование с 1000+ активных мониторов без каких-либо заметных проблем или просадок производительности. + +## 📚 Содержание + +- [📦 Демо](#demo) +- [🔗 Руководство пользователя](#users-guide) +- [🛠️ Установка](#installation) +- [🚀 Производительность](#performance) +- [💚 Вопросы и идеи](#questions--ideas) +- [🧩 Возможности](#features) +- [🏗️ Скриншоты](#screenshots) +- [🏗️ Технологии](#tech-stack) +- [🔗 Несколько ссылок](#a-few-links) +- [🤝 Участие](#contributing) + + + +## Демо + +Последнюю сборку [Checkmate](https://demo.checkmate.so/) можно увидеть в действии. + +Имя пользователя — demouser@demo.com, пароль — Demouser1! (Обратите внимание: мы периодически обновляем демо-сервер; если что-то не работает, напишите нам на канале Discussions). + + +## Руководство пользователя + +Инструкции по использованию доступны [здесь](https://checkmate.so/docs). + +## Требования +- Установленный [Docker](https://www.docker.com/) +- Установленный [Git](https://git-scm.com/) + + +## Установка + +Инструкции по установке смотрите на [портале документации Checkmate](https://checkmate.so/docs). + +В качестве альтернативы вы можете использовать [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (ЮАР), [Cloudzy](https://cloudzy.com/marketplace/checkmate) или [Pikapods](https://www.pikapods.com/), чтобы быстро поднять инстанс Checkmate. Если вы хотите мониторить серверную инфраструктуру, потребуется агент [Capture](https://github.com/bluewave-labs/capture). В репозитории Capture также есть инструкции по установке. + +### Использование собственного УЦ + +Если нужно мониторить внутренние HTTPS-эндпоинты с сертификатами от приватных УЦ (например, Smallstep), смотрите наше [руководство по доверию собственным УЦ](../custom-ca-trust.md) с параметрами конфигурации Docker. + +Дополнительная документация — в [каталоге docs](../). + + +## Производительность + +Благодаря многочисленным оптимизациям Checkmate работает с исключительно низким потреблением памяти и требует минимума RAM и CPU. Ниже — потребление памяти инстансом Node.js на сервере, который мониторит 323 сервера каждую минуту: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +А вот объём памяти MongoDB и Redis на том же сервере (398 МБ и 15 МБ) для того же количества серверов: + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## Вопросы и идеи + +Если у вас есть вопросы, предложения или комментарии — есть несколько вариантов: + +- [Канал в Discord](https://discord.gg/NAb6H3UTjK) (предпочтительно) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (заглядываем туда время от времени) + +Смело задавайте вопросы и делитесь идеями — мы будем рады услышать вас! + + +## Возможности + +- Полностью open source, можно развернуть на собственных серверах или домашних устройствах (например, Raspberry Pi 4 или 5) +- Несколько вариантов мониторинга: доступность, Docker, ping, SSL, порт, игровой сервер +- Мониторинг скорости загрузки страниц +- Мониторинг инфраструктуры (память, использование диска, производительность CPU, сеть и т. д.) — требуется агент [Capture](https://github.com/bluewave-labs/capture) + - Выборочный мониторинг дисков с выбором точек монтирования +- Инциденты в одном месте +- Страницы статуса с 4 красивыми темами +- Уведомления по e-mail, через вебхуки, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) +- Запланированное обслуживание +- Мониторинг JSON-запросов +- Многоязычная поддержка: арабский, китайский (упрощённый), китайский (традиционный, Тайвань), чешский, английский, финский, французский, немецкий, японский, португальский (Бразилия), русский, испанский, тайский, турецкий, украинский и вьетнамский + + +## Жизненный цикл монитора + +1. Монитор выполняет проверку (HTTP / ping / порт / оборудование через агента Capture) +2. Результат сохраняется (успех/неудача + время отклика) +3. Недавние результаты проверяются по заданному порогу смены статуса +4. Если порог достигнут, а текущий статус отличается от предыдущего — состояние монитора изменяется (например, `initializing`, `up`, `down`, `breached`) +5. При смене состояния инцидент создаётся или закрывается в зависимости от текущего статуса +6. Уведомления отправляются в соответствии с настройками + + +## Скриншоты + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## Технологии + +- [ReactJs](https://react.dev/) +- [MUI (React-фреймворк)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- И множество других open-source компонентов! + + +## Несколько ссылок + +- Если хотите поддержать нас — поставьте ⭐ и нажмите «watch». +- Есть вопрос или предложение по дорожной карте/функциям? Загляните в наш [канал Discord](https://discord.gg/NAb6H3UTjK) или форум [Discussions](https://github.com/bluewave-labs/checkmate/discussions). +- Нужно уведомление о новых релизах? Используйте [Newreleases](https://newreleases.io/) — бесплатный сервис отслеживания релизов. +- Посмотрите [видео по установке и использованию Checkmate](https://www.youtube.com/watch?v=GfFOc0xHIwY) + + +## Участие + +Мы — [Alex](http://github.com/ajhollid) (тимлид), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) и [Karen](https://github.com/karenvicent) — помогаем частным лицам и компаниям мониторить их инфраструктуру и серверы. + +Мы гордимся тем, что выстраиваем крепкие связи с контрибьюторами на всех уровнях. Несмотря на то что проект молодой, Checkmate уже получил более 7000 звёзд и более 90 контрибьюторов со всего мира. + +Наш репозиторий помечен сотрудниками **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes и NEC** — так что не стесняйтесь, присоединяйтесь, вносите вклад и учитесь вместе с нами! + +Как внести вклад: + +0. Поставьте звезду этому репозиторию :) +1. Прочтите [руководство контрибьютора](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). Новичкам советуем посмотреть метку `good-first-issue`. +2. Если хотите глубже погрузиться в архитектуру — прочтите подробное описание [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate). +3. Откройте issue, если столкнулись с багом. +4. Если вы новичок — ищите `good-first-issue`. +5. Откройте pull request для добавления функций, улучшений или исправлений. +6. Посмотрите интерактивный обзор кодовой базы `Checkmate` в CodeCanvas [здесь](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). Чтобы доработать имеющиеся симуляции потоков данных или создать новые, следуйте краткому туториалу [здесь](https://docs.code-canvas.com/updating-diagram). + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.th.md b/docs/translations/README.th.md new file mode 100644 index 000000000..0ea5de3fb --- /dev/null +++ b/docs/translations/README.th.md @@ -0,0 +1,200 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +> ⚠️ คำแปลนี้เป็นฉบับร่างที่สร้างโดยเครื่อง ยินดีรับการตรวจทานจากเจ้าของภาษาผ่าน pull request + + +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

แอปพลิเคชันโอเพนซอร์สสำหรับตรวจสอบสถานะการทำงานและโครงสร้างพื้นฐาน

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +รีโพนี้ประกอบด้วยทั้งฟรอนต์เอนด์และแบ็กเอนด์ของ Checkmate ซึ่งเป็นเครื่องมือมอนิเตอร์โอเพนซอร์สและสามารถโฮสต์เองได้ ใช้ติดตามฮาร์ดแวร์ของเซิร์ฟเวอร์ เวลาการทำงาน เวลาในการตอบสนอง และเหตุการณ์ต่าง ๆ แบบเรียลไทม์พร้อมการแสดงผลที่สวยงาม Checkmate จะตรวจสอบเป็นระยะ ๆ ว่าเซิร์ฟเวอร์/เว็บไซต์ใช้งานได้และทำงานได้อย่างเหมาะสมหรือไม่ พร้อมส่งการแจ้งเตือนและรายงานแบบเรียลไทม์เกี่ยวกับความพร้อมใช้งาน ช่วงเวลาที่ระบบไม่พร้อมใช้งาน และเวลาในการตอบสนองของบริการที่ตรวจสอบ + +Checkmate ยังมีเอเจนต์ชื่อ [Capture](https://github.com/bluewave-labs/capture) สำหรับดึงข้อมูลจากเซิร์ฟเวอร์ระยะไกล Capture ไม่จำเป็นต้องใช้เพื่อรัน Checkmate แต่จะให้ข้อมูลเพิ่มเติมเกี่ยวกับ CPU, RAM, ดิสก์ และอุณหภูมิของเซิร์ฟเวอร์ Capture ทำงานได้บน Linux, Windows, Mac, Raspberry Pi หรือทุกอุปกรณ์ที่รัน Go ได้ + +Checkmate ผ่านการทดสอบโหลดสำหรับมอนิเตอร์ที่ทำงานอยู่กว่า 1000 รายการโดยไม่พบปัญหาหรือคอขวดด้านประสิทธิภาพที่สำคัญ + +## 📚 สารบัญ + +- [📦 เดโม](#demo) +- [🔗 คู่มือผู้ใช้](#users-guide) +- [🛠️ การติดตั้ง](#installation) +- [🚀 ประสิทธิภาพ](#performance) +- [💚 คำถามและไอเดีย](#questions--ideas) +- [🧩 ฟีเจอร์](#features) +- [🏗️ ภาพหน้าจอ](#screenshots) +- [🏗️ Tech stack](#tech-stack) +- [🔗 ลิงก์เพิ่มเติม](#a-few-links) +- [🤝 การมีส่วนร่วม](#contributing) + + + +## เดโม + +คุณสามารถดูบิลด์ล่าสุดของ [Checkmate](https://demo.checkmate.so/) ที่ใช้งานจริง + +ชื่อผู้ใช้คือ demouser@demo.com และรหัสผ่านคือ Demouser1! (โปรดทราบว่าเราอัปเดตเซิร์ฟเวอร์เดโมเป็นระยะ ๆ หากไม่ทำงาน โปรดแจ้งเราที่ช่อง Discussions) + + +## คู่มือผู้ใช้ + +วิธีใช้งานสามารถดูได้ [ที่นี่](https://checkmate.so/docs) + +## ข้อกำหนดเบื้องต้น +- ติดตั้ง [Docker](https://www.docker.com/) +- ติดตั้ง [Git](https://git-scm.com/) + + +## การติดตั้ง + +ดูวิธีการติดตั้งใน [พอร์ทัลเอกสารของ Checkmate](https://checkmate.so/docs) + +อีกทางเลือกหนึ่ง คุณสามารถใช้ [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (แอฟริกาใต้), [Cloudzy](https://cloudzy.com/marketplace/checkmate) หรือ [Pikapods](https://www.pikapods.com/) เพื่อเปิดอินสแตนซ์ Checkmate ได้อย่างรวดเร็ว หากต้องการมอนิเตอร์โครงสร้างพื้นฐานเซิร์ฟเวอร์ คุณจะต้องใช้ [เอเจนต์ Capture](https://github.com/bluewave-labs/capture) รีโพ Capture ยังมีวิธีการติดตั้งด้วย + +### การใช้ Custom CA + +หากคุณต้องการมอนิเตอร์ HTTPS เอนด์พอยต์ภายในที่ใช้ใบรับรองจาก CA ส่วนตัว (เช่น Smallstep) โปรดดู [คู่มือการเชื่อถือ CA แบบกำหนดเอง](../custom-ca-trust.md) สำหรับตัวเลือกการตั้งค่า Docker + +สำหรับเอกสารเพิ่มเติม ดูที่ [โฟลเดอร์ docs](../) + + +## ประสิทธิภาพ + +ด้วยการปรับแต่งอย่างละเอียด Checkmate ใช้หน่วยความจำน้อยอย่างมาก และต้องการ RAM กับ CPU น้อยที่สุด ด้านล่างเป็นการใช้หน่วยความจำของอินสแตนซ์ Node.js บนเซิร์ฟเวอร์ที่มอนิเตอร์เซิร์ฟเวอร์ 323 เครื่องทุกนาที: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +คุณยังสามารถดูการใช้หน่วยความจำของ MongoDB และ Redis บนเซิร์ฟเวอร์เดียวกัน (398MB และ 15MB) สำหรับจำนวนเซิร์ฟเวอร์เท่าเดิม: + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## คำถามและไอเดีย + +หากมีคำถาม ข้อเสนอแนะ หรือความคิดเห็น คุณมีหลายทางเลือก: + +- [ช่อง Discord](https://discord.gg/NAb6H3UTjK) (แนะนำ) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (เราเข้ามาดูเป็นระยะ) + +อย่าลังเลที่จะถามหรือแชร์ไอเดีย — เรายินดีรับฟังเสมอ! + + +## ฟีเจอร์ + +- โอเพนซอร์สอย่างสมบูรณ์ สามารถดีพลอยบนเซิร์ฟเวอร์หรืออุปกรณ์ภายในบ้าน (เช่น Raspberry Pi 4 หรือ 5) +- ตัวเลือกการมอนิเตอร์หลายแบบ: สถานะการทำงาน, Docker, Ping, SSL, พอร์ต, เซิร์ฟเวอร์เกม +- มอนิเตอร์ความเร็วของหน้าเว็บ +- มอนิเตอร์โครงสร้างพื้นฐาน (หน่วยความจำ การใช้ดิสก์ ประสิทธิภาพ CPU เครือข่าย ฯลฯ) — ต้องใช้เอเจนต์ [Capture](https://github.com/bluewave-labs/capture) + - มอนิเตอร์ดิสก์แบบเลือกได้ด้วยการเลือก mount point +- ดูเหตุการณ์ได้ในที่เดียว +- หน้าสถานะที่มี 4 ธีมสวยงาม +- การแจ้งเตือนผ่านอีเมล Webhook, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) +- การบำรุงรักษาตามกำหนด +- มอนิเตอร์ด้วย JSON query +- รองรับหลายภาษา: อาหรับ, จีน (ตัวย่อ), จีน (ตัวเต็ม, ไต้หวัน), เช็ก, อังกฤษ, ฟินแลนด์, ฝรั่งเศส, เยอรมัน, ญี่ปุ่น, โปรตุเกส (บราซิล), รัสเซีย, สเปน, ไทย, ตุรกี, ยูเครน และเวียดนาม + + +## วงจรชีวิตของมอนิเตอร์ + +1. มอนิเตอร์ทำการตรวจสอบ (HTTP / Ping / พอร์ต / ฮาร์ดแวร์ผ่านเอเจนต์ Capture) +2. ผลลัพธ์ถูกบันทึก (สำเร็จ/ล้มเหลว + เวลาในการตอบสนอง) +3. ผลลัพธ์ล่าสุดจะถูกประเมินเทียบกับเกณฑ์การเปลี่ยนสถานะที่ตั้งค่าไว้ +4. ถ้าเกณฑ์ถูกตอบสนอง และสถานะปัจจุบันไม่เท่ากับสถานะก่อนหน้า สถานะของมอนิเตอร์จะเปลี่ยน (เช่น `initializing`, `up`, `down`, `breached`) +5. เมื่อสถานะเปลี่ยน เหตุการณ์จะถูกสร้างหรือถูกแก้ไขตามสถานะปัจจุบัน +6. การแจ้งเตือนจะถูกทริกเกอร์ตามการตั้งค่า + + +## ภาพหน้าจอ + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## Tech stack + +- [ReactJs](https://react.dev/) +- [MUI (React framework)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- และส่วนประกอบโอเพนซอร์สอื่นอีกมาก! + + +## ลิงก์เพิ่มเติม + +- หากต้องการสนับสนุนเรา โปรดให้ ⭐ และคลิก "watch" +- มีคำถามหรือข้อเสนอแนะเกี่ยวกับโรดแมป/ฟีเจอร์? เยี่ยมชม [ช่อง Discord](https://discord.gg/NAb6H3UTjK) หรือฟอรัม [Discussions](https://github.com/bluewave-labs/checkmate/discussions) ของเรา +- ต้องการการแจ้งเตือนเมื่อมีรีลีสใหม่? ใช้ [Newreleases](https://newreleases.io/) บริการฟรีสำหรับติดตามการรีลีส +- ดู [วิดีโอการติดตั้งและใช้งาน Checkmate](https://www.youtube.com/watch?v=GfFOc0xHIwY) + + +## การมีส่วนร่วม + +พวกเราคือ [Alex](http://github.com/ajhollid) (หัวหน้าทีม), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) และ [Karen](https://github.com/karenvicent) ที่ช่วยให้บุคคลและธุรกิจสามารถมอนิเตอร์โครงสร้างพื้นฐานและเซิร์ฟเวอร์ได้ + +เราภูมิใจกับการสร้างความสัมพันธ์ที่แข็งแกร่งกับผู้ร่วมพัฒนาในทุกระดับ แม้จะเป็นโครงการที่ยังใหม่ แต่ Checkmate ได้รับมากกว่า 7000 ดาว และมีผู้ร่วมพัฒนามากกว่า 90 คนจากทั่วโลก + +รีโพของเราได้รับดาวจากพนักงานของ **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes และ NEC** — เพราะฉะนั้นอย่าลังเล ร่วมพัฒนา เรียนรู้ และเติบโตไปกับเรา! + +วิธีที่คุณสามารถร่วมพัฒนา: + +0. ให้ดาวกับรีโพนี้ :) +1. อ่าน [คู่มือผู้ร่วมพัฒนา](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md) ผู้เริ่มต้นแนะนำให้ดูแท็ก `good-first-issue` +2. อ่านโครงสร้างเชิงลึกของ [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) หากต้องการเจาะลึกสถาปัตยกรรม +3. เปิด issue หากคุณคิดว่าพบบั๊ก +4. มองหาประเด็นที่ติดแท็ก `good-first-issue` หากเป็นมือใหม่ +5. ส่ง pull request เพื่อเพิ่มฟีเจอร์ ปรับปรุงการใช้งาน หรือแก้บั๊ก +6. ดู interactive walkthrough ของโค้ดเบส `Checkmate` บน CodeCanvas [ที่นี่](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true) เพื่อปรับปรุงการจำลอง dataflow ที่มีอยู่หรือสร้างใหม่ ทำตามบทเรียนสั้น [ที่นี่](https://docs.code-canvas.com/updating-diagram) + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.tr.md b/docs/translations/README.tr.md new file mode 100644 index 000000000..48c56f3b0 --- /dev/null +++ b/docs/translations/README.tr.md @@ -0,0 +1,197 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

Açık kaynaklı çalışma süresi ve altyapı izleme uygulaması

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +Bu depo, sunucu donanımını, çalışma süresini, yanıt sürelerini ve olayları zarif görselleştirmelerle gerçek zamanlı takip eden, açık kaynaklı ve kendi sunucunuzda barındırılan bir izleme aracı olan Checkmate'in hem frontend hem de backend tarafını içerir. Checkmate, bir sunucunun ya da web sitesinin erişilebilir ve düzgün çalışıyor olup olmadığını düzenli olarak kontrol eder; izlenen servislerin kullanılabilirliği, kesintileri ve yanıt süreleri hakkında gerçek zamanlı uyarılar ve raporlar sağlar. + +Checkmate'in ayrıca uzak sunuculardan veri çekmek için [Capture](https://github.com/bluewave-labs/capture) adında bir ajanı vardır. Capture, Checkmate'i çalıştırmak için zorunlu değildir; ancak sunucularınızın CPU, RAM, disk ve sıcaklık durumu hakkında ek bilgi sağlar. Capture; Linux, Windows, Mac, Raspberry Pi ve Go çalıştırabilen herhangi bir cihazda kullanılabilir. + +Checkmate, 1000'den fazla aktif monitör ile herhangi bir sorun ya da performans darboğazı yaşamadan stres testinden geçirilmiştir. + +## 📚 İçindekiler + +- [📦 Demo](#demo) +- [🔗 Kullanıcı kılavuzu](#users-guide) +- [🛠️ Kurulum](#installation) +- [🚀 Performans](#performance) +- [💚 Sorular & fikirler](#questions--ideas) +- [🧩 Özellikler](#features) +- [🏗️ Ekran görüntüleri](#screenshots) +- [🏗️ Teknoloji yığını](#tech-stack) +- [🔗 Birkaç bağlantı](#a-few-links) +- [🤝 Katkıda bulunma](#contributing) + + + +## Demo + +[Checkmate](https://demo.checkmate.so/)'in en son sürümünü iş başında görebilirsiniz. + +Kullanıcı adı demouser@demo.com, şifre Demouser1!'dir (Not: Demo sunucusunu zaman zaman güncelliyoruz; çalışmazsa Discussions kanalından bize haber verin). + + +## Kullanıcı kılavuzu + +Kullanım talimatlarını [burada](https://checkmate.so/docs) bulabilirsiniz. + +## Ön koşullar +- [Docker](https://www.docker.com/) kurulu +- [Git](https://git-scm.com/) kurulu + + +## Kurulum + +Kurulum talimatları için [Checkmate dokümantasyon portalına](https://checkmate.so/docs) bakın. + +Alternatif olarak hızlıca bir Checkmate örneği çalıştırmak için [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (Güney Afrika), [Cloudzy](https://cloudzy.com/marketplace/checkmate) veya [Pikapods](https://www.pikapods.com/)'u kullanabilirsiniz. Sunucu altyapınızı izlemek isterseniz [Capture ajanına](https://github.com/bluewave-labs/capture) ihtiyacınız olacak. Capture deposu da kurulum talimatlarını içerir. + +### Özel CA Kullanımı + +Özel sertifika otoritelerinden (örneğin Smallstep) gelen sertifikalarla iç HTTPS uç noktalarını izlemeniz gerekiyorsa, Docker yapılandırma seçenekleri için [Özel CA Güven Kılavuzumuza](../custom-ca-trust.md) bakın. + +Daha fazla dokümantasyon için [docs dizinine](../) göz atın. + + +## Performans + +Geniş kapsamlı optimizasyonlar sayesinde Checkmate son derece düşük bellek ayak iziyle çalışır ve minimum bellek ile CPU kaynağı gerektirir. Aşağıda her dakika 323 sunucuyu izleyen bir sunucudaki Node.js örneğinin bellek kullanımı yer almaktadır: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +Aynı sayıda sunucu için aynı sunucudaki MongoDB ve Redis bellek ayak izini de görebilirsiniz (398 MB ve 15 MB): + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## Sorular & fikirler + +Sorularınız, önerileriniz ya da yorumlarınız varsa birkaç seçeneğiniz var: + +- [Discord kanalı](https://discord.gg/NAb6H3UTjK) (tercih edilen) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (zaman zaman buraya bakıyoruz) + +Soru sormaktan ya da fikirlerinizi paylaşmaktan çekinmeyin — sizi dinlemekten mutluluk duyarız! + + +## Özellikler + +- Tamamen açık kaynak; kendi sunucularınızda ya da ev cihazlarınızda (örn. Raspberry Pi 4 veya 5) çalıştırılabilir +- Birden fazla izleme seçeneği: çalışma süresi, Docker, ping, SSL, port, oyun sunucusu +- Sayfa hızı izleme +- Altyapı izleme (bellek, disk kullanımı, CPU performansı, ağ vb.) — [Capture](https://github.com/bluewave-labs/capture) ajanı gerekir + - Bağlama noktası seçimiyle seçici disk izleme +- Olayları tek bakışta görme +- 4 farklı şık temaya sahip durum sayfaları +- E-posta, webhook, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) bildirimleri +- Planlı bakım +- JSON sorgu izleme +- Çoklu dil desteği: Arapça, Çince (Basitleştirilmiş), Çince (Geleneksel, Tayvan), Çekçe, İngilizce, Fince, Fransızca, Almanca, Japonca, Portekizce (Brezilya), Rusça, İspanyolca, Tayca, Türkçe, Ukraynaca ve Vietnamca + + +## Monitör yaşam döngüsü + +1. Bir monitör kontrol gerçekleştirir (HTTP / ping / port / Capture ajanı üzerinden donanım) +2. Sonuç kaydedilir (başarılı/başarısız + yanıt süresi) +3. Son sonuçlar, monitörün durum değişikliği eşiğine göre değerlendirilir +4. Eşik aşıldığında ve mevcut durum öncekinden farklıysa, monitörün durumu değişir (örn. `initializing`, `up`, `down`, `breached`) +5. Durum değişikliğinde, monitörün geçerli durumuna bağlı olarak bir olay oluşturulur ya da çözülür +6. Yapılandırmaya göre bildirimler tetiklenir + + +## Ekran görüntüleri + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## Teknoloji yığını + +- [ReactJs](https://react.dev/) +- [MUI (React framework)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- Ve birçok başka açık kaynak bileşen! + + +## Birkaç bağlantı + +- Bizi desteklemek isterseniz, lütfen ⭐ verip "watch" düğmesine basmayı düşünün. +- Yol haritası veya özellik seti için bir sorunuz ya da öneriniz var mı? [Discord kanalımıza](https://discord.gg/NAb6H3UTjK) veya [Discussions](https://github.com/bluewave-labs/checkmate/discussions) forumumuza bakın. +- Yeni bir sürüm çıktığında haber almak ister misiniz? Ücretsiz bir sürüm takip servisi olan [Newreleases](https://newreleases.io/)'ü kullanın. +- Bir Checkmate [kurulum ve kullanım videosu](https://www.youtube.com/watch?v=GfFOc0xHIwY) izleyin. + + +## Katkıda bulunma + +Biz; [Alex](http://github.com/ajhollid) (takım lideri), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) ve [Karen](https://github.com/karenvicent) olarak bireylerin ve işletmelerin altyapı ile sunucularını izlemelerine yardımcı oluyoruz. + +Her seviyedeki katkıda bulunanlarla güçlü bağlar kurmaktan gurur duyuyoruz. Genç bir proje olmasına rağmen Checkmate şimdiden 7000'den fazla yıldız ve dünya genelinde 90'dan fazla katkıda bulunan kazanmıştır. + +Reponuz **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes ve NEC** çalışanları tarafından yıldızlanmıştır — siz de çekinmeyin, katılın, katkıda bulunun ve bizimle birlikte öğrenin! + +Nasıl katkıda bulunabilirsiniz: + +0. Bu repoya yıldız verin :) +1. [Katkıda bulunma kılavuzunu](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md) inceleyin. Yeni başlayanların `good-first-issue` etiketine bakmasını öneririz. +2. Mimari hakkında derinlemesine bilgi almak isterseniz, ayrıntılı [Checkmate yapısını](https://deepwiki.com/bluewave-labs/Checkmate) okuyun. +3. Bir hata yakaladığınızı düşünüyorsanız bir issue açın. +4. Yeni başlıyorsanız `good-first-issue`'lara bakın. +5. Yeni özellikler eklemek, yaşam kalitesi iyileştirmeleri yapmak veya hata düzeltmek için pull request açın. +6. CodeCanvas'taki `Checkmate` kod tabanının interaktif gezintisine [buradan](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true) göz atın. Mevcut veri akışı simülasyonlarını iyileştirmek veya yenilerini oluşturmak için [buradaki](https://docs.code-canvas.com/updating-diagram) hızlı eğitimi takip edin. + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.uk.md b/docs/translations/README.uk.md new file mode 100644 index 000000000..3f7b4630a --- /dev/null +++ b/docs/translations/README.uk.md @@ -0,0 +1,200 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +> ⚠️ Цей переклад є машинним чернетковим варіантом. Будемо вдячні за виправлення від носіїв мови через pull request. + + +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

Open source застосунок для моніторингу доступності та інфраструктури

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +Цей репозиторій містить як фронтенд, так і бекенд Checkmate — інструменту моніторингу з відкритим кодом і самостійним розгортанням для відстеження апаратного забезпечення серверів, часу безперебійної роботи, часу відгуку та інцидентів у реальному часі з красивими візуалізаціями. Checkmate регулярно перевіряє, чи доступний сервер або сайт і чи працює він оптимально, надаючи сповіщення та звіти у реальному часі про доступність, простої та час відгуку моніторованих сервісів. + +У Checkmate також є агент під назвою [Capture](https://github.com/bluewave-labs/capture) для отримання даних з віддалених серверів. Capture не є обов'язковим для роботи Checkmate, але дає додаткову інформацію про стан CPU, RAM, диска та температури ваших серверів. Capture можна запускати на Linux, Windows, Mac, Raspberry Pi або будь-якому пристрої, здатному виконувати Go. + +Checkmate пройшов навантажувальне тестування з понад 1000 активних моніторів без жодних помітних проблем чи вузьких місць. + +## 📚 Зміст + +- [📦 Демо](#demo) +- [🔗 Посібник користувача](#users-guide) +- [🛠️ Встановлення](#installation) +- [🚀 Продуктивність](#performance) +- [💚 Запитання та ідеї](#questions--ideas) +- [🧩 Можливості](#features) +- [🏗️ Знімки екрана](#screenshots) +- [🏗️ Технології](#tech-stack) +- [🔗 Кілька посилань](#a-few-links) +- [🤝 Внески](#contributing) + + + +## Демо + +Найновішу збірку [Checkmate](https://demo.checkmate.so/) можна побачити в дії. + +Логін — demouser@demo.com, пароль — Demouser1! (Зверніть увагу: ми час від часу оновлюємо демо-сервер; якщо щось не працює — напишіть нам у Discussions). + + +## Посібник користувача + +Інструкції з використання доступні [тут](https://checkmate.so/docs). + +## Передумови +- Встановлений [Docker](https://www.docker.com/) +- Встановлений [Git](https://git-scm.com/) + + +## Встановлення + +Інструкції зі встановлення дивіться у [порталі документації Checkmate](https://checkmate.so/docs). + +Альтернативно, для швидкого розгортання Checkmate можна скористатися [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (Південна Африка), [Cloudzy](https://cloudzy.com/marketplace/checkmate) або [Pikapods](https://www.pikapods.com/). Якщо хочете моніторити серверну інфраструктуру, знадобиться агент [Capture](https://github.com/bluewave-labs/capture). У репозиторії Capture також є інструкції зі встановлення. + +### Використання власного УЦ + +Якщо потрібно моніторити внутрішні HTTPS-ендпоїнти з сертифікатами від приватних центрів сертифікації (як-от Smallstep), див. наш [посібник з довіри власному УЦ](../custom-ca-trust.md) для опцій конфігурації Docker. + +Додаткову документацію шукайте у [каталозі docs](../). + + +## Продуктивність + +Завдяки численним оптимізаціям Checkmate працює з винятково низьким споживанням пам'яті, потребуючи мінімум RAM і CPU. Ось використання пам'яті Node.js-інстансу на сервері, який моніторить 323 сервери щохвилини: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +А ось обсяг пам'яті MongoDB і Redis на тому ж сервері (398 МБ і 15 МБ) для тієї ж кількості серверів: + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## Запитання та ідеї + +Якщо у вас є запитання, пропозиції чи коментарі, є кілька варіантів: + +- [Канал у Discord](https://discord.gg/NAb6H3UTjK) (бажано) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (заходимо туди час від часу) + +Не соромтеся ставити запитання чи ділитися ідеями — ми будемо раді почути вас! + + +## Можливості + +- Повністю відкритий код, розгортається на ваших серверах чи домашніх пристроях (наприклад, Raspberry Pi 4 або 5) +- Кілька варіантів моніторингу: доступність, Docker, ping, SSL, порт, ігровий сервер +- Моніторинг швидкості сторінок +- Моніторинг інфраструктури (пам'ять, використання диска, продуктивність CPU, мережа тощо) — потрібен агент [Capture](https://github.com/bluewave-labs/capture) + - Вибірковий моніторинг дисків із вибором точок монтування +- Інциденти з одного погляду +- Сторінки статусу з 4 красивими темами +- Сповіщення електронною поштою, через вебхуки, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) +- Заплановане обслуговування +- Моніторинг за допомогою JSON-запитів +- Багатомовна підтримка: арабська, китайська (спрощена), китайська (традиційна, Тайвань), чеська, англійська, фінська, французька, німецька, японська, португальська (Бразилія), російська, іспанська, тайська, турецька, українська та в'єтнамська + + +## Життєвий цикл монітора + +1. Монітор виконує перевірку (HTTP / ping / порт / апаратне забезпечення через агента Capture) +2. Результат зберігається (успіх/невдача + час відгуку) +3. Останні результати оцінюються відносно налаштованого порога зміни статусу +4. Якщо порогу досягнуто, а поточний статус відрізняється від попереднього — стан монітора змінюється (наприклад, `initializing`, `up`, `down`, `breached`) +5. При зміні стану інцидент або створюється, або закривається, залежно від поточного статусу +6. Сповіщення спрацьовують згідно з налаштуваннями + + +## Знімки екрана + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## Технології + +- [ReactJs](https://react.dev/) +- [MUI (React-фреймворк)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- І безліч інших компонентів з відкритим кодом! + + +## Кілька посилань + +- Якщо хочете підтримати нас, поставте ⭐ і натисніть «watch». +- Маєте запитання чи пропозицію щодо дорожньої карти/функцій? Загляньте в наш [Discord-канал](https://discord.gg/NAb6H3UTjK) або форум [Discussions](https://github.com/bluewave-labs/checkmate/discussions). +- Потрібне сповіщення про нові релізи? Скористайтеся [Newreleases](https://newreleases.io/) — безкоштовним сервісом відстеження релізів. +- Подивіться [відео зі встановлення та використання Checkmate](https://www.youtube.com/watch?v=GfFOc0xHIwY) + + +## Внески + +Ми — [Alex](http://github.com/ajhollid) (тімлід), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) і [Karen](https://github.com/karenvicent) — допомагаємо приватним особам і компаніям моніторити їх інфраструктуру та сервери. + +Ми пишаємося тим, що будуємо міцні зв'язки з контриб'юторами на всіх рівнях. Попри молодий вік, Checkmate уже зібрав понад 7000 зірок і понад 90 контриб'юторів з усього світу. + +Наш репозиторій позначений співробітниками **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes і NEC** — тож не соромтеся, приєднуйтесь, робіть внесок і вчіться разом з нами! + +Як долучитися: + +0. Поставте зірку цьому репозиторію :) +1. Прочитайте [посібник контриб'ютора](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). Початківцям радимо подивитися мітку `good-first-issue`. +2. Прочитайте детальний опис [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate), якщо хочете глибше зануритись у архітектуру. +3. Відкрийте issue, якщо вважаєте, що знайшли баг. +4. Якщо ви новачок, шукайте `good-first-issue`. +5. Робіть pull request, щоб додати функції, покращити зручність використання або виправити баги. +6. Перегляньте інтерактивний огляд кодової бази `Checkmate` у CodeCanvas [тут](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). Щоб уточнити наявні симуляції потоків даних або створити нові, перейдіть до короткого туторіалу [тут](https://docs.code-canvas.com/updating-diagram). + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.vi.md b/docs/translations/README.vi.md new file mode 100644 index 000000000..13369f3d8 --- /dev/null +++ b/docs/translations/README.vi.md @@ -0,0 +1,200 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +> ⚠️ Bản dịch này là bản nháp được dịch bằng máy. Rất hoan nghênh sự đóng góp chỉnh sửa từ người bản ngữ qua pull request. + + +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

Ứng dụng mã nguồn mở giám sát tình trạng hoạt động và hạ tầng

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +Kho mã nguồn này chứa cả frontend và backend của Checkmate — một công cụ giám sát mã nguồn mở, tự lưu trữ, theo dõi phần cứng máy chủ, thời gian hoạt động, thời gian phản hồi và sự cố theo thời gian thực với các biểu đồ trực quan đẹp mắt. Checkmate định kỳ kiểm tra xem một máy chủ/trang web có khả truy cập và hoạt động tối ưu hay không, đồng thời cung cấp cảnh báo và báo cáo theo thời gian thực về độ khả dụng, thời gian gián đoạn và thời gian phản hồi của các dịch vụ được giám sát. + +Checkmate cũng có một agent tên là [Capture](https://github.com/bluewave-labs/capture) dùng để lấy dữ liệu từ các máy chủ từ xa. Capture không bắt buộc để chạy Checkmate, nhưng cung cấp thêm thông tin về CPU, RAM, ổ đĩa và nhiệt độ của máy chủ. Capture có thể chạy trên Linux, Windows, Mac, Raspberry Pi hoặc bất kỳ thiết bị nào có thể chạy Go. + +Checkmate đã được kiểm thử với hơn 1000 monitor đang hoạt động mà không gặp bất kỳ vấn đề nào hay điểm nghẽn hiệu năng đáng kể. + +## 📚 Mục lục + +- [📦 Demo](#demo) +- [🔗 Hướng dẫn sử dụng](#users-guide) +- [🛠️ Cài đặt](#installation) +- [🚀 Hiệu năng](#performance) +- [💚 Câu hỏi & ý tưởng](#questions--ideas) +- [🧩 Tính năng](#features) +- [🏗️ Ảnh chụp màn hình](#screenshots) +- [🏗️ Tech stack](#tech-stack) +- [🔗 Một vài liên kết](#a-few-links) +- [🤝 Đóng góp](#contributing) + + + +## Demo + +Bạn có thể xem bản dựng mới nhất của [Checkmate](https://demo.checkmate.so/) đang chạy. + +Tên đăng nhập là demouser@demo.com và mật khẩu là Demouser1! (lưu ý: chúng tôi cập nhật máy chủ demo theo thời gian; nếu không hoạt động, hãy báo cho chúng tôi qua kênh Discussions). + + +## Hướng dẫn sử dụng + +Có thể tìm thấy hướng dẫn sử dụng [tại đây](https://checkmate.so/docs). + +## Yêu cầu trước +- Đã cài [Docker](https://www.docker.com/) +- Đã cài [Git](https://git-scm.com/) + + +## Cài đặt + +Xem hướng dẫn cài đặt trong [cổng tài liệu của Checkmate](https://checkmate.so/docs). + +Hoặc bạn có thể dùng [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (Nam Phi), [Cloudzy](https://cloudzy.com/marketplace/checkmate) hoặc [Pikapods](https://www.pikapods.com/) để dựng nhanh một instance Checkmate. Nếu muốn giám sát hạ tầng máy chủ, bạn sẽ cần [agent Capture](https://github.com/bluewave-labs/capture). Kho Capture cũng chứa hướng dẫn cài đặt. + +### Sử dụng CA tuỳ chỉnh + +Nếu bạn cần giám sát các endpoint HTTPS nội bộ với chứng chỉ từ các tổ chức phát hành riêng (ví dụ Smallstep), hãy xem [Hướng dẫn tin cậy CA tuỳ chỉnh](../custom-ca-trust.md) với các tuỳ chọn cấu hình Docker. + +Để biết thêm tài liệu, xem [thư mục docs](../). + + +## Hiệu năng + +Nhờ tối ưu rộng rãi, Checkmate hoạt động với mức sử dụng bộ nhớ rất nhỏ, chỉ cần lượng RAM và CPU tối thiểu. Dưới đây là mức sử dụng bộ nhớ của một instance Node.js trên máy chủ giám sát 323 máy chủ mỗi phút: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +Bạn cũng có thể thấy dung lượng bộ nhớ của MongoDB và Redis trên cùng máy chủ (398MB và 15MB) cho cùng số lượng máy chủ: + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## Câu hỏi & ý tưởng + +Nếu bạn có câu hỏi, góp ý hoặc bình luận, có một vài tuỳ chọn: + +- [Kênh Discord](https://discord.gg/NAb6H3UTjK) (khuyến nghị) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (chúng tôi sẽ ghé qua đều đặn) + +Đừng ngần ngại hỏi hoặc chia sẻ ý tưởng — chúng tôi rất mong nghe ý kiến của bạn! + + +## Tính năng + +- Hoàn toàn mã nguồn mở, có thể triển khai trên máy chủ riêng hoặc thiết bị tại nhà (ví dụ Raspberry Pi 4 hoặc 5) +- Nhiều tuỳ chọn giám sát: thời gian hoạt động, Docker, Ping, SSL, cổng, máy chủ game +- Giám sát tốc độ tải trang +- Giám sát hạ tầng (bộ nhớ, ổ đĩa, hiệu năng CPU, mạng, v.v.) — yêu cầu agent [Capture](https://github.com/bluewave-labs/capture) + - Giám sát ổ đĩa có chọn lọc với lựa chọn điểm gắn kết +- Quản lý sự cố một cái nhìn +- Trang trạng thái với 4 theme đẹp mắt +- Thông báo qua e-mail, webhook, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) +- Bảo trì theo lịch +- Giám sát qua truy vấn JSON +- Hỗ trợ đa ngôn ngữ: Ả Rập, Trung Quốc (giản thể), Trung Quốc (phồn thể, Đài Loan), Séc, Anh, Phần Lan, Pháp, Đức, Nhật, Bồ Đào Nha (Brazil), Nga, Tây Ban Nha, Thái, Thổ Nhĩ Kỳ, Ukraine và Việt Nam + + +## Vòng đời của một monitor + +1. Một monitor thực hiện kiểm tra (HTTP / Ping / cổng / phần cứng qua agent Capture) +2. Kết quả được lưu lại (thành công/thất bại + thời gian phản hồi) +3. Các kết quả gần đây được đánh giá dựa trên ngưỡng thay đổi trạng thái đã cấu hình +4. Nếu đạt ngưỡng và trạng thái hiện tại khác trạng thái trước đó, trạng thái monitor sẽ thay đổi (ví dụ `initializing`, `up`, `down`, `breached`) +5. Khi trạng thái thay đổi, một sự cố sẽ được tạo hoặc giải quyết tuỳ vào trạng thái hiện tại +6. Thông báo được kích hoạt theo cấu hình + + +## Ảnh chụp màn hình + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## Tech stack + +- [ReactJs](https://react.dev/) +- [MUI (framework React)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- Và rất nhiều thành phần mã nguồn mở khác! + + +## Một vài liên kết + +- Nếu bạn muốn ủng hộ chúng tôi, hãy cân nhắc cho dự án một ⭐ và nhấn "watch". +- Có câu hỏi hoặc gợi ý cho roadmap/tính năng? Hãy ghé [kênh Discord](https://discord.gg/NAb6H3UTjK) hoặc diễn đàn [Discussions](https://github.com/bluewave-labs/checkmate/discussions). +- Muốn nhận thông báo khi có bản phát hành mới? Hãy dùng [Newreleases](https://newreleases.io/) — dịch vụ miễn phí theo dõi phiên bản phát hành. +- Xem video [cài đặt và sử dụng Checkmate](https://www.youtube.com/watch?v=GfFOc0xHIwY) + + +## Đóng góp + +Chúng tôi là [Alex](http://github.com/ajhollid) (trưởng nhóm), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) và [Karen](https://github.com/karenvicent), hỗ trợ cá nhân và doanh nghiệp giám sát hạ tầng và máy chủ. + +Chúng tôi tự hào xây dựng những mối liên kết bền chặt với người đóng góp ở mọi cấp độ. Dù còn trẻ, Checkmate đã có hơn 7000 sao và thu hút hơn 90 người đóng góp trên khắp thế giới. + +Kho của chúng tôi đã được nhân viên của **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes và NEC** đánh sao — đừng ngần ngại, hãy tham gia, đóng góp và học hỏi cùng chúng tôi! + +Cách bạn có thể đóng góp: + +0. Đánh sao cho repo này :) +1. Đọc [hướng dẫn dành cho người đóng góp](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). Người mới được khuyến khích kiểm tra tag `good-first-issue`. +2. Nếu muốn đi sâu vào kiến trúc, đọc [cấu trúc chi tiết của Checkmate](https://deepwiki.com/bluewave-labs/Checkmate). +3. Mở issue nếu bạn cho rằng đã gặp lỗi. +4. Nếu mới tham gia, hãy tìm các issue gắn nhãn `good-first-issue`. +5. Tạo pull request để thêm tính năng mới, cải thiện trải nghiệm hoặc sửa lỗi. +6. Xem qua bản hướng dẫn tương tác về codebase `Checkmate` trên CodeCanvas [tại đây](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). Để hoàn thiện mô phỏng luồng dữ liệu hiện có hoặc tạo mới, xem hướng dẫn nhanh [tại đây](https://docs.code-canvas.com/updating-diagram). + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.zh-CN.md b/docs/translations/README.zh-CN.md new file mode 100644 index 000000000..75ae7dc86 --- /dev/null +++ b/docs/translations/README.zh-CN.md @@ -0,0 +1,197 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

开源的可用性与基础设施监控应用

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +本仓库同时包含 Checkmate 的前端和后端代码。Checkmate 是一款开源、可自托管的监控工具,通过精美的可视化效果实时追踪服务器硬件、可用性、响应时间以及事件。Checkmate 会定期检查服务器或网站是否可访问、运行是否正常,并针对被监控服务的可用性、停机时间和响应时间提供实时告警与报告。 + +Checkmate 还提供了一个名为 [Capture](https://github.com/bluewave-labs/capture) 的代理,用于从远程服务器拉取数据。Capture 并不是运行 Checkmate 所必需的,但它能提供服务器 CPU、内存、磁盘和温度的额外信息。Capture 可在 Linux、Windows、Mac、Raspberry Pi 或任何能运行 Go 的设备上运行。 + +Checkmate 已经在 1000+ 个活跃监控项的环境下完成压力测试,未出现任何明显问题或性能瓶颈。 + +## 📚 目录 + +- [📦 演示](#demo) +- [🔗 用户指南](#users-guide) +- [🛠️ 安装](#installation) +- [🚀 性能](#performance) +- [💚 问题与想法](#questions--ideas) +- [🧩 功能](#features) +- [🏗️ 截图](#screenshots) +- [🏗️ 技术栈](#tech-stack) +- [🔗 一些链接](#a-few-links) +- [🤝 参与贡献](#contributing) + + + +## 演示 + +你可以体验最新版本的 [Checkmate](https://demo.checkmate.so/)。 + +用户名是 demouser@demo.com,密码是 Demouser1!(请注意,我们会不定期更新演示服务器,如果遇到问题,请在 Discussions 频道告诉我们)。 + + +## 用户指南 + +使用说明请参见[这里](https://checkmate.so/docs)。 + +## 前置要求 +- 已安装 [Docker](https://www.docker.com/) +- 已安装 [Git](https://git-scm.com/) + + +## 安装 + +请参阅 [Checkmate 文档门户](https://checkmate.so/docs)中的安装说明。 + +你也可以使用 [Coolify](https://coolify.io/)、[Elestio](https://elest.io/open-source/checkmate)、[K8s](./charts/helm/checkmate/INSTALLATION.md)、[Sive Host](https://sive.host)(南非)、[Cloudzy](https://cloudzy.com/marketplace/checkmate) 或 [Pikapods](https://www.pikapods.com/) 快速启动一个 Checkmate 实例。如果想监控服务器基础设施,你需要 [Capture 代理](https://github.com/bluewave-labs/capture),Capture 仓库中也提供了安装说明。 + +### 使用自定义 CA + +如果你需要使用私有 CA(例如 Smallstep)签发的证书来监控内部 HTTPS 端点,请查看我们的 [自定义 CA 信任指南](../custom-ca-trust.md),其中介绍了 Docker 的配置选项。 + +更多文档请参见 [docs 目录](../)。 + + +## 性能 + +得益于大量优化,Checkmate 在运行时占用的内存非常少,对内存和 CPU 资源的需求极低。下方是一个每分钟监控 323 台服务器的 Node.js 实例的内存使用情况: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +同一台服务器、相同监控数量下的 MongoDB 与 Redis 的内存占用(分别为 398MB 与 15MB): + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## 问题与想法 + +如果你有任何问题、建议或意见,欢迎通过以下方式联系我们: + +- [Discord 频道](https://discord.gg/NAb6H3UTjK)(推荐) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions)(我们会不定期查看) + +欢迎提问或分享你的想法 — 期待听到你的声音! + + +## 功能 + +- 完全开源,可部署到自己的服务器或家用设备(如 Raspberry Pi 4/5) +- 多种监控选项: 可用性、Docker、Ping、SSL、端口、游戏服务器 +- 页面速度监控 +- 基础设施监控(内存、磁盘使用情况、CPU 性能、网络等) — 需要 [Capture](https://github.com/bluewave-labs/capture) 代理 + - 通过挂载点选择实现的选择性磁盘监控 +- 一目了然的事件视图 +- 提供 4 套精美主题的状态页 +- 通过邮件、Webhook、Discord、Slack、PagerDuty、Matrix、Microsoft Teams、Telegram、Pushover、Twilio (SMS) 发送通知 +- 计划维护 +- JSON 查询监控 +- 多语言支持: 阿拉伯语、简体中文、繁体中文(台湾)、捷克语、英语、芬兰语、法语、德语、日语、葡萄牙语(巴西)、俄语、西班牙语、泰语、土耳其语、乌克兰语和越南语 + + +## 监控生命周期 + +1. 监控项执行一次检查(HTTP / Ping / 端口 / 通过 Capture 代理的硬件检查) +2. 结果被保存(成功/失败 + 响应时间) +3. 近期的检查结果会根据该监控项配置的状态变更阈值进行评估 +4. 当达到状态变更阈值且当前状态与上一状态不同时,监控项状态发生变化(例如 `initializing`、`up`、`down`、`breached`) +5. 状态发生变化时,会根据监控项当前状态创建或解决一个事件 +6. 根据配置触发通知 + + +## 截图 + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## 技术栈 + +- [ReactJs](https://react.dev/) +- [MUI (React 框架)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- 以及大量其他开源组件! + + +## 一些链接 + +- 如果你想支持我们,请考虑给项目点 ⭐ 并点击 “watch”。 +- 对路线图/功能集有问题或建议?欢迎加入我们的 [Discord 频道](https://discord.gg/NAb6H3UTjK) 或 [Discussions](https://github.com/bluewave-labs/checkmate/discussions) 论坛。 +- 想在新版本发布时收到通知?可以使用免费的版本追踪服务 [Newreleases](https://newreleases.io/)。 +- 观看 Checkmate 的 [安装与使用视频](https://www.youtube.com/watch?v=GfFOc0xHIwY)。 + + +## 参与贡献 + +我们是 [Alex](http://github.com/ajhollid)(团队负责人)、[Gorkem](http://github.com/gorkem-bwl/)、[Aryaman](https://github.com/Br0wnHammer)、[Mert](https://github.com/mertssmnoglu) 和 [Karen](https://github.com/karenvicent),致力于帮助个人和企业监控他们的基础设施与服务器。 + +我们以与各个层次的贡献者建立稳固关系为荣。尽管 Checkmate 还是一个年轻的项目,但它已经获得超过 7000 颗星,并吸引了来自全球的 90+ 位贡献者。 + +我们的仓库已经被来自 **Google、Microsoft、Intel、Cisco、Tencent、Electronic Arts、ByteDance、JP Morgan Chase、Deloitte、Accenture、Foxconn、Broadcom、China Telecom、Barclays、Capgemini、Wipro、Cloudflare、Dassault Systèmes 和 NEC** 的员工点亮 — 所以不要犹豫,欢迎加入我们,一起贡献和成长! + +参与贡献的方式: + +0. 给本仓库点亮 ⭐ :) +1. 阅读 [贡献者指南](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md)。新手建议先查看 `good-first-issue` 标签。 +2. 如果想深入了解架构,请阅读 [Checkmate 的详细结构介绍](https://deepwiki.com/bluewave-labs/Checkmate)。 +3. 如果你认为遇到了 bug,请提交一个 issue。 +4. 如果你是新手,建议从 `good-first-issue` 入手。 +5. 提交 pull request 来添加新功能、改进体验或修复 bug。 +6. 通过 CodeCanvas 上的 `Checkmate` 代码交互式演示了解项目([点这里](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true))。如要完善已有数据流仿真或创建新的仿真,请参考 [这里](https://docs.code-canvas.com/updating-diagram) 的快速教程。 + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/docs/translations/README.zh-TW.md b/docs/translations/README.zh-TW.md new file mode 100644 index 000000000..8dfc605ec --- /dev/null +++ b/docs/translations/README.zh-TW.md @@ -0,0 +1,197 @@ +

bluewave-labs%2Fcheckmate | Trendshift

+ +

+ 🇺🇸 English | + 🇸🇦 العربية | + 🇪🇸 Català | + 🇨🇿 Čeština | + 🇩🇪 Deutsch | + 🇪🇸 Español | + 🇫🇮 Suomi | + 🇫🇷 Français | + 🇯🇵 日本語 | + 🇧🇷 Português (Brasil) | + 🇷🇺 Русский | + 🇹🇭 ไทย | + 🇹🇷 Türkçe | + 🇺🇦 Українська | + 🇻🇳 Tiếng Việt | + 🇨🇳 简体中文 | + 🇹🇼 繁體中文 +

+ +![](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) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bluewave-labs/checkmate) + +

Checkmate

+ +

開源的可用性與基礎架構監控應用程式

+ +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +image + +
+ + +本儲存庫同時包含 Checkmate 的前端與後端程式碼。Checkmate 是一款開源、可自我託管的監控工具,透過美觀的視覺化即時追蹤伺服器硬體、運作時間、回應時間以及事件。Checkmate 會定期檢查伺服器或網站是否可存取且運作正常,並針對被監控服務的可用性、停機時間和回應時間提供即時警報與報告。 + +Checkmate 還提供一個名為 [Capture](https://github.com/bluewave-labs/capture) 的代理程式,用以從遠端伺服器擷取資料。Capture 並非執行 Checkmate 的必要元件,但能提供伺服器 CPU、記憶體、磁碟與溫度的額外資訊。Capture 可在 Linux、Windows、Mac、Raspberry Pi 或任何能執行 Go 的裝置上運行。 + +Checkmate 已在 1000+ 個活躍監控項目下進行過壓力測試,未發生任何明顯問題或效能瓶頸。 + +## 📚 目錄 + +- [📦 展示](#demo) +- [🔗 使用者指南](#users-guide) +- [🛠️ 安裝](#installation) +- [🚀 效能](#performance) +- [💚 問題與想法](#questions--ideas) +- [🧩 功能](#features) +- [🏗️ 螢幕截圖](#screenshots) +- [🏗️ 技術堆疊](#tech-stack) +- [🔗 一些連結](#a-few-links) +- [🤝 參與貢獻](#contributing) + + + +## 展示 + +你可以體驗最新版本的 [Checkmate](https://demo.checkmate.so/)。 + +使用者名稱為 demouser@demo.com,密碼為 Demouser1!(我們會不定期更新示範伺服器,若無法使用,請於 Discussions 頻道告知我們)。 + + +## 使用者指南 + +使用說明請見[這裡](https://checkmate.so/docs)。 + +## 先決條件 +- 已安裝 [Docker](https://www.docker.com/) +- 已安裝 [Git](https://git-scm.com/) + + +## 安裝 + +安裝說明請參閱 [Checkmate 文件入口](https://checkmate.so/docs)。 + +你也可以使用 [Coolify](https://coolify.io/)、[Elestio](https://elest.io/open-source/checkmate)、[K8s](./charts/helm/checkmate/INSTALLATION.md)、[Sive Host](https://sive.host)(南非)、[Cloudzy](https://cloudzy.com/marketplace/checkmate) 或 [Pikapods](https://www.pikapods.com/) 來快速啟動一個 Checkmate 實例。若要監控伺服器基礎架構,需要 [Capture 代理程式](https://github.com/bluewave-labs/capture),Capture 儲存庫中也提供了安裝說明。 + +### 使用自訂 CA + +如果你需要使用私有 CA(例如 Smallstep)發行的憑證來監控內部 HTTPS 端點,請參考我們的 [自訂 CA 信任指南](../custom-ca-trust.md),其中介紹了 Docker 的設定選項。 + +更多文件請參閱 [docs 目錄](../)。 + + +## 效能 + +由於進行了大量最佳化,Checkmate 的記憶體佔用極小,所需記憶體與 CPU 資源也非常少。以下是每分鐘監控 323 台伺服器的 Node.js 實例的記憶體使用情況: + +![image](https://github.com/user-attachments/assets/37e04a75-d83a-488f-b25c-025511b492c9) + +同一台伺服器、相同監控數量下,MongoDB 與 Redis 的記憶體佔用(分別為 398MB 與 15MB): + +![image](https://github.com/user-attachments/assets/3b469e85-e675-4040-a162-3f24c1afc751) + + +## 問題與想法 + +若有任何問題、建議或意見,歡迎透過以下方式聯繫我們: + +- [Discord 頻道](https://discord.gg/NAb6H3UTjK)(建議使用) +- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions)(我們會不定期查看) + +歡迎隨時提問或分享你的想法 — 我們很期待聽到你的聲音! + + +## 功能 + +- 完全開源,可部署到自己的伺服器或家用裝置(例如 Raspberry Pi 4 / 5) +- 多種監控選項:可用性、Docker、Ping、SSL、連接埠、遊戲伺服器 +- 頁面速度監控 +- 基礎架構監控(記憶體、磁碟使用量、CPU 效能、網路等) — 需要 [Capture](https://github.com/bluewave-labs/capture) 代理程式 + - 透過掛載點選擇進行的選擇性磁碟監控 +- 一目了然的事件檢視 +- 提供 4 套精美主題的狀態頁 +- 透過電子郵件、Webhook、Discord、Slack、PagerDuty、Matrix、Microsoft Teams、Telegram、Pushover、Twilio (SMS) 傳送通知 +- 排程維護 +- JSON 查詢監控 +- 多語言支援:阿拉伯文、簡體中文、繁體中文(台灣)、捷克文、英文、芬蘭文、法文、德文、日文、葡萄牙文(巴西)、俄文、西班牙文、泰文、土耳其文、烏克蘭文與越南文 + + +## 監控項目生命週期 + +1. 監控項目執行一次檢查(HTTP / Ping / 連接埠 / 透過 Capture 代理的硬體檢查) +2. 結果會被儲存(成功/失敗 + 回應時間) +3. 近期的檢查結果會依該監控項目所設定的狀態變更門檻進行評估 +4. 當達到狀態變更門檻且目前狀態與上一次狀態不同時,監控項目的狀態會改變(例如 `initializing`、`up`、`down`、`breached`) +5. 狀態變更時,會根據監控項目目前的狀態建立或解決一個事件 +6. 通知會依照設定觸發 + + +## 螢幕截圖 + +

+image +

+

+ image +

+

+

image +

+

+image +

+ + + + +## 技術堆疊 + +- [ReactJs](https://react.dev/) +- [MUI (React 框架)](https://mui.com/) +- [Node.js](https://nodejs.org/en) +- [MongoDB](https://mongodb.com) +- [Recharts](https://recharts.org) +- 以及許多其他開源元件! + + +## 一些連結 + +- 如果你想支持我們,請考慮給專案點 ⭐ 並點選 "watch"。 +- 對藍圖或功能集有問題或建議嗎?歡迎加入我們的 [Discord 頻道](https://discord.gg/NAb6H3UTjK) 或 [Discussions](https://github.com/bluewave-labs/checkmate/discussions) 討論區。 +- 想在新版本釋出時收到通知?可以使用免費的版本追蹤服務 [Newreleases](https://newreleases.io/)。 +- 觀看 Checkmate 的 [安裝與使用影片](https://www.youtube.com/watch?v=GfFOc0xHIwY)。 + + +## 參與貢獻 + +我們是 [Alex](http://github.com/ajhollid)(團隊負責人)、[Gorkem](http://github.com/gorkem-bwl/)、[Aryaman](https://github.com/Br0wnHammer)、[Mert](https://github.com/mertssmnoglu) 和 [Karen](https://github.com/karenvicent),致力於協助個人與企業監控其基礎架構與伺服器。 + +我們以與各層級貢獻者建立穩固關係為榮。儘管 Checkmate 還是一個年輕的專案,但已獲得超過 7000 顆星,並吸引了來自全球的 90+ 位貢獻者。 + +我們的儲存庫已被來自 **Google、Microsoft、Intel、Cisco、Tencent、Electronic Arts、ByteDance、JP Morgan Chase、Deloitte、Accenture、Foxconn、Broadcom、China Telecom、Barclays、Capgemini、Wipro、Cloudflare、Dassault Systèmes 與 NEC** 的員工點亮 — 所以請不要猶豫,加入我們,一起貢獻、一起學習吧! + +如何貢獻: + +0. 給本儲存庫點亮 ⭐ :) +1. 閱讀 [貢獻者指南](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md)。新手建議先查看 `good-first-issue` 標籤。 +2. 如果想深入了解架構,請閱讀 [Checkmate 的詳細結構介紹](https://deepwiki.com/bluewave-labs/Checkmate)。 +3. 若你認為遇到了 bug,請開啟一個 issue。 +4. 如果你是新手,建議從 `good-first-issue` 開始。 +5. 提交 pull request 來新增功能、改善體驗或修正 bug。 +6. 透過 CodeCanvas 上的 `Checkmate` 程式碼互動導覽(請點 [這裡](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true))。若要精修現有的資料流模擬或建立新的模擬,請依 [此處](https://docs.code-canvas.com/updating-diagram) 的快速教學操作。 + + + + + +[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) diff --git a/server/.env.example b/server/.env.example index 20c579f5c..92798b56b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -11,6 +11,7 @@ LOG_LEVEL=debug # Database # -------- DB_CONNECTION_STRING=mongodb://localhost:27017/uptime_db +QUEUE_TYPE=superSimpleQueue # JWT Authentication # ------------------ diff --git a/server/.prettierrc b/server/.prettierrc index 681669aa7..932d6330b 100755 --- a/server/.prettierrc +++ b/server/.prettierrc @@ -12,5 +12,14 @@ "quoteProps": "as-needed", "arrowParens": "always", "trailingComma": "es5", - "htmlWhitespaceSensitivity": "css" + "htmlWhitespaceSensitivity": "css", + "plugins": ["prettier-plugin-ini"], + "overrides": [ + { + "files": [".env", ".env.*"], + "options": { + "parser": "ini" + } + } + ] } diff --git a/server/jest.config.ts b/server/jest.config.ts index be7d359b1..1faf98181 100644 --- a/server/jest.config.ts +++ b/server/jest.config.ts @@ -13,6 +13,7 @@ const config: Config = { "^@/(.*)\\.ts$": "/src/$1.ts", "^@/(.*)\\.js$": "/src/$1.ts", "^@/(.*)$": "/src/$1", + "^(\\.{1,2}/.*)\\.js$": "$1", }, testMatch: ["/test/**/*.test.ts"], setupFilesAfterEnv: [], diff --git a/server/openapi.json b/server/openapi.json index cfb1c5aa4..2a5abd6e0 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -1,125 +1,1187 @@ { - "openapi": "3.1.0", + "openapi": "3.0.0", "info": { "title": "Checkmate API", - "summary": "Checkmate OpenAPI Specifications", - "description": "Checkmate is an open source monitoring tool used to track the operational status and performance of servers and websites. It regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.", - "contact": { - "name": "API Support", - "url": "mailto:support@bluewavelabs.ca", - "email": "support@bluewavelabs.ca" - }, - "license": { - "name": "AGPLv3", - "url": "https://github.com/bluewave-labs/checkmate/tree/HEAD/LICENSE" - }, - "version": "2.3" + "version": "1.0.0", + "description": "Generated from Zod validation schemas. Source of truth: server/src/validation/*.ts" }, "servers": [ { - "url": "http://localhost:{PORT}/{API_PATH}", - "description": "Local Development Server", - "variables": { - "PORT": { - "default": "52345" - }, - "API_PATH": { - "default": "api/v1" - } - } - }, - { - "url": "https://checkmate-demo.bluewavelabs.ca/{API_PATH}", - "description": "Checkmate Demo Server", - "variables": { - "API_PATH": { - "default": "api/v1" - } - } + "url": "/api/v1", + "description": "Current server" } ], "tags": [ { "name": "auth", - "description": "Authentication and user management" - }, - { - "name": "invite", - "description": "Team invitation management" + "description": "Sign up, log in, password reset, and the authenticated user's profile." }, { "name": "monitors", - "description": "Monitor management (uptime, page speed, hardware)" + "description": "Configure and inspect monitors (HTTP, port, ping, hardware, docker, game, gRPC) for the caller's team." }, { "name": "checks", - "description": "Monitor check results and history" + "description": "Individual monitoring check results, including history, summaries, and bulk deletes." }, { - "name": "maintenance-window", - "description": "Scheduled maintenance windows" - }, - { - "name": "queue", - "description": "Job queue management" - }, - { - "name": "status-page", - "description": "Public status page management" - }, - { - "name": "settings", - "description": "Application configuration settings" - }, - { - "name": "logs", - "description": "Application logs and diagnostics" - }, - { - "name": "notifications", - "description": "Notification integrations (email, slack, discord, etc.)" - }, - { - "name": "diagnostic", - "description": "System health and performance diagnostics" + "name": "geo-checks", + "description": "Geographic check results for monitors with multi-region probing enabled." }, { "name": "incidents", - "description": "Incident management and resolution" + "description": "Downtime incidents derived from monitor state changes, including filtering and CSV export." + }, + { + "name": "notifications", + "description": "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts." + }, + { + "name": "maintenance-window", + "description": "Scheduled maintenance windows during which monitors do not generate incidents or alerts." + }, + { + "name": "status-page", + "description": "Public status pages, their configuration, and the monitors they expose." + }, + { + "name": "settings", + "description": "Global application settings (admin/superadmin)." + }, + { + "name": "invite", + "description": "Team invitations and accepting them." + }, + { + "name": "queue", + "description": "Background job queue introspection and admin actions (admin/superadmin)." + }, + { + "name": "diagnostic", + "description": "System diagnostics for the running server (admin/superadmin)." + }, + { + "name": "logs", + "description": "Application logs for the running server (admin/superadmin)." } ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "AuthPayload": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/User" + }, + "token": { + "type": "string" + } + }, + "required": ["user"], + "example": { + "user": { + "_id": "65f1c2a4d8b9e0123456789a", + "firstName": "Ada", + "lastName": "Lovelace", + "email": "ada@example.com", + "role": ["admin"], + "teamId": "65f1c2a4d8b9e01234567890", + "createdAt": "2026-04-01T10:00:00.000Z", + "updatedAt": "2026-04-15T14:30:00.000Z" + }, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "User": { + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "role": { + "type": "array", + "items": { + "type": "string" + } + }, + "teamId": { + "type": "string" + }, + "profileImage": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["_id", "firstName", "lastName", "email", "role", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + }, + "example": { + "_id": "65f1c2a4d8b9e0123456789a", + "firstName": "Ada", + "lastName": "Lovelace", + "email": "ada@example.com", + "role": ["admin"], + "teamId": "65f1c2a4d8b9e01234567890", + "createdAt": "2026-04-01T10:00:00.000Z", + "updatedAt": "2026-04-15T14:30:00.000Z" + } + }, + "Incident": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "65f1c2a4d8b9e0123456789c" + }, + "monitorId": { + "type": "string", + "example": "65f1c2a4d8b9e0123456789a" + }, + "teamId": { + "type": "string", + "example": "65f1c2a4d8b9e01234567890" + }, + "startTime": { + "type": "string", + "example": "2026-04-15T03:21:00.000Z" + }, + "endTime": { + "type": "string", + "nullable": true, + "example": "2026-04-15T03:34:00.000Z" + }, + "status": { + "type": "boolean", + "example": true, + "description": "true = resolved, false = ongoing" + }, + "message": { + "type": "string", + "nullable": true, + "example": "HTTP 503 from origin" + }, + "statusCode": { + "type": "number", + "nullable": true, + "example": 503 + }, + "resolutionType": { + "type": "string", + "nullable": true, + "enum": ["automatic", "manual", null], + "example": "automatic" + }, + "resolvedBy": { + "type": "string", + "nullable": true + }, + "resolvedByEmail": { + "type": "string", + "nullable": true + }, + "comment": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "example": "2026-04-15T03:21:00.000Z" + }, + "updatedAt": { + "type": "string", + "example": "2026-04-15T03:34:00.000Z" + } + }, + "required": ["id", "monitorId", "teamId", "startTime", "endTime", "status", "resolutionType", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + }, + "IncidentSummary": { + "type": "object", + "properties": { + "total": { + "type": "number", + "example": 12 + }, + "totalActive": { + "type": "number", + "example": 1 + }, + "totalManualResolutions": { + "type": "number", + "example": 2 + }, + "totalAutomaticResolutions": { + "type": "number", + "example": 9 + }, + "avgResolutionTimeHours": { + "type": "number", + "example": 0.5 + }, + "topMonitor": { + "type": "object", + "nullable": true, + "properties": { + "monitorId": { + "type": "string", + "example": "65f1c2a4d8b9e0123456789a" + }, + "monitorName": { + "type": "string", + "nullable": true, + "example": "Marketing site" + }, + "incidentCount": { + "type": "number", + "example": 4 + } + }, + "required": ["monitorId", "monitorName", "incidentCount"] + }, + "latestIncidents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IncidentSummaryItem" + } + } + }, + "required": [ + "total", + "totalActive", + "totalManualResolutions", + "totalAutomaticResolutions", + "avgResolutionTimeHours", + "topMonitor", + "latestIncidents" + ] + }, + "IncidentSummaryItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "65f1c2a4d8b9e0123456789c" + }, + "monitorId": { + "type": "string", + "example": "65f1c2a4d8b9e0123456789a" + }, + "monitorName": { + "type": "string", + "nullable": true, + "example": "Marketing site" + }, + "status": { + "type": "boolean", + "example": true + }, + "startTime": { + "type": "string", + "example": "2026-04-15T03:21:00.000Z" + }, + "endTime": { + "type": "string", + "nullable": true, + "example": "2026-04-15T03:34:00.000Z" + }, + "resolutionType": { + "type": "string", + "nullable": true, + "enum": ["automatic", "manual", null], + "example": "automatic" + }, + "message": { + "type": "string", + "nullable": true, + "example": "HTTP 503 from origin" + }, + "statusCode": { + "type": "number", + "nullable": true, + "example": 503 + }, + "createdAt": { + "type": "string", + "example": "2026-04-15T03:21:00.000Z" + } + }, + "required": ["id", "monitorId", "monitorName", "status", "startTime", "endTime", "resolutionType", "message", "statusCode", "createdAt"] + }, + "Monitor": { + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "url": { + "type": "string" + }, + "port": { + "type": "number" + }, + "isActive": { + "type": "boolean" + }, + "interval": { + "type": "number" + }, + "status": { + "type": "string", + "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"] + }, + "statusWindowSize": { + "type": "number" + }, + "statusWindowThreshold": { + "type": "number" + }, + "ignoreTlsErrors": { + "type": "boolean" + }, + "useAdvancedMatching": { + "type": "boolean" + }, + "jsonPath": { + "type": "string" + }, + "expectedValue": { + "type": "string" + }, + "matchMethod": { + "type": "string", + "enum": ["equal", "include", "regex"] + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "secret": { + "type": "string" + }, + "cpuAlertThreshold": { + "type": "number" + }, + "memoryAlertThreshold": { + "type": "number" + }, + "diskAlertThreshold": { + "type": "number" + }, + "tempAlertThreshold": { + "type": "number" + }, + "selectedDisks": { + "type": "array", + "items": { + "type": "string" + } + }, + "gameId": { + "type": "string" + }, + "grpcServiceName": { + "type": "string" + }, + "group": { + "type": "string", + "nullable": true + }, + "geoCheckEnabled": { + "type": "boolean" + }, + "geoCheckLocations": { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + }, + "geoCheckInterval": { + "type": "number" + }, + "dnsServer": { + "type": "string" + }, + "dnsRecordType": { + "type": "string", + "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] + }, + "teamId": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "_id", + "name", + "type", + "url", + "isActive", + "interval", + "status", + "statusWindowSize", + "statusWindowThreshold", + "ignoreTlsErrors", + "useAdvancedMatching", + "notifications", + "tags", + "cpuAlertThreshold", + "memoryAlertThreshold", + "diskAlertThreshold", + "tempAlertThreshold", + "selectedDisks", + "geoCheckEnabled", + "geoCheckLocations", + "geoCheckInterval", + "teamId", + "userId", + "createdAt", + "updatedAt" + ], + "additionalProperties": { + "nullable": true + }, + "example": { + "_id": "65f1c2a4d8b9e0123456789a", + "name": "Marketing site", + "description": "Production marketing site monitored from the EU region", + "type": "http", + "url": "https://www.example.com", + "port": 443, + "isActive": true, + "interval": 60000, + "status": "up", + "statusWindowSize": 5, + "statusWindowThreshold": 3, + "ignoreTlsErrors": false, + "useAdvancedMatching": false, + "notifications": ["65f1c2a4d8b9e0123456789b"], + "cpuAlertThreshold": 90, + "memoryAlertThreshold": 90, + "diskAlertThreshold": 90, + "tempAlertThreshold": 80, + "selectedDisks": [], + "geoCheckEnabled": false, + "geoCheckLocations": [], + "geoCheckInterval": 300000, + "teamId": "65f1c2a4d8b9e01234567890", + "userId": "65f1c2a4d8b9e01234567891", + "createdAt": "2026-04-01T10:00:00.000Z", + "updatedAt": "2026-04-15T14:30:00.000Z" + } + }, + "NotificationChannelBody": { + "oneOf": [ + { + "$ref": "#/components/schemas/EmailNotification" + }, + { + "$ref": "#/components/schemas/WebhookNotification" + }, + { + "$ref": "#/components/schemas/SlackNotification" + }, + { + "$ref": "#/components/schemas/DiscordNotification" + }, + { + "$ref": "#/components/schemas/PagerDutyNotification" + }, + { + "$ref": "#/components/schemas/MatrixNotification" + }, + { + "$ref": "#/components/schemas/TeamsNotification" + }, + { + "$ref": "#/components/schemas/TelegramNotification" + }, + { + "$ref": "#/components/schemas/PushoverNotification" + }, + { + "$ref": "#/components/schemas/TwilioNotification" + }, + { + "$ref": "#/components/schemas/NtfyNotification" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "email": "#/components/schemas/EmailNotification", + "webhook": "#/components/schemas/WebhookNotification", + "slack": "#/components/schemas/SlackNotification", + "discord": "#/components/schemas/DiscordNotification", + "pager_duty": "#/components/schemas/PagerDutyNotification", + "matrix": "#/components/schemas/MatrixNotification", + "teams": "#/components/schemas/TeamsNotification", + "telegram": "#/components/schemas/TelegramNotification", + "pushover": "#/components/schemas/PushoverNotification", + "twilio": "#/components/schemas/TwilioNotification", + "ntfy": "#/components/schemas/NtfyNotification" + } + } + }, + "EmailNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["email"] + }, + "address": { + "type": "string", + "format": "email" + }, + "homeserverUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "roomId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "Ops on-call email", + "type": "email", + "address": "alerts@example.com" + } + }, + "WebhookNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["webhook"] + }, + "address": { + "type": "string", + "format": "uri" + }, + "homeserverUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "roomId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "Custom webhook", + "type": "webhook", + "address": "https://example.com/hooks/checkmate" + } + }, + "SlackNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["slack"] + }, + "address": { + "type": "string", + "format": "uri" + }, + "homeserverUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "roomId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "#alerts", + "type": "slack", + "address": "https://hooks.slack.com/services/T000/B000/XXXX" + } + }, + "DiscordNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["discord"] + }, + "address": { + "type": "string", + "format": "uri" + }, + "homeserverUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "roomId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "#status", + "type": "discord", + "address": "https://discord.com/api/webhooks/123/abc" + } + }, + "PagerDutyNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["pager_duty"] + }, + "address": { + "type": "string", + "minLength": 1 + }, + "homeserverUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "roomId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "PagerDuty primary", + "type": "pager_duty", + "address": "R01XXXXXXXXXXXXXXXXXXXXXXX" + } + }, + "MatrixNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["matrix"] + }, + "address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "homeserverUrl": { + "type": "string", + "format": "uri" + }, + "roomId": { + "type": "string", + "minLength": 1 + }, + "accessToken": { + "type": "string", + "minLength": 1 + } + }, + "required": ["notificationName", "type", "homeserverUrl", "roomId", "accessToken"], + "example": { + "notificationName": "Matrix room", + "type": "matrix", + "homeserverUrl": "https://matrix.example.com", + "roomId": "!abc123:example.com", + "accessToken": "syt_xxx" + } + }, + "TeamsNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["teams"] + }, + "address": { + "type": "string", + "format": "uri" + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "Teams ops channel", + "type": "teams", + "address": "https://outlook.office.com/webhook/..." + } + }, + "TelegramNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["telegram"] + }, + "address": { + "type": "string", + "minLength": 1 + }, + "accessToken": { + "type": "string", + "minLength": 1 + } + }, + "required": ["notificationName", "type", "address", "accessToken"], + "example": { + "notificationName": "Telegram bot", + "type": "telegram", + "address": "-1001234567890", + "accessToken": "123456:ABC-DEF" + } + }, + "PushoverNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["pushover"] + }, + "address": { + "type": "string", + "minLength": 1 + }, + "accessToken": { + "type": "string", + "minLength": 1 + } + }, + "required": ["notificationName", "type", "address", "accessToken"], + "example": { + "notificationName": "Pushover personal", + "type": "pushover", + "address": "u1234567890abcdef", + "accessToken": "a1234567890abcdef" + } + }, + "TwilioNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["twilio"] + }, + "accountSid": { + "type": "string", + "minLength": 1 + }, + "accessToken": { + "type": "string", + "minLength": 1 + }, + "phone": { + "type": "string", + "minLength": 1 + }, + "twilioPhoneNumber": { + "type": "string", + "minLength": 1 + } + }, + "required": ["notificationName", "type", "accountSid", "accessToken", "phone", "twilioPhoneNumber"], + "example": { + "notificationName": "Twilio SMS", + "type": "twilio", + "accountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "accessToken": "your-auth-token", + "phone": "+15551234567", + "twilioPhoneNumber": "+15557654321" + } + }, + "NtfyNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["ntfy"] + }, + "address": { + "type": "string", + "format": "uri" + }, + "topic": { + "type": "string", + "minLength": 1 + } + }, + "required": ["notificationName", "type", "address", "topic"], + "example": { + "notificationName": "ntfy topic", + "type": "ntfy", + "address": "https://ntfy.sh", + "topic": "checkmate-alerts" + } + } + }, + "parameters": {} + }, "paths": { "/auth/register": { "post": { "tags": ["auth"], - "summary": "Register new user", - "description": "Register a new user account with profile information", + "summary": "Register a new user (first user becomes superadmin)", "requestBody": { - "required": true, "content": { - "application/json": { + "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/RegisterRequest" + "type": "object", + "properties": { + "firstName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "lastName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" + }, + "profileImage": { + "type": "string", + "format": "binary" + }, + "inviteToken": { + "type": "string", + "default": "" + } + }, + "required": ["firstName", "lastName", "email", "password", "profileImage"] } } } }, "responses": { "200": { - "description": "User registered successfully", + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuthResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AuthPayload" + } + }, + "required": ["success", "msg", "data"] } } } }, - "422": { - "$ref": "#/components/responses/ValidationError" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } } @@ -127,195 +1189,114 @@ "/auth/login": { "post": { "tags": ["auth"], - "summary": "User login", - "description": "Authenticate user with email and password", + "summary": "Log in", "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoginRequest" + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 1 + } + }, + "required": ["email", "password"] + }, + "example": { + "email": "ada@example.com", + "password": "S3cure!Passw0rd" } } } }, "responses": { "200": { - "description": "Login successful", + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuthResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AuthPayload" + } + }, + "required": ["success", "msg", "data"] + }, + "example": { + "success": true, + "msg": "OK", + "data": { + "user": { + "_id": "65f1c2a4d8b9e0123456789a", + "firstName": "Ada", + "lastName": "Lovelace", + "email": "ada@example.com", + "role": ["admin"], + "teamId": "65f1c2a4d8b9e01234567890", + "createdAt": "2026-04-01T10:00:00.000Z", + "updatedAt": "2026-04-15T14:30:00.000Z" + }, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - } - } - }, - "/auth/user/{userId}": { - "put": { - "tags": ["auth"], - "summary": "Update user profile", - "description": "Update user information including profile image", - "parameters": [ - { - "name": "userId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/UserUpdateRequest" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": ["auth"], - "summary": "Delete user account", - "description": "Permanently delete user account and associated data", - "parameters": [ - { - "name": "userId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/auth/users": { - "get": { - "tags": ["auth"], - "summary": "Get all users", - "description": "Retrieve all users (admin/superadmin only)", - "responses": { - "200": { - "description": "Users retrieved successfully", + "description": "Invalid credentials", "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/User" - } - } - } + "msg": { + "type": "string" } - ] - } - } - } - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/auth/users/superadmin": { - "get": { - "tags": ["auth"], - "summary": "Check superadmin exists", - "description": "Check if a superadmin account exists in the system", - "responses": { - "200": { - "description": "Check completed", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "exists": { - "type": "boolean" - } - } - } - } - } - ] + }, + "required": ["success", "msg"] } } } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } } @@ -323,35 +1304,73 @@ "/auth/recovery/request": { "post": { "tags": ["auth"], - "summary": "Request password reset", - "description": "Send password reset email to user", + "summary": "Request a password recovery email", "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["email"], "properties": { "email": { "type": "string", - "format": "email", - "description": "User email address" + "format": "email" } - } + }, + "required": ["email"] + }, + "example": { + "email": "ada@example.com" } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "description": "User not found" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } } @@ -359,34 +1378,70 @@ "/auth/recovery/validate": { "post": { "tags": ["auth"], - "summary": "Validate recovery token", - "description": "Validate password reset token", + "summary": "Validate a password recovery token", "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["recoveryToken"], "properties": { "recoveryToken": { "type": "string", - "description": "Password reset token" + "minLength": 1 } - } + }, + "required": ["recoveryToken"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "400": { - "description": "Invalid or expired token" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } } @@ -394,308 +1449,89 @@ "/auth/recovery/reset": { "post": { "tags": ["auth"], - "summary": "Reset password", - "description": "Reset user password with recovery token", + "summary": "Reset password using a recovery token", "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["recoveryToken", "password"], "properties": { "recoveryToken": { "type": "string", - "description": "Password reset token" + "minLength": 1 }, "password": { "type": "string", - "format": "password", - "description": "New password" + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" + }, + "confirm": { + "type": "string" } - } + }, + "required": ["recoveryToken", "password"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "400": { - "description": "Invalid token or password requirements not met" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } } }, - "/monitors": { - "post": { - "tags": ["monitors"], - "summary": "Create monitor", - "description": "Create a new monitoring endpoint", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateMonitorRequest" - } - } - } - }, - "responses": { - "201": { - "description": "Monitor created successfully", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/Monitor" - } - } - } - ] - } - } - } - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": ["monitors"], - "summary": "Delete all monitors", - "description": "Delete all monitors (superadmin only)", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/team": { + "/auth/users/superadmin": { "get": { - "tags": ["monitors"], - "summary": "Get monitors by team", - "description": "Get monitors filtered by team with optional parameters", - "parameters": [ - { - "name": "type", - "in": "query", - "description": "Filter by monitor type", - "schema": { - "oneOf": [ - { - "type": "string", - "enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"] - }, - { - "type": "array", - "items": { - "type": "string", - "enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"] - } - } - ] - } - }, - { - "name": "filter", - "in": "query", - "description": "Search filter value", - "schema": { - "type": "string" - } - } - ], + "tags": ["auth"], + "summary": "Check whether a superadmin user exists", "responses": { "200": { - "description": "Team monitors retrieved successfully", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "monitors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Monitor" - } - }, - "totalCount": { - "type": "integer" - } - } - } - } - } - ] - } - } - } - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/team/with-checks": { - "get": { - "tags": ["monitors"], - "summary": "Get monitors with recent checks", - "description": "Get team monitors with their most recent check results", - "parameters": [ - { - "name": "limit", - "in": "query", - "description": "Number of recent checks to include per monitor", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "page", - "in": "query", - "description": "Zero-based page index for pagination", - "schema": { - "type": "integer", - "minimum": 0 - } - }, - { - "name": "rowsPerPage", - "in": "query", - "description": "Number of monitors per page", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "filter", - "in": "query", - "description": "Search filter value", - "schema": { - "type": "string" - } - }, - { - "name": "field", - "in": "query", - "description": "Field to sort by", - "schema": { - "type": "string" - } - }, - { - "name": "order", - "in": "query", - "description": "Sort order", - "schema": { - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "type", - "in": "query", - "description": "Filter by monitor type", - "schema": { - "oneOf": [ - { - "type": "string", - "enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"] - }, - { - "type": "array", - "items": { - "type": "string", - "enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"] - } - } - ] - } - }, - { - "name": "explain", - "in": "query", - "description": "Include MongoDB query explain output (debug)", - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "description": "Monitors with checks retrieved successfully" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/team/groups": { - "get": { - "tags": ["monitors"], - "summary": "Get monitor groups by team", - "description": "Get all monitor groups for the authenticated user's team", - "responses": { - "200": { - "description": "Monitor groups retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -703,316 +1539,90 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Monitor groups retrieved successfully" + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "superAdminExists": { + "type": "boolean" + } + }, + "required": ["superAdminExists"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/auth/users": { + "get": { + "tags": ["auth"], + "summary": "List all users (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" }, "data": { "type": "array", "items": { - "type": "object", - "properties": { - "_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "monitors": { - "type": "array", - "items": { - "type": "string" - } - } - } + "$ref": "#/components/schemas/User" } } - } + }, + "required": ["success", "msg", "data"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/{monitorId}": { - "get": { - "tags": ["monitors"], - "summary": "Get monitor by ID", - "description": "Retrieve a specific monitor by its ID", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "description": "Monitor ID", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Monitor retrieved successfully", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/Monitor" - } - } - } - ] - } - } - } - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "patch": { - "tags": ["monitors"], - "summary": "Update monitor", - "description": "Update an existing monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMonitorRequest" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": ["monitors"], - "summary": "Delete monitor", - "description": "Delete a specific monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/uptime/details/{monitorId}": { - "get": { - "tags": ["monitors"], - "summary": "Get uptime details", - "description": "Get detailed uptime statistics for a monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Uptime details retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/hardware/details/{monitorId}": { - "get": { - "tags": ["monitors"], - "summary": "Get hardware monitoring details", - "description": "Get hardware performance metrics for a monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Hardware details retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/pause/{monitorId}": { - "post": { - "tags": ["monitors"], - "summary": "Pause/unpause monitor", - "description": "Toggle monitor active status", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/pagespeed/details/{monitorId}": { - "get": { - "tags": ["monitors"], - "summary": "Get PageSpeed monitor details", - "description": "Get detailed PageSpeed monitoring data including performance metrics and Core Web Vitals", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "description": "The PageSpeed monitor ID", - "schema": { - "type": "string" - } - }, - { - "name": "sortOrder", - "in": "query", - "description": "Sort order for results", - "schema": { - "type": "string", - "enum": ["asc", "desc"], - "default": "desc" - } - }, - { - "name": "limit", - "in": "query", - "description": "Number of results to return", - "schema": { - "type": "integer", - "default": 50 - } - } - ], - "responses": { - "200": { - "description": "PageSpeed details retrieved successfully", + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -1020,839 +1630,2587 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [false] }, "msg": { - "type": "string", - "example": "PageSpeed details retrieved successfully" + "type": "string" }, "data": { - "type": "object", - "properties": { - "monitor": { - "$ref": "#/components/schemas/Monitor" - }, - "checks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Check" - } - } - } + "nullable": true } - } + }, + "required": ["success", "msg"] } } } }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/certificate/{monitorId}": { - "get": { - "tags": ["monitors"], - "summary": "Get SSL certificate info", - "description": "Get SSL certificate information for a monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Certificate information retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/demo": { - "post": { - "tags": ["monitors"], - "summary": "Add demo monitors", - "description": "Add preconfigured demo monitors for testing", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/export/json": { - "get": { - "tags": ["monitors"], - "summary": "Export monitors to JSON", - "description": "Export all monitors to JSON format (admin/superadmin only)", - "responses": { - "200": { - "description": "JSON file generated successfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "msg": { - "type": "string", - "example": "Monitors exported successfully" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Monitor" - } - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, "403": { - "$ref": "#/components/responses/Forbidden" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + }, + "post": { + "tags": ["auth"], + "summary": "Create a new user (superadmin)", "security": [ { "bearerAuth": [] } - ] - } - }, - "/monitors/bulk": { - "post": { - "tags": ["monitors"], - "summary": "Bulk import monitors", - "description": "Import monitors from CSV file", + ], "requestBody": { - "required": true, "content": { "multipart/form-data": { "schema": { "type": "object", "properties": { - "csvFile": { + "firstName": { "type": "string", - "format": "binary", - "description": "CSV file containing monitor data" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Monitors imported successfully" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/test-email": { - "post": { - "tags": ["monitors"], - "summary": "Send test email", - "description": "Send a test email notification", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/settings": { - "get": { - "tags": ["settings"], - "summary": "Get application settings", - "description": "Retrieve current application configuration", - "responses": { - "200": { - "description": "Settings retrieved successfully", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "lastName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" + }, + "role": { + "type": "array", + "items": { + "type": "string", + "enum": ["user", "admin", "superadmin", "demo"] }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/AppSettings" - } - } - } - ] - } - } - } - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "put": { - "tags": ["settings"], - "summary": "Update application settings", - "description": "Update application configuration (admin/superadmin only)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AppSettings" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/settings/test-email": { - "post": { - "tags": ["settings"], - "summary": "Send test email", - "description": "Send test email to verify email configuration", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/notifications": { - "post": { - "tags": ["notifications"], - "summary": "Create notification", - "description": "Create a new notification integration", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateNotificationRequest" + "minItems": 1 + }, + "profileImage": { + "type": "string", + "format": "binary" + } + }, + "required": ["firstName", "lastName", "email", "password", "role", "profileImage"] } } } }, "responses": { "201": { - "description": "Notification created successfully" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/notifications/team": { - "get": { - "tags": ["notifications"], - "summary": "Get team notifications", - "description": "Get all notification configurations for the team", - "responses": { - "200": { - "description": "Notifications retrieved successfully", + "description": "User created", "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Notification" - } - } - } + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/User" } - ] + }, + "required": ["success", "msg", "data"] } } } }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/notifications/{id}": { - "get": { - "tags": ["notifications"], - "summary": "Get notification by ID", - "description": "Retrieve a specific notification configuration", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Notification retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "put": { - "tags": ["notifications"], - "summary": "Update notification", - "description": "Update an existing notification configuration", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateNotificationRequest" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": ["notifications"], - "summary": "Delete notification", - "description": "Delete a notification configuration", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/notifications/test": { - "post": { - "tags": ["notifications"], - "summary": "Test notification", - "description": "Send a test notification to verify configuration", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/notifications/test/all": { - "post": { - "tags": ["notifications"], - "summary": "Test all notifications", - "description": "Send test notifications to all configured integrations", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/logs": { - "get": { - "tags": ["logs"], - "summary": "Get application logs", - "description": "Retrieve application logs (admin/superadmin only)", - "parameters": [ - { - "name": "level", - "in": "query", - "description": "Log level filter", - "schema": { - "type": "string", - "enum": ["error", "warn", "info", "debug"] - } - }, - { - "name": "limit", - "in": "query", - "description": "Number of log entries to return", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 1000, - "default": 100 - } - } - ], - "responses": { - "200": { - "description": "Logs retrieved successfully" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/diagnostic/system": { - "get": { - "tags": ["diagnostic"], - "summary": "Get system diagnostics", - "description": "Get system health and performance metrics (admin/superadmin only)", - "responses": { - "200": { - "description": "System diagnostics retrieved successfully", + "401": { + "description": "Unauthorized", "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/SystemDiagnostics" - } - } + "msg": { + "type": "string" + }, + "data": { + "nullable": true } - ] + }, + "required": ["success", "msg"] } } } }, "403": { - "$ref": "#/components/responses/Forbidden" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/auth/user": { - "put": { - "tags": ["auth"], - "summary": "Update current user profile", - "description": "Update authenticated user's profile information", - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/UserUpdateRequest" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } } } } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": ["auth"], - "summary": "Delete current user account", - "description": "Delete authenticated user's account and associated data", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, "/auth/users/{userId}": { "get": { "tags": ["auth"], - "summary": "Get user by ID", - "description": "Get a specific user by ID (superadmin only)", + "summary": "Get a user by id", + "security": [ + { + "bearerAuth": [] + } + ], "parameters": [ { - "name": "userId", - "in": "path", - "required": true, "schema": { - "type": "string" - } + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "userId", + "in": "path" } ], "responses": { "200": { - "description": "User retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/User" - } - } + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/User" } - ] + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] } } } }, "404": { - "$ref": "#/components/responses/NotFound" - }, - "403": { - "$ref": "#/components/responses/Forbidden" + "description": "User not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + }, + "patch": { + "tags": ["auth"], + "summary": "Edit a user (superadmin)", "security": [ { "bearerAuth": [] } - ] - }, - "put": { - "tags": ["auth"], - "summary": "Update user by ID", - "description": "Update a specific user by ID (superadmin only)", + ], "parameters": [ { - "name": "userId", - "in": "path", - "required": true, "schema": { - "type": "string" - } + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "userId", + "in": "path" } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserUpdateRequest" + "type": "object", + "properties": { + "firstName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "lastName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "role": { + "type": "array", + "items": { + "type": "string", + "enum": ["user", "admin", "superadmin", "demo"] + }, + "minItems": 1 + } + }, + "required": ["firstName", "lastName", "role"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } }, - "404": { - "$ref": "#/components/responses/NotFound" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "403": { - "$ref": "#/components/responses/Forbidden" - }, - "422": { - "$ref": "#/components/responses/ValidationError" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + }, + "delete": { + "tags": ["auth"], + "summary": "Delete a user (admin/superadmin)", "security": [ { "bearerAuth": [] } - ] + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "userId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } } }, "/auth/users/{userId}/password": { - "put": { + "patch": { "tags": ["auth"], - "summary": "Update user password by ID", - "description": "Update a specific user's password by ID (superadmin only)", + "summary": "Change a user's password (superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], "parameters": [ { - "name": "userId", - "in": "path", - "required": true, "schema": { - "type": "string" - } + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "userId", + "in": "path" } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["password"], "properties": { "password": { "type": "string", - "format": "password", "minLength": 8, - "description": "New password for the user" + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" } - } + }, + "required": ["password"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } }, - "404": { - "$ref": "#/components/responses/NotFound" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "403": { - "$ref": "#/components/responses/Forbidden" - }, - "422": { - "$ref": "#/components/responses/ValidationError" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + } + }, + "/auth/user": { + "patch": { + "tags": ["auth"], + "summary": "Edit the currently authenticated user", "security": [ { "bearerAuth": [] } - ] + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "firstName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "lastName": { + "type": "string", + "maxLength": 50, + "pattern": "^(?=.*[\\p{L}\\p{Sc}])[\\p{L}\\p{Sc}\\s'\\-().]+$/u" + }, + "email": { + "type": "string", + "format": "email" + }, + "profilePicture": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" + }, + "newPassword": { + "type": "string", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" + }, + "profileImage": { + "type": "string", + "format": "binary" + } + }, + "required": ["profileImage"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/User" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["auth"], + "summary": "Delete the currently authenticated user", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } } }, - "/invite": { + "/checks/team/summary": { + "get": { + "tags": ["checks"], + "summary": "Aggregate check summary for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": false, + "name": "dateRange", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/checks/team": { + "get": { + "tags": ["checks"], + "summary": "List checks across the team", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": true, + "name": "sortOrder", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": true, + "name": "dateRange", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["all", "up", "down", "resolve"] + }, + "required": false, + "name": "filter", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "ack", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "rowsPerPage", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["checks"], + "summary": "Delete all checks for the team (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "deletedCount": { + "type": "number" + } + }, + "required": ["deletedCount"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/checks/{monitorId}": { + "get": { + "tags": ["checks"], + "summary": "List checks for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "required": false, + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": true, + "name": "sortOrder", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": true, + "name": "dateRange", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["all", "up", "down", "resolve"] + }, + "required": false, + "name": "filter", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "ack", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "rowsPerPage", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + } + ] + }, + "required": false, + "name": "continent", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["checks"], + "summary": "Delete checks for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/diagnostic/system": { + "get": { + "tags": ["diagnostic"], + "summary": "Get system diagnostics (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/geo-checks/{monitorId}": { + "get": { + "tags": ["geo-checks"], + "summary": "Get geo check results for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "required": false, + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": true, + "name": "sortOrder", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": true, + "name": "dateRange", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["all", "up", "down", "resolve"] + }, + "required": false, + "name": "filter", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "ack", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "rowsPerPage", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + } + ] + }, + "required": false, + "name": "continent", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/incidents/team": { + "get": { + "tags": ["incidents"], + "summary": "List incidents for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": true, + "name": "sortOrder", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"], + "default": "all" + }, + "required": false, + "name": "dateRange", + "in": "query" + }, + { + "schema": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": true, + "name": "rowsPerPage", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "monitorId", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["manual", "automatic"] + }, + "required": false, + "name": "resolutionType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Incident" + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/incidents/team/summary": { + "get": { + "tags": ["incidents"], + "summary": "Incident summary for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1 + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/IncidentSummary" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/incidents/{incidentId}": { + "get": { + "tags": ["incidents"], + "summary": "Get an incident by id", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "incidentId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "incident": { + "$ref": "#/components/schemas/Incident" + }, + "monitor": { + "nullable": true + }, + "user": { + "nullable": true + } + }, + "required": ["incident"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/incidents/{incidentId}/resolve": { + "put": { + "tags": ["incidents"], + "summary": "Manually resolve an incident (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "incidentId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/invite/send": { "post": { "tags": ["invite"], - "summary": "Create invite token", - "description": "Create a new invitation token", + "summary": "Send an invite email (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["email", "role"], "properties": { "email": { "type": "string", @@ -1862,292 +4220,210 @@ "type": "array", "items": { "type": "string", - "enum": ["user", "admin"] - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Invite token created successfully" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/invite/send": { - "post": { - "tags": ["invite"], - "summary": "Send invitation email", - "description": "Send invitation email to user", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["email"], - "properties": { - "email": { + "enum": ["user", "admin", "superadmin", "demo"] + }, + "minItems": 1 + }, + "teamId": { "type": "string", - "format": "email" + "minLength": 1 } - } + }, + "required": ["email", "role", "teamId"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "403": { - "$ref": "#/components/responses/Forbidden" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, "/invite/verify": { "post": { "tags": ["invite"], - "summary": "Verify invitation token", - "description": "Verify an invitation token", + "summary": "Verify an invite token", "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["token"], "properties": { "token": { - "type": "string" + "type": "string", + "minLength": 1 } - } + }, + "required": ["token"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "400": { - "description": "Invalid or expired token" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "role": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["email"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } } }, - "/checks/{monitorId}": { - "get": { - "tags": ["checks"], - "summary": "Get checks by monitor", - "description": "Get all checks for a specific monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Checks retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, + "/invite": { + "post": { + "tags": ["invite"], + "summary": "Create an invite token (admin/superadmin)", "security": [ { "bearerAuth": [] } - ] - }, - "delete": { - "tags": ["checks"], - "summary": "Delete checks by monitor", - "description": "Delete all checks for a specific monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/checks/team": { - "get": { - "tags": ["checks"], - "summary": "Get checks by team", - "description": "Get all checks for team monitors", - "responses": { - "200": { - "description": "Team checks retrieved successfully" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "tags": ["checks"], - "summary": "Delete team checks", - "description": "Delete all checks for team (admin/superadmin only)", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/checks/team/summary": { - "get": { - "tags": ["checks"], - "summary": "Get team checks summary", - "description": "Get summary statistics for team checks", - "responses": { - "200": { - "description": "Team checks summary retrieved successfully" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/checks/team/ttl": { - "put": { - "tags": ["checks"], - "summary": "Update checks TTL", - "description": "Update time-to-live for checks (admin/superadmin only)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["ttl"], - "properties": { - "ttl": { - "type": "integer", - "minimum": 1, - "description": "Time to live in days" - } - } - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/checks/check/{checkId}": { - "put": { - "tags": ["checks"], - "summary": "Acknowledge check", - "description": "Acknowledge a specific check", - "parameters": [ - { - "name": "checkId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } ], "requestBody": { "content": { @@ -2155,70 +4431,31 @@ "schema": { "type": "object", "properties": { - "ack": { - "type": "boolean", - "description": "Acknowledgement status" + "email": { + "type": "string", + "format": "email" + }, + "role": { + "type": "array", + "items": { + "type": "string", + "enum": ["user", "admin", "superadmin", "demo"] + }, + "minItems": 1 + }, + "teamId": { + "type": "string", + "minLength": 1 } - } + }, + "required": ["email", "role", "teamId"] } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/checks/{action}": { - "put": { - "tags": ["checks"], - "summary": "Acknowledge all checks for all monitors", - "description": "Acknowledge all checks across all monitors for the team", - "parameters": [ - { - "name": "action", - "in": "path", - "required": true, - "description": "Action to perform (e.g., 'ack')", - "schema": { - "type": "string", - "enum": ["ack"] - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["ack"], - "properties": { - "ack": { - "type": "boolean", - "description": "Acknowledgement status to set" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Checks acknowledged successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -2226,81 +4463,31 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "All checks acknowledged successfully" + "type": "string" }, "data": { "type": "object", - "description": "Updated checks information" + "properties": { + "token": { + "type": "string" + } + }, + "required": ["token"], + "additionalProperties": { + "nullable": true + } } - } + }, + "required": ["success", "msg", "data"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/checks/{action}/{monitorId}": { - "put": { - "tags": ["checks"], - "summary": "Acknowledge all checks for a specific monitor", - "description": "Acknowledge all checks for a specific monitor", - "parameters": [ - { - "name": "action", - "in": "path", - "required": true, - "description": "Action to perform (e.g., 'ack')", - "schema": { - "type": "string", - "enum": ["ack"] - } - }, - { - "name": "monitorId", - "in": "path", - "required": true, - "description": "Monitor ID to acknowledge checks for", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["ack"], - "properties": { - "ack": { - "type": "boolean", - "description": "Acknowledgement status to set" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Checks acknowledged successfully", + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -2308,289 +4495,900 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [false] }, "msg": { - "type": "string", - "example": "All checks acknowledged successfully" + "type": "string" }, "data": { - "type": "object", - "description": "Updated checks information" + "nullable": true } - } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/logs": { + "get": { + "tags": ["logs"], + "summary": "Get application logs (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "404": { - "$ref": "#/components/responses/NotFound" + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, "/maintenance-window": { "post": { "tags": ["maintenance-window"], - "summary": "Create maintenance window", - "description": "Create a new maintenance window", + "summary": "Create one or more maintenance windows", + "security": [ + { + "bearerAuth": [] + } + ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["name", "startTime", "endTime"], "properties": { - "name": { - "type": "string", - "maxLength": 100 - }, - "description": { - "type": "string", - "maxLength": 500 - }, - "startTime": { - "type": "string", - "format": "date-time" - }, - "endTime": { - "type": "string", - "format": "date-time" - }, "monitors": { "type": "array", "items": { "type": "string" - } + }, + "minItems": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "active": { + "type": "boolean" + }, + "duration": { + "type": "number", + "minimum": 1 + }, + "durationUnit": { + "type": "string", + "enum": ["seconds", "minutes", "hours", "days"] + }, + "start": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "end": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "repeat": { + "type": "number", + "minimum": 0 + }, + "expiry": { + "type": "string", + "nullable": true, + "format": "date-time" } - } + }, + "required": ["monitors", "name", "duration", "durationUnit", "start", "end", "repeat"], + "additionalProperties": false } } } }, "responses": { - "201": { - "description": "Maintenance window created successfully" + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "422": { - "$ref": "#/components/responses/ValidationError" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, "/maintenance-window/team": { "get": { "tags": ["maintenance-window"], - "summary": "Get team maintenance windows", - "description": "Get all maintenance windows for the team", - "responses": { - "200": { - "description": "Maintenance windows retrieved successfully" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, + "summary": "List maintenance windows for the caller's team", "security": [ { "bearerAuth": [] } - ] + ], + "parameters": [ + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "active", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "rowsPerPage", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "field", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false, + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } } }, "/maintenance-window/monitor/{monitorId}": { "get": { "tags": ["maintenance-window"], - "summary": "Get maintenance windows by monitor", - "description": "Get all maintenance windows for a specific monitor", - "parameters": [ - { - "name": "monitorId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Monitor maintenance windows retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, + "summary": "List maintenance windows for a monitor", "security": [ { "bearerAuth": [] } - ] + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } } }, "/maintenance-window/{id}": { "get": { "tags": ["maintenance-window"], - "summary": "Get maintenance window by ID", - "description": "Get a specific maintenance window", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Maintenance window retrieved successfully" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, + "summary": "Get a maintenance window by id", "security": [ { "bearerAuth": [] } - ] - }, - "put": { - "tags": ["maintenance-window"], - "summary": "Update maintenance window", - "description": "Update an existing maintenance window", + ], "parameters": [ { - "name": "id", - "in": "path", - "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "patch": { + "tags": ["maintenance-window"], + "summary": "Edit a maintenance window", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" } ], "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { + "active": { + "type": "boolean" + }, "name": { - "type": "string", - "maxLength": 100 + "type": "string" }, - "description": { - "type": "string", - "maxLength": 500 + "repeat": { + "type": "number" }, - "startTime": { + "start": { "type": "string", + "nullable": true, "format": "date-time" }, - "endTime": { + "end": { "type": "string", + "nullable": true, + "format": "date-time" + }, + "expiry": { + "type": "string", + "nullable": true, "format": "date-time" }, "monitors": { "type": "array", "items": { "type": "string" - } + }, + "minItems": 1 + }, + "duration": { + "type": "number" + }, + "durationUnit": { + "type": "string", + "enum": ["seconds", "minutes", "hours", "days"] } - } + }, + "additionalProperties": false } } } }, "responses": { "200": { - "$ref": "#/components/responses/Success" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "404": { - "$ref": "#/components/responses/NotFound" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "422": { - "$ref": "#/components/responses/ValidationError" + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } }, "delete": { "tags": ["maintenance-window"], - "summary": "Delete maintenance window", - "description": "Delete a specific maintenance window", + "summary": "Delete a maintenance window", + "security": [ + { + "bearerAuth": [] + } + ], "parameters": [ { - "name": "id", - "in": "path", - "required": true, "schema": { - "type": "string" - } + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" } ], "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/queue/jobs": { - "get": { - "tags": ["queue"], - "summary": "Get queue jobs", - "description": "Retrieve all jobs in the queue (admin/superadmin only)", - "responses": { - "200": { - "description": "Queue jobs retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -2598,46 +5396,46 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Queue jobs fetched successfully" + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QueueJob" - } + "nullable": true } - } + }, + "required": ["success", "msg"] } } } }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "post": { - "tags": ["queue"], - "summary": "Add job to queue", - "description": "Add a new job to the queue (admin/superadmin only)", - "responses": { - "200": { - "description": "Job added successfully", + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -2645,474 +5443,114 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [false] }, "msg": { - "type": "string", - "example": "Job added to queue successfully" - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/queue/metrics": { - "get": { - "tags": ["queue"], - "summary": "Get queue metrics", - "description": "Retrieve queue metrics (admin/superadmin only)", - "responses": { - "200": { - "description": "Queue metrics retrieved successfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "msg": { - "type": "string", - "example": "Queue metrics fetched successfully" + "type": "string" }, "data": { - "$ref": "#/components/schemas/QueueMetrics" + "nullable": true } - } + }, + "required": ["success", "msg"] } } } }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/queue/health": { - "get": { - "tags": ["queue"], - "summary": "Check queue health", - "description": "Check the health status of the job queue (admin/superadmin only)", - "responses": { - "200": { - "description": "Queue health status retrieved successfully" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/queue/all-metrics": { - "get": { - "tags": ["queue"], - "summary": "Get all queue metrics", - "description": "Get comprehensive queue metrics (admin/superadmin only)", - "responses": { - "200": { - "description": "All queue metrics retrieved successfully" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/queue/flush": { - "post": { - "tags": ["queue"], - "summary": "Flush queue", - "description": "Clear all jobs from the queue (admin/superadmin only)", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/monitors/games": { + "/monitors/team": { "get": { "tags": ["monitors"], - "summary": "Get game server list", - "description": "Get available game servers for monitoring", - "responses": { - "200": { - "description": "Game servers retrieved successfully", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - } - } - } - ] - } - } - } - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, + "summary": "List monitors for the caller's team", "security": [ { "bearerAuth": [] } - ] - } - }, - "/status-page": { - "get": { - "tags": ["status-page"], - "summary": "Get status page", - "description": "Get default status page information", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - } - }, - "post": { - "tags": ["status-page"], - "summary": "Create status page", - "description": "Create a new status page with optional logo upload", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "logo": { + ], + "parameters": [ + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + { + "type": "array", + "items": { "type": "string", - "format": "binary", - "description": "Logo file for the status page" - }, - "title": { - "type": "string", - "description": "Status page title" - }, - "description": { - "type": "string", - "description": "Status page description" - }, - "url": { - "type": "string", - "description": "Custom URL for the status page" - }, - "theme": { - "$ref": "#/components/schemas/StatusPageTheme" - }, - "themeMode": { - "$ref": "#/components/schemas/StatusPageThemeMode" + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] } } - } - } - } - }, - "responses": { - "201": { - "$ref": "#/components/responses/Success" + ] + }, + "required": false, + "name": "type", + "in": "query" }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ { - "bearerAuth": [] - } - ] - }, - "put": { - "tags": ["status-page"], - "summary": "Update status page", - "description": "Update an existing status page with optional logo upload", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "logo": { - "type": "string", - "format": "binary", - "description": "Logo file for the status page" - }, - "title": { - "type": "string", - "description": "Status page title" - }, - "description": { - "type": "string", - "description": "Status page description" - }, - "url": { - "type": "string", - "description": "Custom URL for the status page" - }, - "theme": { - "$ref": "#/components/schemas/StatusPageTheme" - }, - "themeMode": { - "$ref": "#/components/schemas/StatusPageThemeMode" + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "required": false, + "name": "filter", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" } } - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/status-page/team": { - "get": { - "tags": ["status-page"], - "summary": "Get status pages by team", - "description": "Get all status pages for the authenticated user's team", - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/status-page/{url}": { - "get": { - "tags": ["status-page"], - "summary": "Get status page by URL", - "description": "Get a specific status page by its custom URL", - "parameters": [ - { - "name": "url", - "in": "path", - "required": true, - "schema": { - "type": "string" + ] }, - "description": "Custom URL of the status page" + "required": false, + "name": "tags", + "in": "query" } ], "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - } - }, - "delete": { - "tags": ["status-page"], - "summary": "Delete status page", - "description": "Delete a status page by its custom URL", - "parameters": [ - { - "name": "url", - "in": "path", - "required": true, - "schema": { - "type": "string" - }, - "description": "Custom URL of the status page (supports wildcards)" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/incidents/team": { - "get": { - "tags": ["incidents"], - "summary": "Get incidents by team", - "description": "Retrieve all incidents for the authenticated user's team with optional filtering", - "parameters": [ - { - "name": "status", - "in": "query", - "description": "Filter by incident status (true = ongoing, false = resolved)", - "schema": { - "type": "boolean" - } - }, - { - "name": "monitorId", - "in": "query", - "description": "Filter by monitor ID", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number for pagination", - "schema": { - "type": "integer", - "default": 1 - } - }, - { - "name": "limit", - "in": "query", - "description": "Number of items per page", - "schema": { - "type": "integer", - "default": 10 - } - }, - { - "name": "sortOrder", - "in": "query", - "description": "Sort order for results", - "schema": { - "type": "string", - "enum": ["asc", "desc"], - "default": "desc" - } - } - ], - "responses": { - "200": { - "description": "Incidents retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -3120,57 +5558,218 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Incidents retrieved successfully" + "type": "string" }, "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Incident" + "$ref": "#/components/schemas/Monitor" } } - } + }, + "required": ["success", "msg", "data"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + } + }, + "/monitors/team/with-checks": { + "get": { + "tags": ["monitors"], + "summary": "List team monitors with their most recent checks (paginated)", "security": [ { "bearerAuth": [] } - ] - } - }, - "/incidents/team/summary": { - "get": { - "tags": ["incidents"], - "summary": "Get incident summary", - "description": "Retrieve a summary of incidents for the authenticated user's team", + ], "parameters": [ { - "name": "dateRange", - "in": "query", - "description": "Date range for summary", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "required": false, + "name": "rowsPerPage", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "required": false, + "name": "filter", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "field", + "in": "query" + }, + { "schema": { "type": "string", - "enum": ["day", "week", "month"], - "default": "week" - } + "enum": ["asc", "desc"] + }, + "required": false, + "name": "order", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + } + } + ] + }, + "required": false, + "name": "type", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "required": false, + "name": "tags", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "explain", + "in": "query" } ], "responses": { "200": { - "description": "Incident summary retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -3178,53 +5777,104 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Incident summary retrieved successfully" + "type": "string" }, "data": { - "$ref": "#/components/schemas/IncidentSummary" + "nullable": true } - } + }, + "required": ["success", "msg"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/incidents/{incidentId}": { + "/monitors/team/groups": { "get": { - "tags": ["incidents"], - "summary": "Get incident by ID", - "description": "Retrieve a specific incident by its ID", - "parameters": [ + "tags": ["monitors"], + "summary": "List monitor groups for the caller's team", + "security": [ { - "name": "incidentId", - "in": "path", - "required": true, - "description": "The incident ID", - "schema": { - "type": "string" - } + "bearerAuth": [] } ], "responses": { "200": { - "description": "Incident retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -3232,51 +5882,911 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Incident retrieved successfully" + "type": "string" }, "data": { - "$ref": "#/components/schemas/Incident" + "type": "array", + "items": { + "type": "string" + } } - } + }, + "required": ["success", "msg", "data"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "404": { - "$ref": "#/components/responses/NotFound" + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + } + }, + "/monitors/uptime/details/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get uptime details for a monitor", "security": [ { "bearerAuth": [] } - ] - } - }, - "/incidents/{incidentId}/resolve": { - "put": { - "tags": ["incidents"], - "summary": "Resolve incident manually", - "description": "Manually resolve an ongoing incident. Requires admin or superadmin role.", + ], "parameters": [ { - "name": "incidentId", - "in": "path", - "required": true, - "description": "The incident ID to resolve", "schema": { - "type": "string" + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": true, + "name": "dateRange", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "normalize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/hardware/details/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get hardware metrics detail for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": false, + "name": "dateRange", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/pagespeed/details/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get PageSpeed detail for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": false, + "name": "dateRange", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/{monitorId}/geo-checks": { + "get": { + "tags": ["monitors"], + "summary": "Get geo check results for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false, + "name": "sortOrder", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": false, + "name": "dateRange", + "in": "query" + }, + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "numToDisplay", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "normalize", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + } + ] + }, + "required": false, + "name": "continent", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/pause/{monitorId}": { + "post": { + "tags": ["monitors"], + "summary": "Toggle pause state for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Monitor" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/certificate/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get SSL certificate info for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "certificateDate": { + "type": "string" + } + }, + "required": ["certificateDate"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/notifications": { + "patch": { + "tags": ["monitors"], + "summary": "Bulk update notifications across monitors", + "security": [ + { + "bearerAuth": [] } ], "requestBody": { @@ -3285,10 +6795,1548 @@ "schema": { "type": "object", "properties": { - "comment": { + "monitorIds": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 100 + }, + "notificationIds": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 100 + }, + "action": { "type": "string", - "description": "Optional comment about the resolution", - "example": "Issue resolved by restarting the service" + "enum": ["add", "remove", "set"] + } + }, + "required": ["monitorIds", "notificationIds", "action"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "modifiedCount": { + "type": "number" + } + }, + "required": ["modifiedCount"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors": { + "post": { + "tags": ["monitors"], + "summary": "Create a new monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "statusWindowSize": { + "type": "number", + "minimum": 1, + "maximum": 20, + "default": 5 + }, + "statusWindowThreshold": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 60 + }, + "url": { + "type": "string", + "minLength": 1 + }, + "ignoreTlsErrors": { + "type": "boolean", + "default": false + }, + "useAdvancedMatching": { + "type": "boolean", + "default": false + }, + "port": { + "type": "number" + }, + "isActive": { + "type": "boolean" + }, + "interval": { + "type": "number" + }, + "cpuAlertThreshold": { + "type": "number" + }, + "memoryAlertThreshold": { + "type": "number" + }, + "diskAlertThreshold": { + "type": "number" + }, + "tempAlertThreshold": { + "type": "number" + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "secret": { + "type": "string" + }, + "jsonPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "expectedValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "matchMethod": { + "anyOf": [ + { + "type": "string", + "enum": ["equal", "include", "regex"] + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "gameId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "grpcServiceName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ], + "default": "" + }, + "strategy": { + "type": "string", + "enum": ["desktop", "mobile"] + }, + "selectedDisks": { + "type": "array", + "items": { + "type": "string" + } + }, + "group": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "nullable": true + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "geoCheckEnabled": { + "type": "boolean" + }, + "geoCheckLocations": { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + }, + "geoCheckInterval": { + "type": "number", + "minimum": 300000 + }, + "dnsServer": { + "type": "string", + "minLength": 1 + }, + "dnsRecordType": { + "type": "string", + "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] + } + }, + "required": ["name", "type", "url"] + } + } + } + }, + "responses": { + "200": { + "description": "Monitor created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Monitor" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["monitors"], + "summary": "Delete every monitor (superadmin only)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/demo": { + "post": { + "tags": ["monitors"], + "summary": "Insert preconfigured demo monitors", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "number" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/export/json": { + "get": { + "tags": ["monitors"], + "summary": "Export all team monitors as JSON", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/import/json": { + "post": { + "tags": ["monitors"], + "summary": "Import monitors from JSON", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "monitors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "status": { + "type": "string", + "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"], + "default": "initializing" + }, + "statusWindow": { + "type": "array", + "items": { + "type": "boolean" + }, + "default": [] + }, + "statusWindowSize": { + "type": "number", + "minimum": 1, + "maximum": 20, + "default": 5 + }, + "statusWindowThreshold": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 60 + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "ignoreTlsErrors": { + "type": "boolean", + "default": false + }, + "useAdvancedMatching": { + "type": "boolean", + "default": false + }, + "jsonPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "expectedValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "matchMethod": { + "anyOf": [ + { + "type": "string", + "enum": ["equal", "include", "regex"] + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "url": { + "type": "string", + "minLength": 1 + }, + "port": { + "type": "number" + }, + "isActive": { + "type": "boolean", + "default": true + }, + "interval": { + "type": "number", + "default": 60000 + }, + "uptimePercentage": { + "type": "number" + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "secret": { + "type": "string" + }, + "cpuAlertThreshold": { + "type": "number", + "default": 100 + }, + "cpuAlertCounter": { + "type": "number", + "default": 5 + }, + "memoryAlertThreshold": { + "type": "number", + "default": 100 + }, + "memoryAlertCounter": { + "type": "number", + "default": 5 + }, + "diskAlertThreshold": { + "type": "number", + "default": 100 + }, + "diskAlertCounter": { + "type": "number", + "default": 5 + }, + "tempAlertThreshold": { + "type": "number", + "default": 100 + }, + "tempAlertCounter": { + "type": "number", + "default": 5 + }, + "selectedDisks": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "gameId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "grpcServiceName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ], + "default": "" + }, + "strategy": { + "type": "string", + "enum": ["desktop", "mobile"] + }, + "group": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "nullable": true + } + ], + "default": null + }, + "geoCheckEnabled": { + "type": "boolean", + "default": false + }, + "geoCheckLocations": { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + }, + "default": [] + }, + "geoCheckInterval": { + "type": "number", + "minimum": 300000, + "default": 300000 + }, + "dnsServer": { + "type": "string", + "minLength": 1 + }, + "dnsRecordType": { + "type": "string", + "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["name", "type", "url"] + }, + "minItems": 1 + } + }, + "required": ["monitors"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "imported": { + "type": "number" + } + }, + "required": ["imported"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/games": { + "get": { + "tags": ["monitors"], + "summary": "List supported game-server types", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/monitors/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get a monitor by id", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Monitor" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "patch": { + "tags": ["monitors"], + "summary": "Edit a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "url": { + "type": "string" + }, + "statusWindowSize": { + "type": "number", + "minimum": 1, + "maximum": 20, + "default": 5 + }, + "statusWindowThreshold": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 60 + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "interval": { + "type": "number" + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "secret": { + "type": "string" + }, + "ignoreTlsErrors": { + "type": "boolean" + }, + "useAdvancedMatching": { + "type": "boolean" + }, + "jsonPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "expectedValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "matchMethod": { + "anyOf": [ + { + "type": "string", + "enum": ["equal", "include", "regex"] + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "port": { + "type": "number", + "minimum": 1, + "maximum": 65535 + }, + "cpuAlertThreshold": { + "type": "number" + }, + "memoryAlertThreshold": { + "type": "number" + }, + "diskAlertThreshold": { + "type": "number" + }, + "tempAlertThreshold": { + "type": "number" + }, + "gameId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "grpcServiceName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "strategy": { + "type": "string", + "enum": ["desktop", "mobile"] + }, + "selectedDisks": { + "type": "array", + "items": { + "type": "string" + } + }, + "group": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "nullable": true + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "geoCheckEnabled": { + "type": "boolean" + }, + "geoCheckLocations": { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + }, + "geoCheckInterval": { + "type": "number", + "minimum": 300000 + }, + "dnsServer": { + "type": "string", + "minLength": 1 + }, + "dnsRecordType": { + "type": "string", + "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] } } } @@ -3297,7 +8345,7 @@ }, "responses": { "200": { - "description": "Incident resolved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -3305,1433 +8353,2642 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Incident resolved successfully" + "type": "string" }, "data": { - "$ref": "#/components/schemas/Incident" + "$ref": "#/components/schemas/Monitor" } - } + }, + "required": ["success", "msg", "data"] } } } }, "401": { - "$ref": "#/components/responses/Unauthorized" + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, "500": { - "$ref": "#/components/responses/InternalServerError" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } - }, + } + }, + "delete": { + "tags": ["monitors"], + "summary": "Delete a monitor", "security": [ { "bearerAuth": [] } - ] - } - } - }, - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", - "description": "JWT token obtained from login endpoint" - } - }, - "responses": { - "Success": { - "description": "Operation successful", - "content": { - "application/json": { + ], + "parameters": [ + { "schema": { - "$ref": "#/components/schemas/SuccessResponse" - } - } - } - }, - "NotFound": { - "description": "Resource not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "ValidationError": { - "description": "Validation failed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "Unauthorized": { - "description": "Authentication required or token invalid", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "Forbidden": { - "description": "Insufficient permissions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "BadRequest": { - "description": "Invalid request parameters", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "InternalServerError": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "schemas": { - "StatusPageTheme": { - "type": "string", - "enum": ["refined", "modern", "bold", "editorial"], - "default": "refined", - "description": "Visual theme for the public status page. Defaults to 'refined'." - }, - "StatusPageThemeMode": { - "type": "string", - "enum": ["auto", "light", "dark"], - "default": "auto", - "description": "Forces a light/dark mode on the public page. 'auto' follows the visitor's OS preference. Defaults to 'auto'." - }, - "SuccessResponse": { - "type": "object", - "required": ["success", "msg"], - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "msg": { - "type": "string", - "example": "Operation completed successfully" - }, - "data": { - "type": "object", - "description": "Response payload (varies by endpoint)" - } - } - }, - "ErrorResponse": { - "type": "object", - "required": ["success", "msg"], - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "msg": { - "type": "string", - "example": "An error occurred" - }, - "details": { - "type": "object", - "description": "Additional error details" - } - } - }, - "RegisterRequest": { - "type": "object", - "required": ["firstName", "lastName", "email", "password"], - "properties": { - "firstName": { - "type": "string", - "minLength": 1, - "maxLength": 50, - "example": "John" - }, - "lastName": { - "type": "string", - "minLength": 1, - "maxLength": 50, - "example": "Doe" - }, - "email": { - "type": "string", - "format": "email", - "example": "john@example.com" - }, - "password": { - "type": "string", - "minLength": 8, - "format": "password", - "example": "SecurePass123!" - }, - "profileImage": { - "type": "string", - "format": "binary", - "description": "Optional profile image file" - }, - "role": { - "type": "array", - "items": { "type": "string", - "enum": ["user", "admin", "superadmin", "Demo"] + "minLength": 1 }, - "default": ["user"] + "required": true, + "name": "monitorId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Monitor" + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } - }, - "LoginRequest": { - "type": "object", - "required": ["email", "password"], - "properties": { - "email": { - "type": "string", - "format": "email", - "example": "john@example.com" + } + }, + "/notifications": { + "post": { + "tags": ["notifications"], + "summary": "Create a notification channel", + "description": "Create a notification channel of any supported type. The `type` field discriminates the body shape.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "password": { - "type": "string", - "format": "password", - "example": "SecurePass123!" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } - }, - "AuthResponse": { - "allOf": [ + } + }, + "/notifications/test/all": { + "post": { + "tags": ["notifications"], + "summary": "Send a test alert through every notification channel for the team", + "security": [ { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { "type": "object", "properties": { - "token": { + "monitorId": { "type": "string", - "description": "JWT access token" + "minLength": 1 + } + }, + "required": ["monitorId"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } }, - "user": { - "$ref": "#/components/schemas/User" + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/notifications/test": { + "post": { + "tags": ["notifications"], + "summary": "Send a test alert through a single notification channel", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": ["success"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/notifications/team": { + "get": { + "tags": ["notifications"], + "summary": "List notification channels for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/notifications/{id}": { + "get": { + "tags": ["notifications"], + "summary": "Get a notification channel by id", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["notifications"], + "summary": "Delete a notification channel", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "patch": { + "tags": ["notifications"], + "summary": "Edit a notification channel", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/queue/jobs": { + "get": { + "tags": ["queue"], + "summary": "List queued monitor jobs (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/queue/metrics": { + "get": { + "tags": ["queue"], + "summary": "Get queue runtime metrics (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/queue/all-metrics": { + "get": { + "tags": ["queue"], + "summary": "Get queue metrics across all monitors (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/queue/flush": { + "post": { + "tags": ["queue"], + "summary": "Flush the monitor job queue (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/settings": { + "get": { + "tags": ["settings"], + "summary": "Get application settings", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "patch": { + "tags": ["settings"], + "summary": "Update application settings (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "checkTTL": { + "type": "integer", + "minimum": 1, + "maximum": 366 + }, + "systemEmailPort": { + "type": "number", + "nullable": true + }, + "pagespeedApiKey": { + "type": "string", + "nullable": true + }, + "language": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "systemEmailHost": { + "type": "string", + "nullable": true + }, + "systemEmailAddress": { + "type": "string", + "nullable": true + }, + "systemEmailDisplayName": { + "type": "string", + "nullable": true, + "maxLength": 100 + }, + "systemEmailPassword": { + "type": "string", + "nullable": true + }, + "systemEmailUser": { + "type": "string", + "nullable": true + }, + "systemEmailConnectionHost": { + "type": "string", + "nullable": true + }, + "systemEmailTLSServername": { + "type": "string", + "nullable": true + }, + "showURL": { + "type": "boolean" + }, + "systemEmailSecure": { + "type": "boolean" + }, + "systemEmailPool": { + "type": "boolean" + }, + "systemEmailIgnoreTLS": { + "type": "boolean" + }, + "systemEmailRequireTLS": { + "type": "boolean" + }, + "systemEmailRejectUnauthorized": { + "type": "boolean" + }, + "globalThresholds": { + "type": "object", + "properties": { + "cpu": { + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "memory": { + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "disk": { + "type": "number", + "minimum": 1, + "maximum": 100 + }, + "temperature": { + "type": "number", + "minimum": 1, + "maximum": 150 + } + } } } } } } - ] - }, - "UserUpdateRequest": { - "type": "object", - "properties": { - "firstName": { - "type": "string", - "minLength": 1, - "maxLength": 50 - }, - "lastName": { - "type": "string", - "minLength": 1, - "maxLength": 50 - }, - "password": { - "type": "string", - "format": "password", - "description": "Current password for verification" - }, - "newPassword": { - "type": "string", - "minLength": 8, - "format": "password", - "description": "New password (if changing)" - }, - "profileImage": { - "type": "string", - "format": "binary", - "description": "New profile image file" - }, - "deleteProfileImage": { - "type": "boolean", - "description": "Flag to delete current profile image" - } - } - }, - "User": { - "type": "object", - "properties": { - "_id": { - "type": "string", - "example": "64f123a456b789012c345def" - }, - "firstName": { - "type": "string", - "example": "John" - }, - "lastName": { - "type": "string", - "example": "Doe" - }, - "email": { - "type": "string", - "format": "email", - "example": "john@example.com" - }, - "role": { - "type": "array", - "items": { - "type": "string", - "enum": ["user", "admin", "superadmin", "Demo"] - } - }, - "profileImage": { - "type": "string", - "description": "URL or path to profile image" - }, - "isActive": { - "type": "boolean" - }, - "teamId": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "CreateMonitorRequest": { - "type": "object", - "required": ["name", "type", "url"], - "properties": { - "name": { - "type": "string", - "example": "My Website Monitor" - }, - "description": { - "type": "string", - "example": "Monitors the main website homepage" - }, - "type": { - "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"], - "example": "http" - }, - "url": { - "type": "string", - "format": "uri", - "example": "https://example.com" - }, - "interval": { - "type": "integer", - "example": 300, - "description": "Check interval in seconds" - }, - "statusWindowSize": { - "type": "integer", - "minimum": 1, - "maximum": 20, - "default": 5 - }, - "statusWindowThreshold": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 60 - }, - "isActive": { - "type": "boolean" - }, - "ignoreTlsErrors": { - "type": "boolean", - "default": false - }, - "useAdvancedMatching": { - "type": "boolean", - "default": false - }, - "port": { - "type": "integer" - }, - "notifications": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Array of notification IDs to associate with this monitor" - }, - "secret": { - "type": "string" - }, - "jsonPath": { - "type": "string" - }, - "expectedValue": { - "type": "string" - }, - "matchMethod": { - "type": "string", - "enum": ["equal", "include", "regex", ""] - }, - "cpuAlertThreshold": { - "type": "number" - }, - "memoryAlertThreshold": { - "type": "number" - }, - "diskAlertThreshold": { - "type": "number" - }, - "tempAlertThreshold": { - "type": "number" - }, - "gameId": { - "type": "string" - }, - "grpcServiceName": { - "type": "string", - "default": "" - }, - "selectedDisks": { - "type": "array", - "items": { - "type": "string" - } - }, - "group": { - "type": "string", - "nullable": true, - "maxLength": 50 - }, - "geoCheckEnabled": { - "type": "boolean" - }, - "geoCheckLocations": { - "type": "array", - "items": { - "type": "string", - "enum": ["northAmerica", "southAmerica", "europe", "asia", "africa", "oceania"] - } - }, - "geoCheckInterval": { - "type": "integer", - "minimum": 300000 - } - } - }, - "UpdateMonitorRequest": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] - }, - "url": { - "type": "string" - }, - "statusWindowSize": { - "type": "integer", - "minimum": 1, - "maximum": 20, - "default": 5 - }, - "statusWindowThreshold": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 60 - }, - "description": { - "type": "string" - }, - "interval": { - "type": "integer" - }, - "notifications": { - "type": "array", - "items": { - "type": "string" - } - }, - "secret": { - "type": "string" - }, - "ignoreTlsErrors": { - "type": "boolean" - }, - "useAdvancedMatching": { - "type": "boolean" - }, - "jsonPath": { - "type": "string" - }, - "expectedValue": { - "type": "string" - }, - "matchMethod": { - "type": "string", - "enum": ["equal", "include", "regex", ""] - }, - "port": { - "type": "integer", - "minimum": 1, - "maximum": 65535 - }, - "cpuAlertThreshold": { - "type": "number" - }, - "memoryAlertThreshold": { - "type": "number" - }, - "diskAlertThreshold": { - "type": "number" - }, - "tempAlertThreshold": { - "type": "number" - }, - "gameId": { - "type": "string" - }, - "grpcServiceName": { - "type": "string" - }, - "selectedDisks": { - "type": "array", - "items": { - "type": "string" - } - }, - "group": { - "type": "string", - "nullable": true, - "maxLength": 50 - }, - "geoCheckEnabled": { - "type": "boolean" - }, - "geoCheckLocations": { - "type": "array", - "items": { - "type": "string", - "enum": ["northAmerica", "southAmerica", "europe", "asia", "africa", "oceania"] - } - }, - "geoCheckInterval": { - "type": "integer", - "minimum": 300000 - } - } - }, - "Monitor": { - "type": "object", - "properties": { - "_id": { - "type": "string", - "example": "64f123a456b789012c345def" - }, - "userId": { - "type": "string" - }, - "teamId": { - "type": "string" - }, - "name": { - "type": "string", - "example": "My Website Monitor" - }, - "description": { - "type": "string", - "example": "Monitors the main website homepage" - }, - "type": { - "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port"] - }, - "url": { - "type": "string", - "format": "uri", - "example": "https://example.com" - }, - "interval": { - "type": "integer", - "example": 300 - }, - "isActive": { - "type": "boolean", - "example": true - }, - "status": { - "type": "boolean", - "description": "Current monitor status (up/down)" - }, - "lastChecked": { - "type": "string", - "format": "date-time", - "description": "Timestamp of last check" - }, - "notifications": { - "type": "array", - "items": { - "type": "string" - } - }, - "httpOptions": { - "type": "object", - "properties": { - "method": { - "type": "string" - }, - "headers": { - "type": "object" - }, - "body": { - "type": "string" - }, - "timeout": { - "type": "integer" + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } } } }, - "assertions": { - "type": "array", - "items": { - "type": "object" + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } } }, - "createdAt": { - "type": "string", - "format": "date-time" + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } }, - "updatedAt": { - "type": "string", - "format": "date-time" + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } } } - }, - "CreateNotificationRequest": { - "type": "object", - "required": ["type", "name"], - "properties": { - "type": { - "type": "string", - "enum": ["email", "webhook", "slack", "discord", "telegram", "zapier"], - "example": "email" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100, - "example": "Admin Email Notifications" - }, - "config": { - "type": "object", - "description": "Configuration specific to notification type", - "oneOf": [ - { - "title": "Email Configuration", + } + }, + "/settings/test-email": { + "post": { + "tags": ["settings"], + "summary": "Send a test email using current SMTP settings (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", "properties": { "to": { + "type": "string", + "minLength": 1 + }, + "systemEmailHost": { + "type": "string" + }, + "systemEmailPort": { + "type": "number" + }, + "systemEmailSecure": { + "type": "boolean" + }, + "systemEmailPool": { + "type": "boolean" + }, + "systemEmailAddress": { + "type": "string" + }, + "systemEmailDisplayName": { + "type": "string" + }, + "systemEmailPassword": { + "type": "string" + }, + "systemEmailUser": { + "type": "string" + }, + "systemEmailConnectionHost": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "systemEmailIgnoreTLS": { + "type": "boolean" + }, + "systemEmailRequireTLS": { + "type": "boolean" + }, + "systemEmailRejectUnauthorized": { + "type": "boolean" + }, + "systemEmailTLSServername": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + } + }, + "required": ["to"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/status-page/team": { + "get": { + "tags": ["status-page"], + "summary": "List status pages for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/status-page": { + "post": { + "tags": ["status-page"], + "summary": "Create a status page (logo upload optional)", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "type": { + "anyOf": [ + { + "type": "string", + "enum": ["uptime", "infrastructure"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["uptime", "infrastructure"] + } + } + ] + }, + "companyName": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "timezone": { + "type": "string" + }, + "color": { + "type": "string" + }, + "monitors": { "type": "array", "items": { "type": "string", - "format": "email" + "pattern": "^[0-9a-fA-F]{24}$" + }, + "minItems": 1 + }, + "subMonitors": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$" } + }, + "deleteSubmonitors": { + "type": "boolean" + }, + "isPublished": { + "type": "boolean" + }, + "showCharts": { + "type": "boolean" + }, + "showUptimePercentage": { + "type": "boolean" + }, + "showAdminLoginLink": { + "type": "boolean" + }, + "showInfrastructure": { + "type": "boolean" + }, + "removeLogo": { + "anyOf": [ + { + "type": "string", + "enum": ["true"] + }, + { + "type": "string", + "enum": ["false"] + } + ] + }, + "theme": { + "type": "string", + "enum": ["refined", "modern", "bold", "editorial"] + }, + "themeMode": { + "type": "string", + "enum": ["auto", "light", "dark"] + }, + "logo": { + "type": "string", + "format": "binary" } + }, + "required": ["type", "companyName", "url", "monitors", "isPublished", "showUptimePercentage", "logo"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] } - }, - { - "title": "Webhook Configuration", + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/status-page/{id}": { + "put": { + "tags": ["status-page"], + "summary": "Update a status page (logo upload optional)", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", "properties": { + "type": { + "anyOf": [ + { + "type": "string", + "enum": ["uptime", "infrastructure"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["uptime", "infrastructure"] + } + } + ] + }, + "companyName": { + "type": "string", + "minLength": 1 + }, "url": { "type": "string", - "format": "uri" + "pattern": "^[a-zA-Z0-9_-]+$" }, - "headers": { - "type": "object", - "additionalProperties": { + "timezone": { + "type": "string" + }, + "color": { + "type": "string" + }, + "monitors": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$" + }, + "minItems": 1 + }, + "subMonitors": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$" + } + }, + "deleteSubmonitors": { + "type": "boolean" + }, + "isPublished": { + "type": "boolean" + }, + "showCharts": { + "type": "boolean" + }, + "showUptimePercentage": { + "type": "boolean" + }, + "showAdminLoginLink": { + "type": "boolean" + }, + "showInfrastructure": { + "type": "boolean" + }, + "removeLogo": { + "anyOf": [ + { + "type": "string", + "enum": ["true"] + }, + { + "type": "string", + "enum": ["false"] + } + ] + }, + "theme": { + "type": "string", + "enum": ["refined", "modern", "bold", "editorial"] + }, + "themeMode": { + "type": "string", + "enum": ["auto", "light", "dark"] + }, + "logo": { + "type": "string", + "format": "binary" + } + }, + "required": ["type", "companyName", "url", "monitors", "isPublished", "showUptimePercentage", "logo"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["status-page"], + "summary": "Delete a status page", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { "type": "string" } - } - } - }, - { - "title": "Slack Configuration", - "properties": { - "webhookUrl": { - "type": "string", - "format": "uri" }, - "channel": { - "type": "string" - } + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" } - }, - { - "title": "Discord Configuration", - "properties": { - "webhookUrl": { + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/status-page/{url}": { + "get": { + "tags": ["status-page"], + "summary": "Get a public status page by its URL slug", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "url", + "in": "path" + }, + { + "schema": { + "anyOf": [ + { + "type": "string", + "enum": ["uptime", "infrastructure"] + }, + { + "type": "array", + "items": { "type": "string", - "format": "uri" + "enum": ["uptime", "infrastructure"] } } - } - ] - }, - "isActive": { - "type": "boolean", - "default": true - } - } - }, - "Notification": { - "type": "object", - "properties": { - "_id": { - "type": "string" - }, - "userId": { - "type": "string" - }, - "teamId": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["email", "webhook", "slack", "discord", "telegram", "zapier"] - }, - "name": { - "type": "string" - }, - "config": { - "type": "object", - "description": "Type-specific configuration" - }, - "isActive": { - "type": "boolean" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "AppSettings": { - "type": "object", - "properties": { - "appName": { - "type": "string", - "example": "Checkmate" - }, - "appUrl": { - "type": "string", - "format": "uri", - "example": "https://checkmate.example.com" - }, - "logLevel": { - "type": "string", - "enum": ["error", "warn", "info", "debug"], - "default": "info" - }, - "emailConfig": { - "type": "object", - "properties": { - "host": { - "type": "string" - }, - "port": { - "type": "integer" - }, - "secure": { - "type": "boolean" - }, - "user": { - "type": "string" - }, - "pass": { - "type": "string", - "format": "password" - }, - "from": { - "type": "string", - "format": "email" - } - } - }, - "webhookRetries": { - "type": "integer", - "minimum": 0, - "maximum": 10, - "default": 3 - }, - "checksRetention": { - "type": "integer", - "minimum": 1, - "maximum": 365, - "default": 90, - "description": "Days to retain check results" - } - } - }, - "SystemDiagnostics": { - "type": "object", - "properties": { - "uptime": { - "type": "number", - "description": "System uptime in seconds" - }, - "memory": { - "type": "object", - "properties": { - "total": { - "type": "number", - "description": "Total memory in bytes" - }, - "used": { - "type": "number", - "description": "Used memory in bytes" - }, - "free": { - "type": "number", - "description": "Free memory in bytes" - } - } - }, - "cpu": { - "type": "object", - "properties": { - "usage": { - "type": "number", - "description": "CPU usage percentage" - }, - "cores": { - "type": "integer", - "description": "Number of CPU cores" - } - } - }, - "database": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["connected", "disconnected", "error"] - }, - "responseTime": { - "type": "number", - "description": "Database response time in milliseconds" - } - } - }, - "queue": { - "type": "object", - "properties": { - "active": { - "type": "integer" - }, - "waiting": { - "type": "integer" - }, - "completed": { - "type": "integer" - }, - "failed": { - "type": "integer" - } - } - } - } - }, - "Incident": { - "type": "object", - "properties": { - "_id": { - "type": "string", - "description": "Unique incident identifier" - }, - "monitorId": { - "type": "string", - "description": "ID of the associated monitor" - }, - "teamId": { - "type": "string", - "description": "ID of the team" - }, - "startTime": { - "type": "string", - "format": "date-time", - "description": "When the incident started" - }, - "endTime": { - "type": ["string", "null"], - "format": "date-time", - "description": "When the incident was resolved (null if ongoing)" - }, - "status": { - "type": "boolean", - "description": "Incident status (true = ongoing, false = resolved)" - }, - "message": { - "type": ["string", "null"], - "description": "Error message or description" - }, - "statusCode": { - "type": ["integer", "null"], - "description": "HTTP status code if applicable" - }, - "resolutionType": { - "type": ["string", "null"], - "enum": ["automatic", "manual", null], - "description": "How the incident was resolved" - }, - "resolvedBy": { - "type": ["string", "null"], - "description": "User ID who resolved the incident (for manual resolution)" - }, - "comment": { - "type": ["string", "null"], - "description": "Resolution comment" - }, - "checks": { - "type": "array", - "items": { - "type": "string" + ] }, - "description": "Array of check IDs associated with this incident" + "required": true, + "name": "type", + "in": "query" }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" + { + "schema": { + "type": "number", + "nullable": true + }, + "required": false, + "name": "timeFrame", + "in": "query" } - } - }, - "IncidentSummary": { - "type": "object", - "properties": { - "totalIncidents": { - "type": "integer", - "description": "Total number of incidents" - }, - "activeIncidents": { - "type": "integer", - "description": "Number of ongoing incidents" - }, - "resolvedIncidents": { - "type": "integer", - "description": "Number of resolved incidents" - }, - "averageResolutionTime": { - "type": "number", - "description": "Average time to resolve incidents in milliseconds" - }, - "incidentsByMonitor": { - "type": "array", - "items": { - "type": "object", - "properties": { - "monitorId": { - "type": "string" - }, - "monitorName": { - "type": "string" - }, - "count": { - "type": "integer" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] } } } - } - } - }, - "QueueJob": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Job ID" }, - "name": { - "type": "string", - "description": "Job name" + "404": { + "description": "Status page not found" }, - "data": { - "type": "object", - "description": "Job payload data" - }, - "status": { - "type": "string", - "enum": ["waiting", "active", "completed", "failed", "delayed"], - "description": "Current job status" - }, - "progress": { - "type": "number", - "description": "Job progress percentage" - }, - "attemptsMade": { - "type": "integer", - "description": "Number of attempts made" - }, - "timestamp": { - "type": "string", - "format": "date-time", - "description": "When the job was created" - }, - "processedOn": { - "type": ["string", "null"], - "format": "date-time", - "description": "When the job started processing" - }, - "finishedOn": { - "type": ["string", "null"], - "format": "date-time", - "description": "When the job finished" - } - } - }, - "QueueMetrics": { - "type": "object", - "properties": { - "waiting": { - "type": "integer", - "description": "Number of jobs waiting" - }, - "active": { - "type": "integer", - "description": "Number of jobs currently processing" - }, - "completed": { - "type": "integer", - "description": "Number of completed jobs" - }, - "failed": { - "type": "integer", - "description": "Number of failed jobs" - }, - "delayed": { - "type": "integer", - "description": "Number of delayed jobs" - } - } - }, - "Check": { - "type": "object", - "description": "A monitoring check result", - "properties": { - "_id": { - "type": "string", - "description": "Check ID" - }, - "metadata": { - "type": "object", - "properties": { - "monitorId": { - "type": "string", - "description": "Associated monitor ID" - }, - "teamId": { - "type": "string", - "description": "Associated team ID" - }, - "type": { - "type": "string", - "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port"], - "description": "Monitor type" - } - }, - "required": ["monitorId", "teamId", "type"] - }, - "status": { - "type": "boolean", - "description": "Check status (true = up, false = down)" - }, - "responseTime": { - "type": "number", - "description": "Response time in milliseconds" - }, - "statusCode": { - "type": "integer", - "description": "HTTP status code (for HTTP monitors)" - }, - "message": { - "type": "string", - "description": "Status message or error description" - }, - "timings": { - "$ref": "#/components/schemas/CheckTimings" - }, - "cpu": { - "$ref": "#/components/schemas/CheckCpuInfo" - }, - "memory": { - "$ref": "#/components/schemas/CheckMemoryInfo" - }, - "disk": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CheckDiskInfo" - } - }, - "host": { - "$ref": "#/components/schemas/CheckHostInfo" - }, - "errors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CheckErrorInfo" - } - }, - "capture": { - "$ref": "#/components/schemas/CheckCaptureInfo" - }, - "net": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CheckNetworkInfo" - } - }, - "accessibility": { - "type": "number", - "description": "PageSpeed accessibility score (0-100)" - }, - "bestPractices": { - "type": "number", - "description": "PageSpeed best practices score (0-100)" - }, - "seo": { - "type": "number", - "description": "PageSpeed SEO score (0-100)" - }, - "performance": { - "type": "number", - "description": "PageSpeed performance score (0-100)" - }, - "audits": { - "$ref": "#/components/schemas/CheckAudits" - }, - "ack": { - "type": "boolean", - "description": "Whether the check has been acknowledged" - }, - "ackAt": { - "type": ["string", "null"], - "format": "date-time", - "description": "When the check was acknowledged" - }, - "expiry": { - "type": "string", - "format": "date-time", - "description": "When the check expires" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "CheckTimings": { - "type": "object", - "description": "Detailed timing information for HTTP checks", - "properties": { - "start": { - "type": "number" - }, - "socket": { - "type": "number" - }, - "lookup": { - "type": "number" - }, - "connect": { - "type": "number" - }, - "secureConnect": { - "type": "number" - }, - "upload": { - "type": "number" - }, - "response": { - "type": "number" - }, - "end": { - "type": "number" - }, - "phases": { - "type": "object", - "properties": { - "wait": { - "type": "number" - }, - "dns": { - "type": "number" - }, - "tcp": { - "type": "number" - }, - "tls": { - "type": "number" - }, - "request": { - "type": "number" - }, - "firstByte": { - "type": "number" - }, - "download": { - "type": "number" - }, - "total": { - "type": "number" + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } } } } } - }, - "CheckCpuInfo": { - "type": "object", - "description": "CPU information for infrastructure checks", - "properties": { - "physical_core": { - "type": "integer" - }, - "logical_core": { - "type": "integer" - }, - "frequency": { - "type": "number" - }, - "temperature": { - "type": "array", - "items": { - "type": "number" - } - }, - "free_percent": { - "type": "number" - }, - "usage_percent": { - "type": "number" - } - } - }, - "CheckMemoryInfo": { - "type": "object", - "description": "Memory information for infrastructure checks", - "properties": { - "total_bytes": { - "type": "number" - }, - "available_bytes": { - "type": "number" - }, - "used_bytes": { - "type": "number" - }, - "usage_percent": { - "type": "number" - } - } - }, - "CheckDiskInfo": { - "type": "object", - "description": "Disk information for infrastructure checks", - "properties": { - "device": { - "type": "string" - }, - "mountpoint": { - "type": "string" - }, - "read_speed_bytes": { - "type": "number" - }, - "write_speed_bytes": { - "type": "number" - }, - "total_bytes": { - "type": "number" - }, - "free_bytes": { - "type": "number" - }, - "usage_percent": { - "type": "number" - } - } - }, - "CheckHostInfo": { - "type": "object", - "description": "Host information for infrastructure checks", - "properties": { - "os": { - "type": "string" - }, - "platform": { - "type": "string" - }, - "kernel_version": { - "type": "string" - } - } - }, - "CheckErrorInfo": { - "type": "object", - "description": "Error information from checks", - "properties": { - "metric": { - "type": "array", - "items": { - "type": "string" - } - }, - "err": { - "type": "string" - } - } - }, - "CheckCaptureInfo": { - "type": "object", - "description": "Capture agent information", - "properties": { - "version": { - "type": "string" - }, - "mode": { - "type": "string" - } - } - }, - "CheckNetworkInfo": { - "type": "object", - "description": "Network interface information", - "properties": { - "name": { - "type": "string" - }, - "bytes_sent": { - "type": "number" - }, - "bytes_recv": { - "type": "number" - }, - "packets_sent": { - "type": "number" - }, - "packets_recv": { - "type": "number" - }, - "err_in": { - "type": "number" - }, - "err_out": { - "type": "number" - }, - "drop_in": { - "type": "number" - }, - "drop_out": { - "type": "number" - }, - "fifo_in": { - "type": "number" - }, - "fifo_out": { - "type": "number" - } - } - }, - "CheckAudits": { - "type": "object", - "description": "PageSpeed audit results", - "properties": { - "cls": { - "$ref": "#/components/schemas/LighthouseAudit" - }, - "si": { - "$ref": "#/components/schemas/LighthouseAudit" - }, - "fcp": { - "$ref": "#/components/schemas/LighthouseAudit" - }, - "lcp": { - "$ref": "#/components/schemas/LighthouseAudit" - }, - "tbt": { - "$ref": "#/components/schemas/LighthouseAudit" - } - } - }, - "LighthouseAudit": { - "type": "object", - "description": "Individual Lighthouse audit result", - "properties": { - "id": { - "type": "string" - }, - "title": { - "type": "string" - }, - "score": { - "type": "number" - }, - "displayValue": { - "type": "string" - }, - "numericValue": { - "type": "number" - }, - "numericUnit": { - "type": "string" - } - } } } } diff --git a/server/openapi/helpers.ts b/server/openapi/helpers.ts new file mode 100644 index 000000000..bd3728836 --- /dev/null +++ b/server/openapi/helpers.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +export const successEnvelope = (data: T) => + z.object({ + success: z.literal(true), + msg: z.string(), + data, + }); + +export const successEnvelopeNoData = z.object({ + success: z.literal(true), + msg: z.string(), +}); + +export const errorEnvelope = z.object({ + success: z.literal(false), + msg: z.string(), + data: z.unknown().optional(), +}); + +export const bearer = [{ bearerAuth: [] }]; + +export const json = (schema: T, example?: unknown) => ({ + "application/json": example === undefined ? { schema } : { schema, example }, +}); + +export const standardErrors = { + "401": { description: "Unauthorized", content: json(errorEnvelope) }, + "403": { description: "Forbidden", content: json(errorEnvelope) }, + "500": { description: "Internal server error", content: json(errorEnvelope) }, +}; + +export const okJson = (data: T, description = "OK", example?: unknown) => ({ + description, + content: json(successEnvelope(data), example === undefined ? undefined : { success: true, msg: "OK", data: example }), +}); + +export const okJsonNoData = (description = "OK") => ({ + description, + content: json(successEnvelopeNoData, { success: true, msg: "OK" }), +}); + +export const okUnknown = okJson(z.unknown()); + +export const multipart = (fields: Record, fileField?: string) => { + const shape: Record = { ...fields }; + if (fileField) { + shape[fileField] = z.string().openapi({ type: "string", format: "binary" }); + } + return { + "multipart/form-data": { schema: z.object(shape) }, + }; +}; diff --git a/server/openapi/index.ts b/server/openapi/index.ts new file mode 100644 index 000000000..9758c9d54 --- /dev/null +++ b/server/openapi/index.ts @@ -0,0 +1,56 @@ +import "./registry.js"; +import "./routes/auth.js"; +import "./routes/check.js"; +import "./routes/diagnostic.js"; +import "./routes/geoCheck.js"; +import "./routes/incident.js"; +import "./routes/invite.js"; +import "./routes/log.js"; +import "./routes/maintenanceWindow.js"; +import "./routes/monitor.js"; +import "./routes/notification.js"; +import "./routes/queue.js"; +import "./routes/settings.js"; +import "./routes/statusPage.js"; + +import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; +import type { JsonObject } from "swagger-ui-express"; +import { registry } from "./registry.js"; + +let cached: JsonObject | null = null; + +export function getOpenApiSpec(): JsonObject { + if (cached) return cached; + + const generator = new OpenApiGeneratorV3(registry.definitions); + const document = generator.generateDocument({ + openapi: "3.0.0", + info: { + title: "Checkmate API", + version: "1.0.0", + description: "Generated from Zod validation schemas. Source of truth: server/src/validation/*.ts", + }, + servers: [{ url: "/api/v1", description: "Current server" }], + tags: [ + { name: "auth", description: "Sign up, log in, password reset, and the authenticated user's profile." }, + { name: "monitors", description: "Configure and inspect monitors (HTTP, port, ping, hardware, docker, game, gRPC) for the caller's team." }, + { name: "checks", description: "Individual monitoring check results, including history, summaries, and bulk deletes." }, + { name: "geo-checks", description: "Geographic check results for monitors with multi-region probing enabled." }, + { name: "incidents", description: "Downtime incidents derived from monitor state changes, including filtering and CSV export." }, + { + name: "notifications", + description: "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts.", + }, + { name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." }, + { name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." }, + { name: "settings", description: "Global application settings (admin/superadmin)." }, + { name: "invite", description: "Team invitations and accepting them." }, + { name: "queue", description: "Background job queue introspection and admin actions (admin/superadmin)." }, + { name: "diagnostic", description: "System diagnostics for the running server (admin/superadmin)." }, + { name: "logs", description: "Application logs for the running server (admin/superadmin)." }, + ], + }); + + cached = document as unknown as JsonObject; + return cached; +} diff --git a/server/openapi/registry.ts b/server/openapi/registry.ts new file mode 100644 index 000000000..a733e61b2 --- /dev/null +++ b/server/openapi/registry.ts @@ -0,0 +1,12 @@ +import { OpenAPIRegistry, extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; +import { z } from "zod"; + +extendZodWithOpenApi(z); + +export const registry = new OpenAPIRegistry(); + +registry.registerComponent("securitySchemes", "bearerAuth", { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", +}); diff --git a/server/openapi/routes/auth.ts b/server/openapi/routes/auth.ts new file mode 100644 index 000000000..66b74382b --- /dev/null +++ b/server/openapi/routes/auth.ts @@ -0,0 +1,207 @@ +import { z } from "zod"; +import { registry } from "../registry.js"; +import { bearer, json, multipart, okJson, okJsonNoData, standardErrors } from "../helpers.js"; +import { + loginValidation, + registrationBodyValidation, + recoveryValidation, + recoveryTokenBodyValidation, + newPasswordValidation, +} from "@/validation/authValidation.js"; +import { + getUserByIdParamValidation, + editUserByIdParamValidation, + editUserByIdBodyValidation, + editUserPasswordByIdBodyValidation, + createUserBodyValidation, + editUserBodyValidation, + userResponseSchema, +} from "@/validation/userValidation.js"; + +const tags = ["auth"]; + +const userExample = { + _id: "65f1c2a4d8b9e0123456789a", + firstName: "Ada", + lastName: "Lovelace", + email: "ada@example.com", + role: ["admin"], + teamId: "65f1c2a4d8b9e01234567890", + createdAt: "2026-04-01T10:00:00.000Z", + updatedAt: "2026-04-15T14:30:00.000Z", +}; + +const userObject = userResponseSchema.openapi("User", { example: userExample }); + +const authPayload = z + .object({ + user: userObject, + token: z.string().optional(), + }) + .openapi("AuthPayload", { + example: { + user: userExample, + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + }, + }); + +registry.registerPath({ + method: "post", + path: "/auth/register", + tags, + summary: "Register a new user (first user becomes superadmin)", + request: { body: { content: multipart(registrationBodyValidation.shape, "profileImage") } }, + responses: { "200": okJson(authPayload), ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/auth/login", + tags, + summary: "Log in", + request: { + body: { + content: json(loginValidation, { email: "ada@example.com", password: "S3cure!Passw0rd" }), + }, + }, + responses: { + "200": okJson(authPayload, "OK", { + user: { + _id: "65f1c2a4d8b9e0123456789a", + firstName: "Ada", + lastName: "Lovelace", + email: "ada@example.com", + role: ["admin"], + teamId: "65f1c2a4d8b9e01234567890", + createdAt: "2026-04-01T10:00:00.000Z", + updatedAt: "2026-04-15T14:30:00.000Z", + }, + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + }), + "401": { description: "Invalid credentials", content: json(z.object({ success: z.literal(false), msg: z.string() })) }, + "500": standardErrors["500"], + }, +}); + +registry.registerPath({ + method: "post", + path: "/auth/recovery/request", + tags, + summary: "Request a password recovery email", + request: { body: { content: json(recoveryValidation, { email: "ada@example.com" }) } }, + responses: { "200": okJsonNoData(), "500": standardErrors["500"] }, +}); + +registry.registerPath({ + method: "post", + path: "/auth/recovery/validate", + tags, + summary: "Validate a password recovery token", + request: { body: { content: json(recoveryTokenBodyValidation) } }, + responses: { "200": okJsonNoData(), "500": standardErrors["500"] }, +}); + +registry.registerPath({ + method: "post", + path: "/auth/recovery/reset", + tags, + summary: "Reset password using a recovery token", + request: { body: { content: json(newPasswordValidation) } }, + responses: { "200": okJsonNoData(), "500": standardErrors["500"] }, +}); + +registry.registerPath({ + method: "get", + path: "/auth/users/superadmin", + tags, + summary: "Check whether a superadmin user exists", + responses: { "200": okJson(z.object({ superAdminExists: z.boolean() })), "500": standardErrors["500"] }, +}); + +registry.registerPath({ + method: "get", + path: "/auth/users", + tags, + summary: "List all users (admin/superadmin)", + security: bearer, + responses: { "200": okJson(z.array(userObject)), ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/auth/users", + tags, + summary: "Create a new user (superadmin)", + security: bearer, + request: { body: { content: multipart(createUserBodyValidation.shape, "profileImage") } }, + responses: { "201": okJson(userObject, "User created"), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/auth/users/{userId}", + tags, + summary: "Get a user by id", + security: bearer, + request: { params: getUserByIdParamValidation }, + responses: { + "200": okJson(userObject), + "404": { description: "User not found", content: json(z.object({ success: z.literal(false), msg: z.string() })) }, + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "patch", + path: "/auth/users/{userId}", + tags, + summary: "Edit a user (superadmin)", + security: bearer, + request: { + params: editUserByIdParamValidation, + body: { content: json(editUserByIdBodyValidation) }, + }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/auth/users/{userId}/password", + tags, + summary: "Change a user's password (superadmin)", + security: bearer, + request: { + params: editUserByIdParamValidation, + body: { content: json(editUserPasswordByIdBodyValidation) }, + }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/auth/users/{userId}", + tags, + summary: "Delete a user (admin/superadmin)", + security: bearer, + request: { params: getUserByIdParamValidation }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/auth/user", + tags, + summary: "Edit the currently authenticated user", + security: bearer, + request: { body: { content: multipart(editUserBodyValidation.shape, "profileImage") } }, + responses: { "200": okJson(userObject), ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/auth/user", + tags, + summary: "Delete the currently authenticated user", + security: bearer, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/openapi/routes/check.ts b/server/openapi/routes/check.ts new file mode 100644 index 000000000..a9b6b3804 --- /dev/null +++ b/server/openapi/routes/check.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; +import { registry } from "../registry.js"; +import { bearer, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { + getChecksParamValidation, + getChecksQueryValidation, + getTeamChecksQueryValidation, + deleteChecksParamValidation, + getChecksSummaryByTeamIdQueryValidation, +} from "@/validation/checkValidation.js"; + +const tags = ["checks"]; + +registry.registerPath({ + method: "get", + path: "/checks/team/summary", + tags, + summary: "Aggregate check summary for the caller's team", + security: bearer, + request: { query: getChecksSummaryByTeamIdQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/checks/team", + tags, + summary: "List checks across the team", + security: bearer, + request: { query: getTeamChecksQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/checks/team", + tags, + summary: "Delete all checks for the team (admin/superadmin)", + security: bearer, + responses: { "200": okJson(z.object({ deletedCount: z.number() })), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/checks/{monitorId}", + tags, + summary: "List checks for a monitor", + security: bearer, + request: { params: getChecksParamValidation, query: getChecksQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/checks/{monitorId}", + tags, + summary: "Delete checks for a monitor", + security: bearer, + request: { params: deleteChecksParamValidation }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/openapi/routes/diagnostic.ts b/server/openapi/routes/diagnostic.ts new file mode 100644 index 000000000..49e384876 --- /dev/null +++ b/server/openapi/routes/diagnostic.ts @@ -0,0 +1,11 @@ +import { registry } from "../registry.js"; +import { bearer, okUnknown, standardErrors } from "../helpers.js"; + +registry.registerPath({ + method: "get", + path: "/diagnostic/system", + tags: ["diagnostic"], + summary: "Get system diagnostics (admin/superadmin)", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); diff --git a/server/openapi/routes/geoCheck.ts b/server/openapi/routes/geoCheck.ts new file mode 100644 index 000000000..7b90d0a0d --- /dev/null +++ b/server/openapi/routes/geoCheck.ts @@ -0,0 +1,13 @@ +import { registry } from "../registry.js"; +import { bearer, okUnknown, standardErrors } from "../helpers.js"; +import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js"; + +registry.registerPath({ + method: "get", + path: "/geo-checks/{monitorId}", + tags: ["geo-checks"], + summary: "Get geo check results for a monitor", + security: bearer, + request: { params: getChecksParamValidation, query: getChecksQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); diff --git a/server/openapi/routes/incident.ts b/server/openapi/routes/incident.ts new file mode 100644 index 000000000..6497a5bd0 --- /dev/null +++ b/server/openapi/routes/incident.ts @@ -0,0 +1,104 @@ +import { z } from "zod"; +import { registry } from "../registry.js"; +import { bearer, okJson, okJsonNoData, standardErrors } from "../helpers.js"; +import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js"; + +const tags = ["incidents"]; + +const incidentIdParam = z.object({ incidentId: z.string() }); + +const incidentObject = z + .object({ + id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789c" }), + monitorId: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }), + teamId: z.string().openapi({ example: "65f1c2a4d8b9e01234567890" }), + startTime: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }), + endTime: z.string().nullable().openapi({ example: "2026-04-15T03:34:00.000Z" }), + status: z.boolean().openapi({ example: true, description: "true = resolved, false = ongoing" }), + message: z.string().nullable().optional().openapi({ example: "HTTP 503 from origin" }), + statusCode: z.number().nullable().optional().openapi({ example: 503 }), + resolutionType: z.enum(["automatic", "manual"]).nullable().openapi({ example: "automatic" }), + resolvedBy: z.string().nullable().optional(), + resolvedByEmail: z.string().nullable().optional(), + comment: z.string().nullable().optional(), + createdAt: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }), + updatedAt: z.string().openapi({ example: "2026-04-15T03:34:00.000Z" }), + }) + .passthrough() + .openapi("Incident"); + +const incidentSummaryItem = z + .object({ + id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789c" }), + monitorId: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }), + monitorName: z.string().nullable().openapi({ example: "Marketing site" }), + status: z.boolean().openapi({ example: true }), + startTime: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }), + endTime: z.string().nullable().openapi({ example: "2026-04-15T03:34:00.000Z" }), + resolutionType: z.enum(["automatic", "manual"]).nullable().openapi({ example: "automatic" }), + message: z.string().nullable().openapi({ example: "HTTP 503 from origin" }), + statusCode: z.number().nullable().openapi({ example: 503 }), + createdAt: z.string().openapi({ example: "2026-04-15T03:21:00.000Z" }), + }) + .openapi("IncidentSummaryItem"); + +const incidentSummary = z + .object({ + total: z.number().openapi({ example: 12 }), + totalActive: z.number().openapi({ example: 1 }), + totalManualResolutions: z.number().openapi({ example: 2 }), + totalAutomaticResolutions: z.number().openapi({ example: 9 }), + avgResolutionTimeHours: z.number().openapi({ example: 0.5 }), + topMonitor: z + .object({ + monitorId: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }), + monitorName: z.string().nullable().openapi({ example: "Marketing site" }), + incidentCount: z.number().openapi({ example: 4 }), + }) + .nullable(), + latestIncidents: z.array(incidentSummaryItem), + }) + .openapi("IncidentSummary"); + +registry.registerPath({ + method: "get", + path: "/incidents/team", + tags, + summary: "List incidents for the caller's team", + security: bearer, + request: { query: getIncidentsByTeamQueryValidation }, + responses: { "200": okJson(z.array(incidentObject)), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/incidents/team/summary", + tags, + summary: "Incident summary for the caller's team", + security: bearer, + request: { query: getIncidentSummaryQueryValidation }, + responses: { "200": okJson(incidentSummary), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/incidents/{incidentId}", + tags, + summary: "Get an incident by id", + security: bearer, + request: { params: incidentIdParam }, + responses: { + "200": okJson(z.object({ incident: incidentObject, monitor: z.unknown(), user: z.unknown().nullable() })), + ...standardErrors, + }, +}); + +registry.registerPath({ + method: "put", + path: "/incidents/{incidentId}/resolve", + tags, + summary: "Manually resolve an incident (admin/superadmin)", + security: bearer, + request: { params: incidentIdParam }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/openapi/routes/invite.ts b/server/openapi/routes/invite.ts new file mode 100644 index 000000000..bf9b568b3 --- /dev/null +++ b/server/openapi/routes/invite.ts @@ -0,0 +1,35 @@ +import { registry } from "../registry.js"; +import { bearer, json, okJson, okJsonNoData, standardErrors } from "../helpers.js"; +import { z } from "zod"; +import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js"; + +const tags = ["invite"]; + +registry.registerPath({ + method: "post", + path: "/invite/send", + tags, + summary: "Send an invite email (admin/superadmin)", + security: bearer, + request: { body: { content: json(inviteBodyValidation) } }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/invite/verify", + tags, + summary: "Verify an invite token", + request: { body: { content: json(inviteVerificationBodyValidation) } }, + responses: { "200": okJson(z.object({ email: z.string(), role: z.array(z.string()).optional() }).passthrough()), "500": standardErrors["500"] }, +}); + +registry.registerPath({ + method: "post", + path: "/invite", + tags, + summary: "Create an invite token (admin/superadmin)", + security: bearer, + request: { body: { content: json(inviteBodyValidation) } }, + responses: { "200": okJson(z.object({ token: z.string() }).passthrough()), ...standardErrors }, +}); diff --git a/server/openapi/routes/log.ts b/server/openapi/routes/log.ts new file mode 100644 index 000000000..8fc043ff7 --- /dev/null +++ b/server/openapi/routes/log.ts @@ -0,0 +1,11 @@ +import { registry } from "../registry.js"; +import { bearer, okUnknown, standardErrors } from "../helpers.js"; + +registry.registerPath({ + method: "get", + path: "/logs", + tags: ["logs"], + summary: "Get application logs (admin/superadmin)", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); diff --git a/server/openapi/routes/maintenanceWindow.ts b/server/openapi/routes/maintenanceWindow.ts new file mode 100644 index 000000000..6afc57031 --- /dev/null +++ b/server/openapi/routes/maintenanceWindow.ts @@ -0,0 +1,73 @@ +import { registry } from "../registry.js"; +import { bearer, json, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { + createMaintenanceWindowBodyValidation, + getMaintenanceWindowByIdParamValidation, + getMaintenanceWindowsByTeamIdQueryValidation, + getMaintenanceWindowsByMonitorIdParamValidation, + deleteMaintenanceWindowByIdParamValidation, + editMaintenanceWindowByIdParamValidation, + editMaintenanceByIdWindowBodyValidation, +} from "@/validation/maintenanceWindowValidation.js"; + +const tags = ["maintenance-window"]; + +registry.registerPath({ + method: "post", + path: "/maintenance-window", + tags, + summary: "Create one or more maintenance windows", + security: bearer, + request: { body: { content: json(createMaintenanceWindowBodyValidation) } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/maintenance-window/team", + tags, + summary: "List maintenance windows for the caller's team", + security: bearer, + request: { query: getMaintenanceWindowsByTeamIdQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/maintenance-window/monitor/{monitorId}", + tags, + summary: "List maintenance windows for a monitor", + security: bearer, + request: { params: getMaintenanceWindowsByMonitorIdParamValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/maintenance-window/{id}", + tags, + summary: "Get a maintenance window by id", + security: bearer, + request: { params: getMaintenanceWindowByIdParamValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/maintenance-window/{id}", + tags, + summary: "Edit a maintenance window", + security: bearer, + request: { params: editMaintenanceWindowByIdParamValidation, body: { content: json(editMaintenanceByIdWindowBodyValidation) } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/maintenance-window/{id}", + tags, + summary: "Delete a maintenance window", + security: bearer, + request: { params: deleteMaintenanceWindowByIdParamValidation }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts new file mode 100644 index 000000000..10f988fa8 --- /dev/null +++ b/server/openapi/routes/monitor.ts @@ -0,0 +1,238 @@ +import { z } from "zod"; +import { registry } from "../registry.js"; +import { bearer, json, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { + getMonitorByIdParamValidation, + getMonitorByIdQueryValidation, + getMonitorsByTeamIdQueryValidation, + getMonitorsWithChecksQueryValidation, + getCertificateParamValidation, + createMonitorBodyValidation, + editMonitorBodyValidation, + pauseMonitorParamValidation, + getUptimeDetailsByIdParamValidation, + getUptimeDetailsByIdQueryValidation, + importMonitorsBodyValidation, + getHardwareDetailsByIdParamValidation, + getHardwareDetailsByIdQueryValidation, + monitorResponseSchema, +} from "@/validation/monitorValidation.js"; +import { updateNotificationsValidation } from "@/validation/notificationValidation.js"; + +const tags = ["monitors"]; + +const monitorObject = monitorResponseSchema.openapi("Monitor", { + example: { + _id: "65f1c2a4d8b9e0123456789a", + name: "Marketing site", + description: "Production marketing site monitored from the EU region", + type: "http", + url: "https://www.example.com", + port: 443, + isActive: true, + interval: 60000, + status: "up", + statusWindowSize: 5, + statusWindowThreshold: 3, + ignoreTlsErrors: false, + useAdvancedMatching: false, + notifications: ["65f1c2a4d8b9e0123456789b"], + cpuAlertThreshold: 90, + memoryAlertThreshold: 90, + diskAlertThreshold: 90, + tempAlertThreshold: 80, + selectedDisks: [], + geoCheckEnabled: false, + geoCheckLocations: [], + geoCheckInterval: 300000, + teamId: "65f1c2a4d8b9e01234567890", + userId: "65f1c2a4d8b9e01234567891", + createdAt: "2026-04-01T10:00:00.000Z", + updatedAt: "2026-04-15T14:30:00.000Z", + }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/team", + tags, + summary: "List monitors for the caller's team", + security: bearer, + request: { query: getMonitorsByTeamIdQueryValidation }, + responses: { "200": okJson(z.array(monitorObject)), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/team/with-checks", + tags, + summary: "List team monitors with their most recent checks (paginated)", + security: bearer, + request: { query: getMonitorsWithChecksQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/team/groups", + tags, + summary: "List monitor groups for the caller's team", + security: bearer, + responses: { "200": okJson(z.array(z.string())), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/uptime/details/{monitorId}", + tags, + summary: "Get uptime details for a monitor", + security: bearer, + request: { params: getUptimeDetailsByIdParamValidation, query: getUptimeDetailsByIdQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/hardware/details/{monitorId}", + tags, + summary: "Get hardware metrics detail for a monitor", + security: bearer, + request: { params: getHardwareDetailsByIdParamValidation, query: getHardwareDetailsByIdQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/pagespeed/details/{monitorId}", + tags, + summary: "Get PageSpeed detail for a monitor", + security: bearer, + request: { params: getHardwareDetailsByIdParamValidation, query: getHardwareDetailsByIdQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/{monitorId}/geo-checks", + tags, + summary: "Get geo check results for a monitor", + security: bearer, + request: { params: getMonitorByIdParamValidation, query: getMonitorByIdQueryValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/monitors/pause/{monitorId}", + tags, + summary: "Toggle pause state for a monitor", + security: bearer, + request: { params: pauseMonitorParamValidation }, + responses: { "200": okJson(monitorObject), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/certificate/{monitorId}", + tags, + summary: "Get SSL certificate info for a monitor", + security: bearer, + request: { params: getCertificateParamValidation }, + responses: { "200": okJson(z.object({ certificateDate: z.string() })), ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/monitors/notifications", + tags, + summary: "Bulk update notifications across monitors", + security: bearer, + request: { body: { content: json(updateNotificationsValidation) } }, + responses: { "200": okJson(z.object({ modifiedCount: z.number() })), ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/monitors", + tags, + summary: "Create a new monitor", + security: bearer, + request: { body: { content: json(createMonitorBodyValidation) } }, + responses: { "200": okJson(monitorObject, "Monitor created"), ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/monitors", + tags, + summary: "Delete every monitor (superadmin only)", + security: bearer, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/monitors/demo", + tags, + summary: "Insert preconfigured demo monitors", + security: bearer, + responses: { "200": okJson(z.number()), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/export/json", + tags, + summary: "Export all team monitors as JSON", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/monitors/import/json", + tags, + summary: "Import monitors from JSON", + security: bearer, + request: { body: { content: json(importMonitorsBodyValidation) } }, + responses: { "200": okJson(z.object({ imported: z.number() }).passthrough()), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/games", + tags, + summary: "List supported game-server types", + security: bearer, + responses: { "200": okJson(z.array(z.string())), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/monitors/{monitorId}", + tags, + summary: "Get a monitor by id", + security: bearer, + request: { params: getMonitorByIdParamValidation }, + responses: { "200": okJson(monitorObject), ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/monitors/{monitorId}", + tags, + summary: "Edit a monitor", + security: bearer, + request: { params: getMonitorByIdParamValidation, body: { content: json(editMonitorBodyValidation) } }, + responses: { "200": okJson(monitorObject), ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/monitors/{monitorId}", + tags, + summary: "Delete a monitor", + security: bearer, + request: { params: getMonitorByIdParamValidation }, + responses: { "200": okJson(monitorObject), ...standardErrors }, +}); diff --git a/server/openapi/routes/notification.ts b/server/openapi/routes/notification.ts new file mode 100644 index 000000000..c66e387f6 --- /dev/null +++ b/server/openapi/routes/notification.ts @@ -0,0 +1,159 @@ +import { z } from "zod"; +import { registry } from "../registry.js"; +import { bearer, json, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { + createNotificationBodyValidation, + deleteNotificationParamValidation, + getNotificationByIdParamValidation, + editNotificationParamValidation, + testAllNotificationsBodyValidation, +} from "@/validation/notificationValidation.js"; + +const tags = ["notifications"]; + +// OpenAPI metadata for each notification variant. Keyed by the discriminator +// value in createNotificationBodyValidation; build fails loudly if a variant +// in the validator has no entry here, which doubles as drift detection. +const notificationVariantMeta: Record }> = { + email: { + component: "EmailNotification", + example: { notificationName: "Ops on-call email", type: "email", address: "alerts@example.com" }, + }, + webhook: { + component: "WebhookNotification", + example: { notificationName: "Custom webhook", type: "webhook", address: "https://example.com/hooks/checkmate" }, + }, + slack: { + component: "SlackNotification", + example: { notificationName: "#alerts", type: "slack", address: "https://hooks.slack.com/services/T000/B000/XXXX" }, + }, + discord: { + component: "DiscordNotification", + example: { notificationName: "#status", type: "discord", address: "https://discord.com/api/webhooks/123/abc" }, + }, + pager_duty: { + component: "PagerDutyNotification", + example: { notificationName: "PagerDuty primary", type: "pager_duty", address: "R01XXXXXXXXXXXXXXXXXXXXXXX" }, + }, + matrix: { + component: "MatrixNotification", + example: { + notificationName: "Matrix room", + type: "matrix", + homeserverUrl: "https://matrix.example.com", + roomId: "!abc123:example.com", + accessToken: "syt_xxx", + }, + }, + teams: { + component: "TeamsNotification", + example: { notificationName: "Teams ops channel", type: "teams", address: "https://outlook.office.com/webhook/..." }, + }, + telegram: { + component: "TelegramNotification", + example: { notificationName: "Telegram bot", type: "telegram", address: "-1001234567890", accessToken: "123456:ABC-DEF" }, + }, + pushover: { + component: "PushoverNotification", + example: { notificationName: "Pushover personal", type: "pushover", address: "u1234567890abcdef", accessToken: "a1234567890abcdef" }, + }, + twilio: { + component: "TwilioNotification", + example: { + notificationName: "Twilio SMS", + type: "twilio", + accountSid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + accessToken: "your-auth-token", + phone: "+15551234567", + twilioPhoneNumber: "+15557654321", + }, + }, + ntfy: { + component: "NtfyNotification", + example: { notificationName: "ntfy topic", type: "ntfy", address: "https://ntfy.sh", topic: "checkmate-alerts" }, + }, +}; + +const decoratedVariants = createNotificationBodyValidation.options.map((variant) => { + const typeField = variant.shape.type as z.ZodLiteral; + const type = typeField.value; + const meta = notificationVariantMeta[type]; + if (!meta) { + throw new Error(`Missing OpenAPI metadata for notification variant "${type}". Add an entry in notificationVariantMeta.`); + } + return variant.openapi(meta.component, { example: meta.example }); +}); + +const notificationBody = z + .discriminatedUnion("type", decoratedVariants as typeof createNotificationBodyValidation.options) + .openapi("NotificationChannelBody"); + +registry.registerPath({ + method: "post", + path: "/notifications", + tags, + summary: "Create a notification channel", + description: "Create a notification channel of any supported type. The `type` field discriminates the body shape.", + security: bearer, + request: { body: { content: json(notificationBody) } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/notifications/test/all", + tags, + summary: "Send a test alert through every notification channel for the team", + security: bearer, + request: { body: { content: json(testAllNotificationsBodyValidation) } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/notifications/test", + tags, + summary: "Send a test alert through a single notification channel", + security: bearer, + request: { body: { content: json(notificationBody) } }, + responses: { "200": okJson(z.object({ success: z.boolean() }).passthrough()), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/notifications/team", + tags, + summary: "List notification channels for the caller's team", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/notifications/{id}", + tags, + summary: "Get a notification channel by id", + security: bearer, + request: { params: getNotificationByIdParamValidation }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/notifications/{id}", + tags, + summary: "Delete a notification channel", + security: bearer, + request: { params: deleteNotificationParamValidation }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/notifications/{id}", + tags, + summary: "Edit a notification channel", + security: bearer, + request: { params: editNotificationParamValidation, body: { content: json(notificationBody) } }, + responses: { "200": okUnknown, ...standardErrors }, +}); diff --git a/server/openapi/routes/queue.ts b/server/openapi/routes/queue.ts new file mode 100644 index 000000000..f2970c22c --- /dev/null +++ b/server/openapi/routes/queue.ts @@ -0,0 +1,40 @@ +import { registry } from "../registry.js"; +import { bearer, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; + +const tags = ["queue"]; + +registry.registerPath({ + method: "get", + path: "/queue/jobs", + tags, + summary: "List queued monitor jobs (admin/superadmin)", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/queue/metrics", + tags, + summary: "Get queue runtime metrics (admin/superadmin)", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/queue/all-metrics", + tags, + summary: "Get queue metrics across all monitors (admin/superadmin)", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/queue/flush", + tags, + summary: "Flush the monitor job queue (admin/superadmin)", + security: bearer, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/openapi/routes/settings.ts b/server/openapi/routes/settings.ts new file mode 100644 index 000000000..1297c3e85 --- /dev/null +++ b/server/openapi/routes/settings.ts @@ -0,0 +1,35 @@ +import { registry } from "../registry.js"; +import { bearer, json, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js"; +import { sendTestEmailBodyValidation } from "@/validation/notificationValidation.js"; + +const tags = ["settings"]; + +registry.registerPath({ + method: "get", + path: "/settings", + tags, + summary: "Get application settings", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/settings", + tags, + summary: "Update application settings (admin/superadmin)", + security: bearer, + request: { body: { content: json(updateAppSettingsBodyValidation) } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/settings/test-email", + tags, + summary: "Send a test email using current SMTP settings (admin/superadmin)", + security: bearer, + request: { body: { content: json(sendTestEmailBodyValidation) } }, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/openapi/routes/statusPage.ts b/server/openapi/routes/statusPage.ts new file mode 100644 index 000000000..ac315b31c --- /dev/null +++ b/server/openapi/routes/statusPage.ts @@ -0,0 +1,52 @@ +import { registry } from "../registry.js"; +import { bearer, multipart, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation } from "@/validation/statusPageValidation.js"; + +const tags = ["status-page"]; + +registry.registerPath({ + method: "get", + path: "/status-page/team", + tags, + summary: "List status pages for the caller's team", + security: bearer, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "post", + path: "/status-page", + tags, + summary: "Create a status page (logo upload optional)", + security: bearer, + request: { body: { content: multipart(createStatusPageBodyValidation.shape, "logo") } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "put", + path: "/status-page/{id}", + tags, + summary: "Update a status page (logo upload optional)", + security: bearer, + request: { body: { content: multipart(createStatusPageBodyValidation.shape, "logo") } }, + responses: { "200": okUnknown, ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/status-page/{url}", + tags, + summary: "Get a public status page by its URL slug", + request: { params: getStatusPageParamValidation, query: getStatusPageQueryValidation }, + responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] }, +}); + +registry.registerPath({ + method: "delete", + path: "/status-page/{id}", + tags, + summary: "Delete a status page", + security: bearer, + responses: { "200": okJsonNoData(), ...standardErrors }, +}); diff --git a/server/package-lock.json b/server/package-lock.json index 8423aa8d0..0f16e6b5d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -12,16 +12,16 @@ "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", "@pulsecron/pulse": "1.6.8", - "axios": "^1.7.2", + "axios": "^1.15.2", "bcryptjs": "3.0.2", "cacheable-lookup": "7.0.0", "compression": "1.8.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", - "dockerode": "4.0.6", + "dockerode": "5.0.0", "dotenv": "^16.4.5", "express": "^4.19.2", - "express-rate-limit": "8.3.2", + "express-rate-limit": "^8.5.1", "gamedig": "5.2.0", "got": "14.4.7", "handlebars": "^4.7.8", @@ -31,21 +31,23 @@ "jmespath": "^0.16.0", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", + "less-simple-scheduler": "2.6.0", "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", - "multer": "^1.4.5-lts.1", + "multer": "^1.4.5-lts.2", "nodemailer": "8.0.5", "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", - "super-simple-scheduler": "1.9.1", + "super-simple-scheduler": "1.9.2", "swagger-ui-express": "5.0.1", "winston": "^3.13.0", "ws": "^8.19.0", "zod": "^4.3.6" }, "devDependencies": { + "@asteasolutions/zod-to-openapi": "8.5.0", "@eslint/js": "^9.17.0", "@types/compression": "1.8.1", "@types/cookie-parser": "1.4.10", @@ -74,6 +76,7 @@ "lint-staged": "^16.2.7", "nodemon": "^3.1.11", "prettier": "^3.3.3", + "prettier-plugin-ini": "1.3.0", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "tsc-alias": "1.8.16", @@ -126,6 +129,19 @@ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "license": "MIT" }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz", + "integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -797,14 +813,15 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", - "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", + "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", - "fast-xml-parser": "5.5.8", + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.2", "tslib": "^2.6.2" }, "engines": { @@ -3179,6 +3196,19 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3253,25 +3283,24 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -3281,9 +3310,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -3299,9 +3328,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@pulsecron/pulse": { @@ -3743,9 +3772,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", - "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4838,9 +4867,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -5441,16 +5470,42 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", - "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", @@ -5700,9 +5755,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -5713,7 +5768,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -5906,9 +5961,9 @@ "license": "MIT" }, "node_modules/buildcheck": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", - "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", "optional": true, "engines": { "node": ">=10.0.0" @@ -7057,9 +7112,9 @@ } }, "node_modules/docker-modem": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", - "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", "license": "Apache-2.0", "dependencies": { "debug": "^4.1.1", @@ -7072,21 +7127,20 @@ } }, "node_modules/dockerode": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.6.tgz", - "integrity": "sha512-FbVf3Z8fY/kALB9s+P9epCpWhfi/r0N2DgYYcYpsAUlaTxPjdsitsFobnltb+lyCgAIvf9C+4PSWlTnHlJMf1w==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.0.tgz", + "integrity": "sha512-C52mvJ+7lcyhWNfrzVfFsbTrBfy/ezE9FGEYLpu17FUeBcCkxERk9nN7uDl/478ynDiQ4U+5DbQC2vENHkVEtQ==", "license": "Apache-2.0", "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", - "docker-modem": "^5.0.6", + "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", - "tar-fs": "~2.1.2", - "uuid": "^10.0.0" + "tar-fs": "^2.1.4" }, "engines": { - "node": ">= 8.0" + "node": ">= 14.17" } }, "node_modules/dockerode/node_modules/@grpc/proto-loader": { @@ -7107,19 +7161,6 @@ "node": ">=6" } }, - "node_modules/dockerode/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -7839,15 +7880,15 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -7866,7 +7907,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -7886,12 +7927,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -7976,9 +8017,9 @@ "license": "MIT" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "dev": true, "funding": [ { @@ -7988,13 +8029,14 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.1.3" + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" } }, "node_modules/fast-xml-parser": { - "version": "5.5.8", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", - "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", + "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", "dev": true, "funding": [ { @@ -8004,9 +8046,10 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.2.0", - "strnum": "^2.2.0" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -9083,9 +9126,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -10373,6 +10416,95 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/less-simple-scheduler": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/less-simple-scheduler/-/less-simple-scheduler-2.6.0.tgz", + "integrity": "sha512-Vi8ouiodLqe2fyh+DqCmxE5cCJZDI3xOyUAH4jcj9sf7GunLUzH71PBcYJy2P8lxTK3LxEkHtr1reymiogs6JQ==", + "license": "ISC", + "dependencies": { + "mongodb": "7.2.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/less-simple-scheduler/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==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/less-simple-scheduler/node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/less-simple-scheduler/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==", + "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/less-simple-scheduler/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==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -11529,9 +11661,9 @@ } }, "node_modules/mongoose": { - "version": "8.19.2", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.19.2.tgz", - "integrity": "sha512-ww2T4dBV+suCbOfG5YPwj9pLCfUVyj8FEA1D3Ux1HHqutpLxGyOYEPU06iPRBW4cKr3PJfOSYsIpHWPTkz5zig==", + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.1.tgz", + "integrity": "sha512-gHSPD8qEwRmiXapK17hEnFWZdcFENMegHTcw5XIIg2+7R8eXQvdwSiMpD/A2oG8tKzFLLHyRXd8/eaDPAVwZgQ==", "license": "MIT", "dependencies": { "bson": "^6.10.4", @@ -11611,9 +11743,9 @@ } }, "node_modules/nan": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", - "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", + "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", "license": "MIT", "optional": true }, @@ -11956,6 +12088,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi3-ts": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", + "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.8.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -12130,9 +12272,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz", - "integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "dev": true, "funding": [ { @@ -12329,9 +12471,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "funding": [ { "type": "opencollective", @@ -12966,6 +13108,16 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-plugin-ini": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-ini/-/prettier-plugin-ini-1.3.0.tgz", + "integrity": "sha512-kX1KnPwITCTtYV0MTBZQKuFLxcNcbHOS9r7Ta1jHLLvRvZRTzHHqX0MEZMrCkedjLuxpQSgzr4aC5IRd+R3FPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier": ">=3.0.0-alpha.3" + } + }, "node_modules/pretty-format": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", @@ -13001,24 +13153,24 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", - "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", + "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -13090,9 +13242,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -14186,9 +14338,9 @@ } }, "node_modules/strnum": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", - "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", "dev": true, "funding": [ { @@ -14215,18 +14367,18 @@ } }, "node_modules/super-simple-scheduler": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.9.1.tgz", - "integrity": "sha512-JSiEQLk7MsnlSkJ87uoyFKIseG2N27ikB0FSxlh25nnQHWC7R5Lrd0VcoLpwSBanyLzlAslhK4W2rQ0mCU8jCQ==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.9.2.tgz", + "integrity": "sha512-dfeI1R8jFLeHXIrNsDRxqvdJN2r4BjwKrvv/pPThu0X4RQa008/ubkFno96esHad04J6KyWYTK+P/qgOcoaReg==", "license": "MIT", "dependencies": { - "uuid": "11.1.0" + "uuid": "11.1.1" } }, "node_modules/super-simple-scheduler/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -15337,9 +15489,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -15366,6 +15518,22 @@ "node": ">=18" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlbuilder": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", @@ -15538,6 +15706,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/server/package.json b/server/package.json index 4251a2f9d..2812b9b1f 100755 --- a/server/package.json +++ b/server/package.json @@ -9,9 +9,13 @@ "test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/**/*.ts' test/unit", "test:unit:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications", "test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --no-coverage test/integration", - "dev": "nodemon --exec tsx src/index.js", "start": "node --watch ./dist/index.js", - "build": "tsc && tsc-alias && cp -r src/templates dist/templates", + "dev": "nodemon --exec tsx src/index.js", + "worker": "QUEUE_MODE=worker node ./dist/index.js", + "worker:dev": "QUEUE_MODE=worker nodemon --exec tsx src/index.js", + "clean": "rm -rf dist", + "build": "npm run clean && npm run build:openapi && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && cp -r src/templates dist/templates", + "build:openapi": "tsc --noEmit && tsx scripts/buildOpenApi.ts", "lint": "eslint .", "lint-fix": "eslint --fix .", "format": "prettier --write .", @@ -30,16 +34,16 @@ "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", "@pulsecron/pulse": "1.6.8", - "axios": "^1.7.2", + "axios": "^1.15.2", "bcryptjs": "3.0.2", "cacheable-lookup": "7.0.0", "compression": "1.8.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", - "dockerode": "4.0.6", + "dockerode": "5.0.0", "dotenv": "^16.4.5", "express": "^4.19.2", - "express-rate-limit": "8.3.2", + "express-rate-limit": "^8.5.1", "gamedig": "5.2.0", "got": "14.4.7", "handlebars": "^4.7.8", @@ -49,21 +53,23 @@ "jmespath": "^0.16.0", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", + "less-simple-scheduler": "2.6.0", "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", - "multer": "^1.4.5-lts.1", + "multer": "^1.4.5-lts.2", "nodemailer": "8.0.5", "ping": "0.4.4", "sharp": "0.33.5", "ssl-checker": "2.0.10", - "super-simple-scheduler": "1.9.1", + "super-simple-scheduler": "1.9.2", "swagger-ui-express": "5.0.1", "winston": "^3.13.0", "ws": "^8.19.0", "zod": "^4.3.6" }, "devDependencies": { + "@asteasolutions/zod-to-openapi": "8.5.0", "@eslint/js": "^9.17.0", "@types/compression": "1.8.1", "@types/cookie-parser": "1.4.10", @@ -92,6 +98,7 @@ "lint-staged": "^16.2.7", "nodemon": "^3.1.11", "prettier": "^3.3.3", + "prettier-plugin-ini": "1.3.0", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "tsc-alias": "1.8.16", diff --git a/server/scripts/buildOpenApi.ts b/server/scripts/buildOpenApi.ts new file mode 100644 index 000000000..4f02004b7 --- /dev/null +++ b/server/scripts/buildOpenApi.ts @@ -0,0 +1,10 @@ +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getOpenApiSpec } from "../openapi/index.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const outPath = path.resolve(__dirname, "..", "openapi.json"); + +writeFileSync(outPath, JSON.stringify(getOpenApiSpec(), null, 2) + "\n"); +console.log(`OpenAPI spec written to ${outPath}`); diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 00769f545..e757cfa22 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -9,6 +9,7 @@ import QueueController from "../controllers/queueController.js"; import LogController from "../controllers/logController.js"; import StatusPageController from "../controllers/statusPageController.js"; import NotificationController from "../controllers/notificationController.js"; +import TagsController from "../controllers/tagController.js"; import DiagnosticController from "../controllers/diagnosticController.js"; import IncidentController from "../controllers/incidentController.js"; import type { InitializedServices } from "@/config/services.js"; @@ -25,6 +26,7 @@ export interface InitializedControllers { logController: LogController; statusPageController: StatusPageController; notificationController: NotificationController; + tagController: TagsController; diagnosticController: DiagnosticController; incidentController: IncidentController; } @@ -41,6 +43,7 @@ export const initializeControllers = (services: InitializedServices): Initialize logController: new LogController(services.logger), statusPageController: new StatusPageController(services.statusPageService, services.monitorsRepository, services.settingsService), notificationController: new NotificationController(services.notificationsService, services.monitorsRepository), + tagController: new TagsController(services.tagsService), diagnosticController: new DiagnosticController(services.diagnosticService), incidentController: new IncidentController(services.incidentService), }; diff --git a/server/src/config/routes.ts b/server/src/config/routes.ts index f36f56e16..cff5e6b1d 100644 --- a/server/src/config/routes.ts +++ b/server/src/config/routes.ts @@ -17,6 +17,7 @@ import QueueRoutes from "../routes/queueRoute.js"; import LogRoutes from "../routes/logRoutes.js"; import DiagnosticRoutes from "../routes/diagnosticRoute.js"; import NotificationRoutes from "../routes/notificationRoute.js"; +import TagRoutes from "../routes/tagRoutes.js"; import IncidentRoutes from "../routes/incidentRoute.js"; @@ -34,6 +35,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController const verifyStatusPageAccess = createVerifyStatusPageAccess(services.statusPagesRepository, verifyJWT); const statusPageRoutes = new StatusPageRoutes(controllers.statusPageController, verifyJWT, verifyStatusPageAccess); const notificationRoutes = new NotificationRoutes(controllers.notificationController); + const tagRoutes = new TagRoutes(controllers.tagController); const diagnosticRoutes = new DiagnosticRoutes(controllers.diagnosticController, verifyJWT); const incidentRoutes = new IncidentRoutes(controllers.incidentController); @@ -48,6 +50,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController app.use("/api/v1/logs", verifyJWT, logRoutes.getRouter()); app.use("/api/v1/status-page", statusPageRoutes.getRouter()); app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); + app.use("/api/v1/tags", verifyJWT, tagRoutes.getRouter()); app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter()); app.use("/api/v1/incidents", verifyJWT, incidentRoutes.getRouter()); }; diff --git a/server/src/config/services.ts b/server/src/config/services.ts index b6cbb3a30..818ef1663 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -1,3 +1,4 @@ +import { Mongoose } from "mongoose"; import MongoDB from "../db/MongoDB.js"; import { IDb } from "@/db/IDb.js"; import { @@ -6,9 +7,11 @@ import { EmailService, BufferService, GlobalPingService, + LessSimpleQueue, SuperSimpleQueue, - SuperSimpleQueueHelper, + QueueHelper, NotificationsService, + TagsService, StatusService, NotificationMessageBuilder, MonitorService, @@ -31,12 +34,14 @@ import { TelegramProvider, PushoverProvider, TwilioProvider, + NtfyProvider, // Interfaces INetworkService, IEmailService, IBufferService, - ISuperSimpleQueue, + IJobQueue, INotificationsService, + ITagsService, IStatusService, IMonitorService, IUserService, @@ -64,8 +69,10 @@ import { PortProvider } from "@/service/infrastructure/network/PortProvider.js"; import { GameProvider } from "@/service/infrastructure/network/GameProvider.js"; import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js"; import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js"; +import { DNSProvider } from "@/service/infrastructure/network/DNSProvider.js"; // Third-party +import { Resolver } from "dns/promises"; import axios from "axios"; import got from "got"; import ping from "ping"; @@ -96,10 +103,12 @@ import { MongoInvitesRepository, MongoRecoveryTokensRepository, MongoNotificationsRepository, + MongoTagsRepository, MongoIncidentsRepository, MongoTeamsRepository, MongoMaintenanceWindowsRepository, MongoSettingsRepository, + MongoQueueWorkersRepository, IMonitorsRepository, IChecksRepository, IGeoChecksRepository, @@ -110,21 +119,23 @@ import { IRecoveryTokensRepository, ISettingsRepository, INotificationsRepository, + ITagsRepository, IIncidentsRepository, ITeamsRepository, IMaintenanceWindowsRepository, } from "@/repositories/index.js"; import { ILogger } from "@/utils/logger.js"; import { AppError } from "@/utils/AppError.js"; +import { type QueueMode } from "@/types/settings.js"; export type InitializedServices = { settingsService: ISettingsService; - db: IDb; + db: IDb; networkService: INetworkService; emailService: IEmailService; bufferService: IBufferService; statusService: IStatusService; - jobQueue: ISuperSimpleQueue; + jobQueue: IJobQueue; userService: IUserService; checkService: ICheckService; geoChecksService: IGeoChecksService; @@ -135,6 +146,7 @@ export type InitializedServices = { incidentService: IIncidentService; logger: ILogger; notificationsService: INotificationsService; + tagsService: ITagsService; statusPageService: IStatusPageService; notificationMessageBuilder: INotificationMessageBuilder; @@ -149,6 +161,7 @@ export type InitializedServices = { recoveryTokensRepository: IRecoveryTokensRepository; settingsRepository: ISettingsRepository; notificationsRepository: INotificationsRepository; + tagsRepository: ITagsRepository; incidentsRepository: IIncidentsRepository; teamsRepository: ITeamsRepository; maintenanceWindowsRepository: IMaintenanceWindowsRepository; @@ -158,60 +171,86 @@ export const initializeServices = async ({ logger, envSettings, settingsService, + queueMode = "primary", }: { logger: ILogger; envSettings: EnvConfig; settingsService: ISettingsService; + queueMode?: QueueMode; }): Promise => { // Create DB - const dbType = envSettings.dbType; - - let db: IDb | null = null; - - if (dbType === "mongodb") { - db = new MongoDB(logger, envSettings); - } - - if (!db) { - throw new AppError({ message: "Unsupported database type", status: 500 }); - } - + let db: IDb | null = null; + db = new MongoDB(logger, envSettings); await db.connect(); - let monitorsRepository: IMonitorsRepository; - let checksRepository: IChecksRepository; - let geoChecksRepository: IGeoChecksRepository; - let monitorStatsRepository: IMonitorStatsRepository; - let statusPagesRepository: IStatusPagesRepository; - let usersRepository: IUsersRepository; - let invitesRepository: IInvitesRepository; - let recoveryTokensRepository: IRecoveryTokensRepository; - let settingsRepository: ISettingsRepository; - let notificationsRepository: INotificationsRepository; - let incidentsRepository: IIncidentsRepository; - let teamsRepository: ITeamsRepository; - let maintenanceWindowsRepository: IMaintenanceWindowsRepository; + // ** NOTE ** + // const dbType = envSettings.dbType; + // DB type has been fixed to MongoDB for now + + // if (dbType === "mongodb") { + // db = new MongoDB(logger, envSettings); + // } + + // if (!db) { + // throw new AppError({ message: "Unsupported database type", status: 500 }); + // } + + // await db.connect(); // Repositories - if (dbType === "mongodb") { - monitorsRepository = new MongoMonitorsRepository(); - checksRepository = new MongoChecksRepository(logger); - geoChecksRepository = new MongoGeoChecksRepository(logger); - monitorStatsRepository = new MongoMonitorStatsRepository(); - statusPagesRepository = new MongoStatusPagesRepository(); - usersRepository = new MongoUsersRepository(); - invitesRepository = new MongoInvitesRepository(); - recoveryTokensRepository = new MongoRecoveryTokensRepository(); - settingsRepository = new MongoSettingsRepository(); - notificationsRepository = new MongoNotificationsRepository(); - incidentsRepository = new MongoIncidentsRepository(); - teamsRepository = new MongoTeamsRepository(); - maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); - } else { - throw new AppError({ message: "Unsupported database type", status: 500 }); - } + // ** NOTE ** + // DB Type fixed to MongoDB for now + // let monitorsRepository: IMonitorsRepository; + // let checksRepository: IChecksRepository; + // let geoChecksRepository: IGeoChecksRepository; + // let monitorStatsRepository: IMonitorStatsRepository; + // let statusPagesRepository: IStatusPagesRepository; + // let usersRepository: IUsersRepository; + // let invitesRepository: IInvitesRepository; + // let recoveryTokensRepository: IRecoveryTokensRepository; + // let settingsRepository: ISettingsRepository; + // let notificationsRepository: INotificationsRepository; + // let tagsRepository: ITagsRepository; + // let incidentsRepository: IIncidentsRepository; + // let teamsRepository: ITeamsRepository; + // let maintenanceWindowsRepository: IMaintenanceWindowsRepository; + + // if (dbType === "mongodb") { + // monitorsRepository = new MongoMonitorsRepository(); + // checksRepository = new MongoChecksRepository(logger); + // geoChecksRepository = new MongoGeoChecksRepository(logger); + // monitorStatsRepository = new MongoMonitorStatsRepository(); + // statusPagesRepository = new MongoStatusPagesRepository(); + // usersRepository = new MongoUsersRepository(); + // invitesRepository = new MongoInvitesRepository(); + // recoveryTokensRepository = new MongoRecoveryTokensRepository(); + // settingsRepository = new MongoSettingsRepository(); + // notificationsRepository = new MongoNotificationsRepository(); + // tagsRepository = new MongoTagsRepository(); + // incidentsRepository = new MongoIncidentsRepository(); + // teamsRepository = new MongoTeamsRepository(); + // maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); + // } else { + // throw new AppError({ message: "Unsupported database type", status: 500 }); + // } + + const monitorsRepository = new MongoMonitorsRepository(); + const checksRepository = new MongoChecksRepository(logger); + const geoChecksRepository = new MongoGeoChecksRepository(logger); + const monitorStatsRepository = new MongoMonitorStatsRepository(); + const statusPagesRepository = new MongoStatusPagesRepository(); + const usersRepository = new MongoUsersRepository(); + const invitesRepository = new MongoInvitesRepository(); + const recoveryTokensRepository = new MongoRecoveryTokensRepository(); + const settingsRepository = new MongoSettingsRepository(); + const notificationsRepository = new MongoNotificationsRepository(); + const tagsRepository = new MongoTagsRepository(); + const incidentsRepository = new MongoIncidentsRepository(); + const teamsRepository = new MongoTeamsRepository(); + const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); + const queueWorkersRepository = new MongoQueueWorkersRepository(); // Inject settings repository into settings service (now that DB is connected) (settingsService as SettingsService).setRepository(settingsRepository); @@ -226,6 +265,7 @@ export const initializeServices = async ({ const gameProvider = new GameProvider(logger, GameDig); const grpcProvider = new GrpcProvider(grpc, protoLoader); const webSocketProvider = new WebSocketProvider(WebSocket); + const dnsProvider = new DNSProvider(() => new Resolver()); const networkService = new NetworkService(axios, logger, [ pingProvider, @@ -237,6 +277,7 @@ export const initializeServices = async ({ gameProvider, grpcProvider, webSocketProvider, + dnsProvider, ]); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); @@ -270,6 +311,7 @@ export const initializeServices = async ({ const telegramProvider = new TelegramProvider(logger); const pushoverProvider = new PushoverProvider(logger); const twilioProvider = new TwilioProvider(logger); + const ntfyProvider = new NtfyProvider(logger); const notificationsService = new NotificationsService( notificationsRepository, @@ -284,12 +326,15 @@ export const initializeServices = async ({ telegramProvider, pushoverProvider, twilioProvider, + ntfyProvider, settingsService, logger, notificationMessageBuilder ); - const superSimpleQueueHelper = new SuperSimpleQueueHelper( + const tagsService = new TagsService(tagsRepository, monitorsRepository); + + const queueHelper = new QueueHelper( logger, networkService, statusService, @@ -308,7 +353,23 @@ export const initializeServices = async ({ geoChecksRepository ); - const superSimpleQueue = await SuperSimpleQueue.create(logger, superSimpleQueueHelper, monitorsRepository); + if (queueMode === "worker" && envSettings.queueType !== "lessSimpleQueue") { + throw new AppError({ + message: `Worker mode requires QUEUE_TYPE="lessSimpleQueue" got ${envSettings.queueType}`, + status: 500, + service: "config services", + method: "initializeServices", + }); + } + + let jobQueue: IJobQueue; + switch (envSettings.queueType) { + case "lessSimpleQueue": + jobQueue = await LessSimpleQueue.create(logger, queueHelper, monitorsRepository, queueWorkersRepository, envSettings, queueMode); + break; + default: + jobQueue = await SuperSimpleQueue.create(logger, queueHelper, monitorsRepository); + } // Business services const userService = new UserService({ @@ -317,7 +378,7 @@ export const initializeServices = async ({ settingsService, logger, jwt, - jobQueue: superSimpleQueue, + jobQueue, monitorsRepository, usersRepository, invitesRepository, @@ -326,7 +387,7 @@ export const initializeServices = async ({ teamsRepository, }); - const diagnosticService = new DiagnosticService(); + const diagnosticService = new DiagnosticService(db); const inviteService = new InviteService({ invitesRepository, settingsService, @@ -337,7 +398,7 @@ export const initializeServices = async ({ maintenanceWindowsRepository, }); const monitorService = new MonitorService({ - jobQueue: superSimpleQueue, + jobQueue, logger, games, monitorsRepository, @@ -357,7 +418,7 @@ export const initializeServices = async ({ emailService, bufferService, statusService, - jobQueue: superSimpleQueue, + jobQueue, userService, checkService, geoChecksService, @@ -368,6 +429,7 @@ export const initializeServices = async ({ incidentService, logger, notificationsService, + tagsService, statusPageService, notificationMessageBuilder, @@ -382,6 +444,7 @@ export const initializeServices = async ({ recoveryTokensRepository, settingsRepository, notificationsRepository, + tagsRepository, incidentsRepository, teamsRepository, maintenanceWindowsRepository, diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 7f1136b7c..25946ccdf 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -14,12 +14,12 @@ import { getUptimeDetailsByIdParamValidation, getUptimeDetailsByIdQueryValidation, importMonitorsBodyValidation, + bulkPauseMonitorBodyValidation, } from "@/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js"; import { AppError } from "@/utils/AppError.js"; import { IMonitorService, INotificationsService } from "@/service/index.js"; -import { GeoContinent } from "@/types/geoCheck.js"; const SERVICE_NAME = "monitorController"; @@ -36,6 +36,7 @@ export interface IMonitorController { deleteAllMonitors: (req: Request, res: Response, next: NextFunction) => Promise; editMonitor: (req: Request, res: Response, next: NextFunction) => Promise; pauseMonitor: (req: Request, res: Response, next: NextFunction) => Promise; + bulkPauseMonitors: (req: Request, res: Response, next: NextFunction) => Promise; addDemoMonitors: (req: Request, res: Response, next: NextFunction) => Promise; getMonitorsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise; getMonitorsWithChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise; @@ -307,6 +308,33 @@ class MonitorController implements IMonitorController { } }; + bulkPauseMonitors = async (req: Request, res: Response, next: NextFunction) => { + try { + const validatedBody = bulkPauseMonitorBodyValidation.parse(req.body); + + const { monitorIds, pause } = validatedBody; + const teamId = requireTeamId(req.user?.teamId); + + const { monitors, failedCount } = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause }); + + const action = pause ? "paused" : "resumed"; + const monitorStr = monitors.length === 1 ? "monitor" : "monitors"; + + let msg = `${monitors.length} ${monitorStr} ${action} successfully`; + if (failedCount > 0) { + msg = `${monitors.length} ${monitorStr} ${action} in database, but ${failedCount} failed to sync with the job queue. Please check logs.`; + } + + return res.status(200).json({ + success: true, + msg, + data: { monitors, failedCount }, + }); + } catch (error) { + next(error); + } + }; + addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => { try { const id = requireUserId(req.user?.id); @@ -329,9 +357,10 @@ class MonitorController implements IMonitorController { const teamId = requireTeamId(req.user?.teamId); const type = validatedQuery.type; + const tags = validatedQuery.tags; const filter = validatedQuery.filter; - const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, type, filter }); + const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, type, tags, filter }); return res.status(200).json({ success: true, @@ -354,12 +383,14 @@ class MonitorController implements IMonitorController { const field = validatedQuery.field; const order = validatedQuery.order; const type = validatedQuery.type; + const tags = validatedQuery.tags; const teamId = requireTeamId(req.user?.teamId); const monitors = await this.monitorService.getMonitorsWithChecksByTeamId({ teamId, limit, type, + tags, page, rowsPerPage, filter, diff --git a/server/src/controllers/queueController.ts b/server/src/controllers/queueController.ts index 52762f42b..3b0bb64ce 100755 --- a/server/src/controllers/queueController.ts +++ b/server/src/controllers/queueController.ts @@ -1,4 +1,5 @@ -import { ISuperSimpleQueue } from "@/service/index.js"; +import { IJobQueue } from "@/service/index.js"; +import { getQueueJobsQueryValidation } from "@/validation/queueValidation.js"; import { Request, Response, NextFunction } from "express"; const SERVICE_NAME = "JobQueueController"; @@ -12,8 +13,8 @@ export interface IJobQueueController { class JobQueueController implements IJobQueueController { static SERVICE_NAME = SERVICE_NAME; - private jobQueue: ISuperSimpleQueue; - constructor(jobQueue: ISuperSimpleQueue) { + private jobQueue: IJobQueue; + constructor(jobQueue: IJobQueue) { this.jobQueue = jobQueue; } @@ -36,11 +37,12 @@ class JobQueueController implements IJobQueueController { getJobs = async (req: Request, res: Response, next: NextFunction) => { try { - const jobs = await this.jobQueue.getJobs(); + const pagination = getQueueJobsQueryValidation.parse(req.query); + const { jobs, count } = await this.jobQueue.getJobs(pagination); return res.status(200).json({ success: true, msg: "Queue jobs fetched successfully", - data: jobs, + data: { jobs, count }, }); } catch (error) { next(error); @@ -49,12 +51,13 @@ class JobQueueController implements IJobQueueController { getAllMetrics = async (req: Request, res: Response, next: NextFunction) => { try { - const jobs = await this.jobQueue.getJobs(); + const pagination = getQueueJobsQueryValidation.parse(req.query); + const { jobs, count } = await this.jobQueue.getJobs(pagination); const metrics = await this.jobQueue.getMetrics(); return res.status(200).json({ success: true, msg: "Queue metrics fetched successfully", - data: { jobs, metrics }, + data: { jobs, count, metrics }, }); } catch (error) { next(error); diff --git a/server/src/controllers/settingsController.ts b/server/src/controllers/settingsController.ts index 03bc91898..485a90d64 100644 --- a/server/src/controllers/settingsController.ts +++ b/server/src/controllers/settingsController.ts @@ -89,6 +89,7 @@ class SettingsController implements ISettingsController { systemEmailHost, systemEmailPort, systemEmailAddress, + systemEmailDisplayName, systemEmailPassword, systemEmailUser, systemEmailConnectionHost, @@ -112,6 +113,7 @@ class SettingsController implements ISettingsController { systemEmailPort, systemEmailUser, systemEmailAddress, + systemEmailDisplayName, systemEmailPassword, systemEmailConnectionHost, systemEmailSecure, diff --git a/server/src/controllers/tagController.ts b/server/src/controllers/tagController.ts new file mode 100644 index 000000000..66b21dc6b --- /dev/null +++ b/server/src/controllers/tagController.ts @@ -0,0 +1,98 @@ +import { Request, Response, NextFunction } from "express"; +import { ITagsService } from "@/service/index.js"; +import { requireTeamId } from "./controllerUtils.js"; +import { + createTagBodyValidation, + editTagBodyValidation, + getTagByIdParamValidation, + editTagParamValidation, + deleteTagParamValidation, +} from "@/validation/index.js"; + +export interface ITagsController { + createTag: (req: Request, res: Response, next: NextFunction) => Promise; + getTagById: (req: Request, res: Response, next: NextFunction) => Promise; + getTagsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise; + editTag: (req: Request, res: Response, next: NextFunction) => Promise; + deleteTag: (req: Request, res: Response, next: NextFunction) => Promise; +} + +class TagsController implements ITagsController { + constructor(private tagsService: ITagsService) {} + + createTag = async (req: Request, res: Response, next: NextFunction) => { + try { + const validatedBody = createTagBodyValidation.parse(req.body); + const teamId = requireTeamId(req.user?.teamId); + + const tag = await this.tagsService.createTag(validatedBody, teamId); + return res.status(200).json({ + success: true, + msg: "Tag created successfully", + data: tag, + }); + } catch (error) { + next(error); + } + }; + getTagById = async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = requireTeamId(req.user?.teamId); + const { id: tagId } = getTagByIdParamValidation.parse(req.params); + const tag = await this.tagsService.getTag(tagId, teamId); + return res.status(200).json({ + success: true, + msg: "Tag retrieved successfully", + data: tag, + }); + } catch (error) { + next(error); + } + }; + + getTagsByTeamId = async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = requireTeamId(req.user?.teamId); + const tags = await this.tagsService.getTagsByTeamId(teamId); + return res.status(200).json({ + success: true, + msg: "Tags retrieved successfully", + data: tags, + }); + } catch (error) { + next(error); + } + }; + + editTag = async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = requireTeamId(req.user?.teamId); + const { id: tagId } = editTagParamValidation.parse(req.params); + const validatedBody = editTagBodyValidation.parse(req.body); + const updatedTag = await this.tagsService.updateTag(tagId, teamId, validatedBody); + return res.status(200).json({ + success: true, + msg: "Tag updated successfully", + data: updatedTag, + }); + } catch (error) { + next(error); + } + }; + + deleteTag = async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = requireTeamId(req.user?.teamId); + const { id: tagId } = deleteTagParamValidation.parse(req.params); + await this.tagsService.deleteTag(tagId, teamId); + return res.status(200).json({ + success: true, + msg: "Tag deleted successfully", + }); + } catch (error) { + next(error); + } + }; +} + +export default TagsController; diff --git a/server/src/db/IDb.ts b/server/src/db/IDb.ts index 4c719999f..837ad4cf9 100644 --- a/server/src/db/IDb.ts +++ b/server/src/db/IDb.ts @@ -1,4 +1,5 @@ -export interface IDb { +export interface IDb { connect(): Promise; disconnect(): Promise; + getConnection(): Promise; } diff --git a/server/src/db/MongoDB.ts b/server/src/db/MongoDB.ts index 1284aef4d..4531ce5a9 100755 --- a/server/src/db/MongoDB.ts +++ b/server/src/db/MongoDB.ts @@ -1,3 +1,4 @@ +import { Mongoose } from "mongoose"; import mongoose from "mongoose"; import AppSettings from "@/db/models/AppSettings.js"; import { runMigrations } from "@/db/migration/index.js"; @@ -5,16 +6,19 @@ import { ILogger } from "@/utils/logger.js"; import { EnvConfig } from "@/service/system/settingsService.js"; const SERVICE_NAME = "MongoDB"; import { IDb } from "@/db/IDb.js"; +import { AppError } from "@/utils/AppError.js"; -class MongoDB implements IDb { +class MongoDB implements IDb { static SERVICE_NAME = SERVICE_NAME; private logger: ILogger; private envSettings: EnvConfig; + private db: Mongoose | null; constructor(logger: ILogger, envSettings: EnvConfig) { this.logger = logger; this.envSettings = envSettings; + this.db = null; } get serviceName() { @@ -24,7 +28,7 @@ class MongoDB implements IDb { connect = async () => { try { const connectionString = this.envSettings.dbConnectionString || "mongodb://localhost:27017/uptime_db"; - await mongoose.connect(connectionString); + this.db = await mongoose.connect(connectionString); // If there are no AppSettings, create one // TODO why is this here? await AppSettings.findOneAndUpdate( {}, // empty filter to match any document @@ -63,6 +67,13 @@ class MongoDB implements IDb { } }; + getConnection = async (): Promise => { + if (this.db === null) { + throw new AppError({ message: "Database not connected", service: SERVICE_NAME, method: "getConnection" }); + } + return this.db; + }; + disconnect = async () => { try { this.logger.info({ message: "Disconnecting from MongoDB", service: SERVICE_NAME, method: "disconnect" }); diff --git a/server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts b/server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts new file mode 100644 index 000000000..a60b8786a --- /dev/null +++ b/server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts @@ -0,0 +1,111 @@ +import mongoose from "mongoose"; +import type { AnyBulkWriteOperation, Document } from "mongodb"; +import { logger } from "@/utils/logger.js"; +import MaintenanceWindowModel from "../models/MaintenanceWindow.js"; + +type GroupResult = { + _id: { + teamId: mongoose.Types.ObjectId; + name: string; + start: Date; + end: Date; + repeat: number; + durationUnit: string; + duration: number; + active: boolean; + }; + docIds: mongoose.Types.ObjectId[]; + monitorIds: mongoose.Types.ObjectId[]; +}; + +export async function migrateMaintenanceWindowMonitorIdToArray(): Promise { + const SERVICE_NAME = "Migration:MigrateMaintenanceWindowMonitorIdToArray"; + + try { + logger.info({ + service: SERVICE_NAME, + message: "Starting migration of MaintenanceWindow.monitorId to monitorIds array (eager merge)", + }); + + const db = mongoose.connection.db; + if (!db) { + throw new Error("Database connection is not initialized"); + } + + // Group old-style docs by their scheduling key. + // Skip rows that already have a monitorIds array — those are either already migrated + // or in a partially-migrated state that needs manual review. + const groups: GroupResult[] = await MaintenanceWindowModel.aggregate([ + { + $match: { + monitorId: { $exists: true, $ne: null }, + monitorIds: { $exists: false }, + }, + }, + { + $group: { + _id: { + teamId: "$teamId", + name: "$name", + start: "$start", + end: "$end", + repeat: "$repeat", + durationUnit: "$durationUnit", + duration: "$duration", + active: "$active", + }, + docIds: { $push: "$_id" }, + monitorIds: { $addToSet: "$monitorId" }, + }, + }, + ]); + + if (groups.length === 0) { + logger.info({ service: SERVICE_NAME, message: "No MaintenanceWindow documents needed migration" }); + return; + } + + const bulkOps: AnyBulkWriteOperation[] = []; + let originalDocCount = 0; + + for (const group of groups) { + const { docIds, monitorIds } = group; + originalDocCount += docIds.length; + const [survivorId, ...duplicateIds] = docIds; + + bulkOps.push({ + updateOne: { + filter: { _id: survivorId }, + update: { + $set: { monitorIds }, + $unset: { monitorId: "" }, + }, + }, + }); + + if (duplicateIds.length > 0) { + bulkOps.push({ + deleteMany: { + filter: { _id: { $in: duplicateIds } }, + }, + }); + } + } + + const result = await MaintenanceWindowModel.collection.bulkWrite(bulkOps, { ordered: true }); + const deletedCount = originalDocCount - groups.length; + + logger.info({ + service: SERVICE_NAME, + message: `Migration complete. Consolidated ${originalDocCount} old document(s) into ${groups.length} group window(s); deleted ${deletedCount} duplicate(s).`, + }); + logger.debug({ + service: SERVICE_NAME, + message: `bulkWrite result: modified=${result.modifiedCount}, deleted=${result.deletedCount}`, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error during MaintenanceWindow migration: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index 5b74a96fc..61785118e 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -5,6 +5,7 @@ import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds. import MigrationModel from "../models/Migration.js"; import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js"; import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js"; +import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -19,6 +20,7 @@ const migrations: MigrationEntry[] = [ { name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds }, { name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray }, { name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex }, + { name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray }, ]; const runMigrations = async (logger?: ILogger) => { diff --git a/server/src/db/models/AppSettings.ts b/server/src/db/models/AppSettings.ts index 9eeaa640a..195f43044 100644 --- a/server/src/db/models/AppSettings.ts +++ b/server/src/db/models/AppSettings.ts @@ -26,6 +26,7 @@ const AppSettingsSchema = new Schema( systemEmailHost: { type: String }, systemEmailPort: { type: Number }, systemEmailAddress: { type: String }, + systemEmailDisplayName: { type: String }, systemEmailPassword: { type: String }, systemEmailUser: { type: String }, systemEmailConnectionHost: { type: String, default: "localhost" }, diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/db/models/MaintenanceWindow.ts index 311cf97bf..654ae50dd 100644 --- a/server/src/db/models/MaintenanceWindow.ts +++ b/server/src/db/models/MaintenanceWindow.ts @@ -1,8 +1,8 @@ import { Schema, model, type Types } from "mongoose"; -import type { MaintenanceWindow } from "@/types/maintenanceWindow.js"; +import { DurationUnits, type MaintenanceWindow } from "@/types/maintenanceWindow.js"; -type MaintenanceWindowDocumentBase = Omit & { - monitorId: Types.ObjectId; +type MaintenanceWindowDocumentBase = Omit & { + monitorIds: Types.ObjectId[]; teamId: Types.ObjectId; start: Date; end: Date; @@ -17,10 +17,10 @@ interface MaintenanceWindowDocument extends MaintenanceWindowDocumentBase { const MaintenanceWindowSchema = new Schema( { - monitorId: { - type: Schema.Types.ObjectId, + monitorIds: { + type: [Schema.Types.ObjectId], ref: "Monitor", - immutable: true, + index: true, }, teamId: { type: Schema.Types.ObjectId, @@ -39,7 +39,7 @@ const MaintenanceWindowSchema = new Schema( }, durationUnit: { type: String, - enum: ["seconds", "minutes", "hours", "days"], + enum: DurationUnits, }, repeat: { type: Number, diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 036aeadad..e6bffddd8 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -1,6 +1,6 @@ import { Schema, model, Types } from "mongoose"; import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js"; -import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js"; +import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/types/monitor.js"; import type { CheckAudits, CheckCaptureInfo, @@ -18,11 +18,12 @@ type CheckSnapshotDocument = Omit & { createdAt: Dat type MonitorDocumentBase = Omit< Monitor, - "id" | "userId" | "teamId" | "notifications" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt" + "id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt" > & { statusWindow: boolean[]; recentChecks: CheckSnapshotDocument[]; notifications: Types.ObjectId[]; + tags: Types.ObjectId[]; selectedDisks: string[]; matchMethod?: MonitorMatchMethod; }; @@ -284,6 +285,12 @@ const MonitorSchema = new Schema( ref: "Notification", }, ], + tags: [ + { + type: Schema.Types.ObjectId, + ref: "Tag", + }, + ], secret: { type: String, }, @@ -330,6 +337,10 @@ const MonitorSchema = new Schema( type: String, default: "", }, + strategy: { + type: String, + enum: PageSpeedStrategies, + }, group: { type: String, trim: true, @@ -351,6 +362,13 @@ const MonitorSchema = new Schema( type: Number, default: 300000, }, + dnsServer: { + type: String, + }, + dnsRecordType: { + type: String, + enum: DnsRecordTypes, + }, recentChecks: { type: [checkSnapshotSchema], default: [], diff --git a/server/src/db/models/Notification.ts b/server/src/db/models/Notification.ts index 2f7effa17..93202bb8f 100755 --- a/server/src/db/models/Notification.ts +++ b/server/src/db/models/Notification.ts @@ -25,7 +25,19 @@ const NotificationSchema = new Schema( }, type: { type: String, - enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[], + enum: [ + "email", + "slack", + "discord", + "webhook", + "pager_duty", + "matrix", + "teams", + "telegram", + "pushover", + "twilio", + "ntfy", + ] as NotificationChannel[], required: true, }, notificationName: { @@ -39,6 +51,7 @@ const NotificationSchema = new Schema( accessToken: { type: String }, accountSid: { type: String }, twilioPhoneNumber: { type: String }, + topic: { type: String }, }, { timestamps: true, diff --git a/server/src/db/models/QueueWorker.ts b/server/src/db/models/QueueWorker.ts new file mode 100644 index 000000000..76485f56c --- /dev/null +++ b/server/src/db/models/QueueWorker.ts @@ -0,0 +1,28 @@ +import { Schema, model } from "mongoose"; +import { QueueModes, type QueueMode } from "@/types/settings.js"; + +// Heartbeat presence record for a scheduler instance. _id is the scheduler's +// workerId (hostname:pid:uuid). The TTL index on lastSeenAt garbage-collects +// records for instances that have stopped heartbeating. +interface QueueWorkerDocument { + _id: string; + mode: QueueMode; + lastSeenAt: Date; + createdAt: Date; + updatedAt: Date; +} + +const QueueWorkerSchema = new Schema( + { + _id: { type: String, required: true }, + mode: { type: String, enum: QueueModes, required: true }, + lastSeenAt: { type: Date, default: Date.now, expires: 30 }, + }, + { timestamps: true } +); + +const QueueWorkerModel = model("QueueWorker", QueueWorkerSchema); + +export type { QueueWorkerDocument }; +export { QueueWorkerModel }; +export default QueueWorkerModel; diff --git a/server/src/db/models/Tag.ts b/server/src/db/models/Tag.ts new file mode 100644 index 000000000..df77aa7ab --- /dev/null +++ b/server/src/db/models/Tag.ts @@ -0,0 +1,33 @@ +import { Schema, model, type Types } from "mongoose"; +import type { Tag as TagEntity } from "@/types/index.js"; + +type TagDocumentBase = Omit; + +interface TagDocument extends TagDocumentBase { + _id: Types.ObjectId; + teamId: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const TagSchema = new Schema( + { + teamId: { + type: Schema.Types.ObjectId, + ref: "Team", + immutable: true, + required: true, + }, + name: { type: String, required: true }, + color: { type: String, required: true, default: "#13715B" }, + }, + { timestamps: true } +); + +TagSchema.index({ teamId: 1, name: 1 }, { unique: true }); + +const TagModel = model("Tag", TagSchema); + +export type { TagDocument }; +export { TagModel }; +export default TagModel; diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index d4c8f1a3b..b825ca503 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -39,3 +39,8 @@ export { default as GeoCheckModel } from "@/db/models/GeoCheck.js"; export * from "@/db/models/DLQItem.js"; export { default as DLQItemModel } from "@/db/models/DLQItem.js"; +export * from "@/db/models/Tag.js"; +export { default as TagModel } from "@/db/models/Tag.js"; + +export * from "@/db/models/QueueWorker.js"; +export { default as QueueWorkerModel } from "@/db/models/QueueWorker.js"; diff --git a/server/src/index.ts b/server/src/index.ts index 83698b45c..45c75ac8b 100755 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,22 +17,31 @@ const startApp = async () => { // Validate environment variables first const env = validateEnv(); + // Create settings service (env only — DB repository injected after connect) + const settingsService = new SettingsService(env); + const envSettings = settingsService.loadSettings(); + // Create logger + logger = new Logger({ envSettings }); + + // Initialize services (connects DB, creates repositories, injects settingsRepository) + const services = await initializeServices({ logger, envSettings, settingsService, queueMode: envSettings.queueMode }); + + // If this is a worker instance, we're done. No need for express + if (envSettings.queueMode === "worker") { + logger.info({ + message: "Worker instance started. API will not be started", + service: SERVICE_NAME, + }); + initShutdownListener(null, services); + return; + } + // FE path const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8")); const frontendPath = path.join(__dirname, "..", "public"); - // Create settings service (env only — DB repository injected after connect) - const settingsService = new SettingsService(env); - const envSettings = settingsService.loadSettings(); - - // Create logger - logger = new Logger({ envSettings }); - - // Initialize services (connects DB, creates repositories, injects settingsRepository) - const services = await initializeServices({ logger, envSettings, settingsService }); - // Initialize controllers const controllers = initializeControllers(services); diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index fc5eefee9..d36d9d189 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -25,6 +25,9 @@ export { default as MongoSettingsRepository } from "@/repositories/settings/Mong export * from "@/repositories/notifications/INotificationsRepository.js"; export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js"; +export * from "@/repositories/tags/ITagsRepository.js"; +export { default as MongoTagsRepository } from "@/repositories/tags/MongoTagsRepository.js"; + export * from "@/repositories/incidents/IIncidentsRepository.js"; export { default as MongoIncidentsRepository } from "@/repositories/incidents/MongoIncidentsRepository.js"; @@ -39,3 +42,6 @@ export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/M export * from "@/repositories/dlq/IDLQRepository.js"; export { default as MongoDLQRepository } from "@/repositories/dlq/MongoDLQRepository.js"; + +export * from "@/repositories/queue-workers/IQueueWorkersRepository.js"; +export { default as MongoQueueWorkersRepository } from "@/repositories/queue-workers/MongoQueueWorkersRepository.js"; diff --git a/server/src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts index fca63ac35..0f4ad66a2 100644 --- a/server/src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts +++ b/server/src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts @@ -5,6 +5,7 @@ export interface IMaintenanceWindowsRepository { // fetch findById(id: string, teamId: string): Promise; findByMonitorId(monitorId: string, teamId: string): Promise; + findByMonitorIds(monitorIds: string[], teamId: string, excludeId?: string): Promise; findByTeamId(teamId: string, page: number, rowsPerPage: number, field?: string, order?: string, active?: boolean): Promise; // update diff --git a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts index ad96328c5..c3f94f55d 100644 --- a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts +++ b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts @@ -29,7 +29,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository private toEntity = (doc: MaintenanceWindowDocument): MaintenanceWindow => { return { id: this.toStringId(doc._id), - monitorId: this.toStringId(doc.monitorId), + monitorIds: doc.monitorIds.map(this.toStringId), teamId: this.toStringId(doc.teamId), active: doc.active, name: doc.name, @@ -65,9 +65,21 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository return this.toEntity(maintenanceWindow); }; + findByMonitorIds = async (monitorIds: string[], teamId: string, excludeId?: string): Promise => { + const query: Record = { + monitorIds: { $in: monitorIds }, + teamId, + }; + if (excludeId) { + query._id = { $ne: new mongoose.Types.ObjectId(excludeId) }; + } + const maintenanceWindows = await MaintenanceWindowModel.find(query); + return this.mapDocuments(maintenanceWindows); + }; + findByMonitorId = async (monitorId: string, teamId: string): Promise => { const maintenanceWindows = await MaintenanceWindowModel.find({ - monitorId: monitorId, + monitorIds: monitorId, teamId: teamId, }); return this.mapDocuments(maintenanceWindows); @@ -113,6 +125,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository if (!updated) { throw new AppError({ message: "Maintenance window not found or could not be updated", status: 404 }); } + return this.toEntity(updated); }; @@ -124,6 +137,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository if (!deleted) { throw new AppError({ message: "Maintenance window not found or could not be deleted", status: 404 }); } + return this.toEntity(deleted); }; countByTeamId = async (teamId: string, active?: boolean) => { diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index 941556589..90fcc5a94 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -1,8 +1,9 @@ -import { type MonitorType, type Monitor, type MonitorsSummary, CheckSnapshot } from "@/types/index.js"; +import { type MonitorType, type Monitor, type MonitorStatus, type MonitorsSummary, CheckSnapshot } from "@/types/index.js"; export interface TeamQueryConfig { limit?: number; type?: MonitorType | MonitorType[]; + tags?: string | string[]; page?: number; rowsPerPage?: number; filter?: string; @@ -12,6 +13,7 @@ export interface TeamQueryConfig { export interface SummaryConfig { type?: MonitorType | MonitorType[]; + tags?: string | string[]; } export interface IMonitorsRepository { @@ -29,6 +31,7 @@ export interface IMonitorsRepository { // update updateById(monitorId: string, teamId: string, updates: Partial): Promise; + updateByIds(monitorIds: string[], teamId: string, updates: Partial, excludeStatuses?: MonitorStatus[]): Promise; updateStatusWindowAndChecks( monitorId: string, teamId: string, @@ -39,6 +42,7 @@ export interface IMonitorsRepository { statusPatch?: Partial ): Promise; togglePauseById(monitorId: string, teamId: string): Promise; + bulkTogglePause(monitorIds: string[], teamId: string, pause: boolean): Promise; // delete deleteById(monitorId: string, teamId: string): Promise; deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }>; @@ -50,6 +54,7 @@ export interface IMonitorsRepository { findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise; findGroupsByTeamId(teamId: string): Promise; removeNotificationFromMonitors(notificationId: string): Promise; + removeTagFromMonitors(tagId: string): Promise; updateNotifications(teamId: string, monitorIds: string[], notificationIds: string[], action: "add" | "remove" | "set"): Promise; deleteByTeamIdsNotIn(teamIds: string[]): Promise; findAllMonitorIds(): Promise; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index bd6d506d5..3d1f9c3a5 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -1,6 +1,6 @@ import { MonitorModel } from "@/db/models/index.js"; import type { MonitorDocument, CheckSnapshotDocument } from "@/db/models/index.js"; -import type { Monitor, MonitorsSummary, CheckSnapshot } from "@/types/index.js"; +import type { Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/types/index.js"; import mongoose, { type FilterQuery, type PipelineStage } from "mongoose"; import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "./IMonitorsRepository.js"; import { MongoBulkWriteError } from "mongodb"; @@ -44,7 +44,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise => { - const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type } = config ?? {}; + const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type, tags } = config ?? {}; const query: Record = { teamId: new mongoose.Types.ObjectId(teamId), @@ -54,6 +54,10 @@ class MongoMonitorsRepository implements IMonitorsRepository { query.type = Array.isArray(type) ? { $in: type } : type; } + if (tags !== undefined) { + query.tags = Array.isArray(tags) ? { $in: tags } : tags; + } + if (filter !== undefined) { switch (field) { case "name": @@ -108,7 +112,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { $lookup: { from: "maintenancewindows", let: { monitorId: "$_id" }, - pipeline: [{ $match: { $expr: { $eq: ["$monitorId", "$$monitorId"] } } }], + pipeline: [{ $match: { $expr: { $in: ["$$monitorId", { $ifNull: ["$monitorIds", []] }] } } }], as: "maintenanceWindows", }, }, @@ -152,7 +156,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; findMonitorCountByTeamIdAndType = async (teamId: string, config?: TeamQueryConfig): Promise => { - const { type } = config ?? {}; + const { type, tags } = config ?? {}; const query: FilterQuery = { teamId: new mongoose.Types.ObjectId(teamId), @@ -162,6 +166,10 @@ class MongoMonitorsRepository implements IMonitorsRepository { query.type = Array.isArray(type) ? { $in: type } : type; } + if (tags !== undefined) { + query.tags = Array.isArray(tags) ? { $in: tags } : tags; + } + const count = await MonitorModel.countDocuments(query); return count; }; @@ -182,6 +190,16 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.toEntity(updatedMonitor); }; + updateByIds = async (monitorIds: string[], teamId: string, updates: Partial, excludeStatuses?: MonitorStatus[]) => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + const filter: Record = { _id: { $in: objectIds }, teamId }; + if (excludeStatuses && excludeStatuses.length > 0) { + filter.status = { $nin: excludeStatuses }; + } + const updated = await MonitorModel.updateMany(filter, { $set: updates }, { runValidators: true }); + return updated.modifiedCount; + }; + updateStatusWindowAndChecks = async ( monitorId: string, teamId: string, @@ -234,6 +252,34 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.toEntity(monitor); }; + bulkTogglePause = async (monitorIds: string[], teamId: string, pause: boolean): Promise => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + const filter = { + _id: { $in: objectIds }, + teamId: new mongoose.Types.ObjectId(teamId), + isActive: pause, // Only pause if active (true), only resume if inactive (false) + }; + const nextStatus = pause ? "paused" : "initializing"; + + const eligible = await MonitorModel.find(filter); + if (eligible.length === 0) return []; + + const now = new Date(); + await MonitorModel.updateMany( + { _id: { $in: eligible.map((doc) => doc._id) } }, + { $set: { isActive: !pause, status: nextStatus, updatedAt: now } }, + { timestamps: false } + ); + + eligible.forEach((doc) => { + doc.isActive = !pause; + doc.status = nextStatus; + doc.updatedAt = now; + }); + + return this.mapDocuments(eligible); + }; + deleteById = async (monitorId: string, teamId: string) => { const deletedMonitor = await MonitorModel.findOneAndDelete({ _id: monitorId, teamId }); @@ -256,6 +302,10 @@ class MongoMonitorsRepository implements IMonitorsRepository { if (config?.type !== undefined) { match.type = Array.isArray(config.type) ? { $in: config.type } : config.type; } + if (config?.tags !== undefined) { + const tagIds = (Array.isArray(config.tags) ? config.tags : [config.tags]).map((tag) => new mongoose.Types.ObjectId(tag)); + match.tags = { $in: tagIds }; + } const pipeline = [ { $match: match }, { @@ -323,6 +373,10 @@ class MongoMonitorsRepository implements IMonitorsRepository { await MonitorModel.updateMany({ notifications: notificationId }, { $pull: { notifications: notificationId } }); }; + removeTagFromMonitors = async (tagId: string): Promise => { + await MonitorModel.updateMany({ tags: tagId }, { $pull: { tags: tagId } }); + }; + updateNotifications = async ( teamId: string, monitorIds: string[], @@ -378,7 +432,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; const notificationIds = (doc.notifications ?? []).map((notification) => toStringId(notification)); - + const tagIds = (doc.tags ?? []).map((tag) => toStringId(tag)); return { id: toStringId(doc._id), userId: toStringId(doc.userId), @@ -401,6 +455,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { interval: doc.interval, uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, + tags: tagIds, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, @@ -413,11 +468,14 @@ class MongoMonitorsRepository implements IMonitorsRepository { selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, grpcServiceName: doc.grpcServiceName ?? undefined, + strategy: doc.strategy ?? undefined, group: doc.group ?? null, recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)), geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, + dnsServer: doc.dnsServer ?? undefined, + dnsRecordType: doc.dnsRecordType ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; @@ -437,6 +495,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; const notificationIds = (doc.notifications ?? []).map((notification: unknown) => toStringId(notification)); + const tagIds = (doc.tags ?? []).map((tag: unknown) => toStringId(tag)); return { id: toStringId(doc._id), @@ -460,6 +519,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { interval: doc.interval, uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, + tags: tagIds, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, @@ -472,11 +532,14 @@ class MongoMonitorsRepository implements IMonitorsRepository { selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, grpcServiceName: doc.grpcServiceName ?? undefined, + strategy: doc.strategy ?? undefined, group: doc.group ?? null, recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)), geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, + dnsServer: doc.dnsServer ?? undefined, + dnsRecordType: doc.dnsRecordType ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/repositories/notifications/MongoNotificationsRepository.ts index 5303aa6ab..e556fa206 100644 --- a/server/src/repositories/notifications/MongoNotificationsRepository.ts +++ b/server/src/repositories/notifications/MongoNotificationsRepository.ts @@ -34,6 +34,7 @@ class MongoNotificationsRepository implements INotificationsRepository { accessToken: doc.accessToken ?? undefined, accountSid: doc.accountSid ?? undefined, twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined, + topic: doc.topic ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/repositories/queue-workers/IQueueWorkersRepository.ts b/server/src/repositories/queue-workers/IQueueWorkersRepository.ts new file mode 100644 index 000000000..b1f7e76b1 --- /dev/null +++ b/server/src/repositories/queue-workers/IQueueWorkersRepository.ts @@ -0,0 +1,7 @@ +import type { QueueMode, QueueWorker } from "@/types/settings.js"; + +export interface IQueueWorkersRepository { + upsert(workerId: string, mode: QueueMode): Promise; + findRecent(maxAgeMs: number): Promise; + deleteById(workerId: string): Promise; +} diff --git a/server/src/repositories/queue-workers/MongoQueueWorkersRepository.ts b/server/src/repositories/queue-workers/MongoQueueWorkersRepository.ts new file mode 100644 index 000000000..b8155ea1d --- /dev/null +++ b/server/src/repositories/queue-workers/MongoQueueWorkersRepository.ts @@ -0,0 +1,33 @@ +import type { QueueMode, QueueWorker } from "@/types/settings.js"; +import type { IQueueWorkersRepository } from "./IQueueWorkersRepository.js"; +import type { QueueWorkerDocument } from "@/db/models/QueueWorker.js"; +import { QueueWorkerModel } from "@/db/models/QueueWorker.js"; +const SERVICE_NAME = "MongoQueueWorkersRepository"; + +class MongoQueueWorkersRepository implements IQueueWorkersRepository { + static SERVICE_NAME = SERVICE_NAME; + + protected toEntity = (doc: QueueWorkerDocument): QueueWorker => { + return { + workerId: doc._id, + mode: doc.mode, + lastSeenAt: doc.lastSeenAt.getTime(), + }; + }; + + upsert = async (workerId: string, mode: QueueMode): Promise => { + await QueueWorkerModel.updateOne({ _id: workerId }, { $set: { mode, lastSeenAt: new Date() } }, { upsert: true }); + }; + + findRecent = async (maxAgeMs: number): Promise => { + const cutoff = new Date(Date.now() - maxAgeMs); + const docs = await QueueWorkerModel.find({ lastSeenAt: { $gte: cutoff } }); + return docs.map(this.toEntity); + }; + + deleteById = async (workerId: string): Promise => { + await QueueWorkerModel.deleteOne({ _id: workerId }); + }; +} + +export default MongoQueueWorkersRepository; diff --git a/server/src/repositories/tags/ITagsRepository.ts b/server/src/repositories/tags/ITagsRepository.ts new file mode 100644 index 000000000..42cec2a5a --- /dev/null +++ b/server/src/repositories/tags/ITagsRepository.ts @@ -0,0 +1,13 @@ +import { Tag } from "@/types/index.js"; + +export interface ITagsRepository { + // create + create(tagData: Partial): Promise; + // read + findById(tagId: string, teamId: string): Promise; + findByTeamId(teamId: string): Promise; + // update + updateById(tagId: string, teamId: string, patch: Partial): Promise; + // delete + deleteById(tagId: string, teamId: string): Promise; +} diff --git a/server/src/repositories/tags/MongoTagsRepository.ts b/server/src/repositories/tags/MongoTagsRepository.ts new file mode 100644 index 000000000..72355c7df --- /dev/null +++ b/server/src/repositories/tags/MongoTagsRepository.ts @@ -0,0 +1,87 @@ +import { ITagsRepository } from "@/repositories/tags/ITagsRepository.js"; +import { Tag } from "@/types/tag.js"; +import { TagDocument, TagModel } from "@/db/models/index.js"; +import mongoose from "mongoose"; +import { AppError } from "@/utils/AppError.js"; + +const SERVICE_NAME = "TagsRepository"; + +class MongoTagsRepository implements ITagsRepository { + static SERVICE_NAME = SERVICE_NAME; + + private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { + if (!value) { + return ""; + } + return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value); + }; + + private toDateString = (value?: Date | string | null): string => { + if (!value) { + return new Date(0).toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + }; + + protected toEntity = (doc: TagDocument): Tag => { + return { + id: this.toStringId(doc._id), + teamId: this.toStringId(doc.teamId), + name: doc.name, + color: doc.color, + createdAt: this.toDateString(doc.createdAt), + updatedAt: this.toDateString(doc.updatedAt), + }; + }; + + async create(tagData: Partial): Promise { + try { + const tag = await TagModel.create({ ...tagData }); + return this.toEntity(tag); + } catch (error) { + if (error && typeof error === "object" && (error as { code?: number }).code === 11000) { + throw new AppError({ message: `A tag named "${tagData.name}" already exists`, service: SERVICE_NAME, status: 409 }); + } + throw error; + } + } + + async findById(tagId: string, teamId: string): Promise { + const tag = await TagModel.findOne({ _id: tagId, teamId }); + if (!tag) { + throw new AppError({ message: "Tag not found", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(tag); + } + + async findByTeamId(teamId: string): Promise { + const tags = await TagModel.find({ teamId }); + return tags.map(this.toEntity); + } + + async updateById(tagId: string, teamId: string, patch: Partial): Promise { + const updatedTag = await TagModel.findOneAndUpdate( + { _id: tagId, teamId }, + { + $set: { + ...patch, + }, + }, + { new: true, runValidators: true } + ); + if (!updatedTag) { + throw new AppError({ message: "Tag not found or could not be updated", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(updatedTag); + } + + async deleteById(tagId: string, teamId: string): Promise { + const deletedTag = await TagModel.findOneAndDelete({ _id: tagId, teamId }); + if (!deletedTag) { + throw new AppError({ message: "Tag not found or could not be deleted", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(deletedTag); + } +} + +export default MongoTagsRepository; diff --git a/server/src/routes/monitorRoute.ts b/server/src/routes/monitorRoute.ts index 277cd250b..0b5e24dd0 100755 --- a/server/src/routes/monitorRoute.ts +++ b/server/src/routes/monitorRoute.ts @@ -31,6 +31,7 @@ class MonitorRoutes { // General monitor routes this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor); + this.router.post("/bulk/pause", isAllowed(["admin", "superadmin"]), this.monitorController.bulkPauseMonitors); // Util routes this.router.get("/certificate/:monitorId", (req, res, next) => { diff --git a/server/src/routes/tagRoutes.ts b/server/src/routes/tagRoutes.ts new file mode 100644 index 000000000..9b237c976 --- /dev/null +++ b/server/src/routes/tagRoutes.ts @@ -0,0 +1,28 @@ +import { ITagsController } from "@/controllers/tagController.js"; +import { Router } from "express"; +class TagRoutes { + private router: Router; + private tagController: ITagsController; + + constructor(tagController: ITagsController) { + this.router = Router(); + this.tagController = tagController; + this.initializeRoutes(); + } + + initializeRoutes() { + this.router.post("/", this.tagController.createTag); + + this.router.get("/team", this.tagController.getTagsByTeamId); + + this.router.get("/:id", this.tagController.getTagById); + this.router.delete("/:id", this.tagController.deleteTag); + this.router.patch("/:id", this.tagController.editTag); + } + + getRouter() { + return this.router; + } +} + +export default TagRoutes; diff --git a/server/src/service/business/diagnosticService.ts b/server/src/service/business/diagnosticService.ts index 45a5592f5..2d9968240 100644 --- a/server/src/service/business/diagnosticService.ts +++ b/server/src/service/business/diagnosticService.ts @@ -1,24 +1,50 @@ import v8 from "v8"; import os from "os"; +import { IDb } from "@/db/IDb.js"; +import { Mongoose } from "mongoose"; +import { AppError } from "@/utils/AppError.js"; const SERVICE_NAME = "diagnosticService"; +export interface CollectionDiagnostics { + name: string; + documentCount: number; + storageSize: number; + totalIndexSize: number; + totalSize: number; + bucketCount?: number; +} + +export interface MongoDiagnostics { + readyState: number; + host: string; + port: number; + dbName: string; + totalSize: number; + collections: CollectionDiagnostics[]; +} + +export interface Diagnostics { + osStats: { totalMemoryBytes: number }; + cpuUsage: { usagePercentage: number }; + v8HeapStats: { + totalHeapSizeBytes: number; + usedHeapSizeBytes: number; + heapSizeLimitBytes: number; + }; + eventLoopDelayMs: number; + uptimeMs: number; + mongoStats: MongoDiagnostics; +} + export interface IDiagnosticService { - getCPUUsage(): Promise<{ userUsageMs: number; systemUsageMs: number; usagePercentage: number }>; - getSystemStats(): Promise<{ - osStats: { freeMemoryBytes: number; totalMemoryBytes: number }; - memoryUsage: Record; - cpuUsage: { userUsageMs: number; systemUsageMs: number; usagePercentage: number }; - v8HeapStats: { totalHeapSizeBytes: number; usedHeapSizeBytes: number; heapSizeLimitBytes: number }; - eventLoopDelayMs: number; - uptimeMs: number; - }>; + getSystemStats(): Promise; } export class DiagnosticService implements IDiagnosticService { static SERVICE_NAME = SERVICE_NAME; - constructor() { + constructor(private db: IDb) { /** * Performance Observer for monitoring system performance metrics. * Clears performance marks after each measurement to prevent memory leaks. @@ -34,75 +60,86 @@ export class DiagnosticService implements IDiagnosticService { return DiagnosticService.SERVICE_NAME; } - getCPUUsage = async () => { + private getCPUUsagePercentage = async (): Promise => { const startUsage = process.cpuUsage(); const timingPeriod = 1000; // measured in ms await new Promise((resolve) => setTimeout(resolve, timingPeriod)); const endUsage = process.cpuUsage(startUsage); - const cpuUsage = { - userUsageMs: endUsage.user / 1000, - systemUsageMs: endUsage.system / 1000, - usagePercentage: ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100, - }; - return cpuUsage; + return ((endUsage.user + endUsage.system) / 1000 / timingPeriod) * 100; }; - getSystemStats = async () => { - // Memory Usage - const totalMemory = os.totalmem(); - const freeMemory = os.freemem(); + private getMongoDBStats = async (): Promise => { + const mongo = await this.db.getConnection(); + const db = mongo.connection.db; + if (!db) { + throw new AppError({ message: "Database connection is not available", service: SERVICE_NAME, method: "getMongoDBStats" }); + } - const osStats = { - freeMemoryBytes: freeMemory, // bytes - totalMemoryBytes: totalMemory, // bytes + const dbStats = await db.stats(); + const rawCollections = await db.collections(); + + const collections: CollectionDiagnostics[] = await Promise.all( + rawCollections + .filter((collection) => !collection.collectionName.startsWith("system")) + .map(async (collection) => { + const stats = await db.command({ collStats: collection.collectionName }); + const documentCount = await db.collection(collection.collectionName).countDocuments({}); + const entry: CollectionDiagnostics = { + name: collection.collectionName, + documentCount, + storageSize: stats.storageSize, + totalIndexSize: stats.totalIndexSize, + totalSize: stats.totalSize, + }; + if (stats.timeseries?.bucketCount !== undefined) { + entry.bucketCount = stats.timeseries.bucketCount; + } + return entry; + }) + ); + + return { + readyState: mongo.connection.readyState, + host: mongo.connection.host, + port: mongo.connection.port, + dbName: mongo.connection.name, + totalSize: dbStats.totalSize, + collections, }; + }; - const used = process.memoryUsage(); + getSystemStats = async (): Promise => { + const osStats = { totalMemoryBytes: os.totalmem() }; - // In MB - const memoryUsage: Record = { - rss: Math.round((used.rss / 1024 / 1024) * 100) / 100, - heapTotal: Math.round((used.heapTotal / 1024 / 1024) * 100) / 100, - heapUsed: Math.round((used.heapUsed / 1024 / 1024) * 100) / 100, - external: Math.round((used.external / 1024 / 1024) * 100) / 100, - arrayBuffers: Math.round((used.arrayBuffers / 1024 / 1024) * 100) / 100, - }; + const cpuUsage = { usagePercentage: await this.getCPUUsagePercentage() }; - // CPU Usage - const cpuMetrics = await this.getCPUUsage(); - - // V8 Heap Statistics const heapStats = v8.getHeapStatistics(); - const v8Metrics = { - totalHeapSizeBytes: heapStats.total_heap_size, // bytes - usedHeapSizeBytes: heapStats.used_heap_size, // bytes - heapSizeLimitBytes: heapStats.heap_size_limit, // bytes + const v8HeapStats = { + totalHeapSizeBytes: heapStats.total_heap_size, + usedHeapSizeBytes: heapStats.used_heap_size, + heapSizeLimitBytes: heapStats.heap_size_limit, }; - // Event Loop Delay - let eventLoopDelay = 0; + let eventLoopDelayMs = 0; performance.mark("start"); await new Promise((resolve) => setTimeout(resolve, 0)); performance.mark("end"); performance.measure("eventLoopDelay", "start", "end"); const entries = performance.getEntriesByName("eventLoopDelay"); if (entries.length > 0 && entries[0] !== undefined) { - eventLoopDelay = entries[0].duration; + eventLoopDelayMs = entries[0].duration; } - // Uptime - const uptimeMs = process.uptime() * 1000; // ms + const uptimeMs = process.uptime() * 1000; + const mongoStats = await this.getMongoDBStats(); - // Combine Metrics - const diagnostics = { + return { osStats, - memoryUsage, - cpuUsage: cpuMetrics, - v8HeapStats: v8Metrics, - eventLoopDelayMs: eventLoopDelay, + cpuUsage, + v8HeapStats, + eventLoopDelayMs, uptimeMs, + mongoStats, }; - - return diagnostics; }; } diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 2070a8937..1606cc95d 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -5,7 +5,7 @@ import { AppError } from "@/utils/AppError.js"; import { getDateForRange } from "@/utils/dataUtils.js"; import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js"; import type { Incident, IncidentSummary, User } from "@/types/index.js"; -import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/JobQueues/QueueHelper.js"; import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js"; import type { ILogger } from "@/utils/logger.js"; diff --git a/server/src/service/business/maintenanceWindowService.ts b/server/src/service/business/maintenanceWindowService.ts index 50278d215..2ea4c4eae 100644 --- a/server/src/service/business/maintenanceWindowService.ts +++ b/server/src/service/business/maintenanceWindowService.ts @@ -1,6 +1,7 @@ import { IMaintenanceWindowsRepository, IMonitorsRepository } from "@/repositories/index.js"; import type { DurationUnit, MaintenanceWindow } from "@/types/index.js"; import { AppError } from "@/utils/AppError.js"; +import { isWindowActive } from "@/utils/maintenanceWindow.js"; const SERVICE_NAME = "maintenanceWindowService"; @@ -27,7 +28,11 @@ export interface IMaintenanceWindowService { }): Promise<{ maintenanceWindows: MaintenanceWindow[]; maintenanceWindowCount: number }>; getMaintenanceWindowsByMonitorId(params: { monitorId: string; teamId: string }): Promise; deleteMaintenanceWindow(params: { id: string; teamId: string }): Promise; - editMaintenanceWindow(params: { id: string; teamId: string; body: Partial }): Promise; + editMaintenanceWindow(params: { + id: string; + teamId: string; + body: Partial> & { monitors?: string[] }; + }): Promise; } export class MaintenanceWindowService implements IMaintenanceWindowService { @@ -50,6 +55,24 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { return MaintenanceWindowService.SERVICE_NAME; } + private flipMonitorsLeavingMaintenance = async (monitorIds: string[], teamId: string, excludeWindowId: string, now: Date): Promise => { + if (monitorIds.length === 0) return; + + const otherWindows = await this.maintenanceWindowsRepository.findByMonitorIds(monitorIds, teamId, excludeWindowId); + const stillCovered = new Set(); + for (const otherWindow of otherWindows) { + if (!isWindowActive(otherWindow, now)) continue; + for (const monitorId of otherWindow.monitorIds) { + if (monitorIds.includes(monitorId)) stillCovered.add(monitorId); + } + } + + const toInitializing = monitorIds.filter((monitorId) => !stillCovered.has(monitorId)); + if (toInitializing.length > 0) { + await this.monitorsRepository.updateByIds(toInitializing, teamId, { status: "initializing" }, ["paused"]); + } + }; + createMaintenanceWindow = async ({ teamId, monitorIDs, @@ -84,20 +107,21 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { }); } - const dbTransactions = monitorIDs.map((monitorId: string) => { - return this.maintenanceWindowsRepository.create({ - teamId, - monitorId, - name: name, - active: active, - duration: duration, - durationUnit: durationUnit, - repeat: repeat, - start: start, - end: end, - }); + const created = await this.maintenanceWindowsRepository.create({ + teamId, + monitorIds: monitorIDs, + name: name, + active: active, + duration: duration, + durationUnit: durationUnit, + repeat: repeat, + start: start, + end: end, }); - await Promise.all(dbTransactions); + + if (isWindowActive(created)) { + await this.monitorsRepository.updateByIds(created.monitorIds, teamId, { status: "maintenance" }, ["paused"]); + } }; getMaintenanceWindowById = async ({ id, teamId }: { id: string; teamId: string }) => { @@ -132,10 +156,62 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { }; deleteMaintenanceWindow = async ({ id, teamId }: { id: string; teamId: string }) => { - return await this.maintenanceWindowsRepository.deleteById(id, teamId); + const deleted = await this.maintenanceWindowsRepository.deleteById(id, teamId); + + const now = new Date(); + if (isWindowActive(deleted, now)) { + await this.flipMonitorsLeavingMaintenance(deleted.monitorIds, teamId, deleted.id, now); + } + + return deleted; }; - editMaintenanceWindow = async ({ id, teamId, body }: { id: string; teamId: string; body: Partial }) => { - return await this.maintenanceWindowsRepository.updateById(id, teamId, body); + editMaintenanceWindow = async ({ + id, + teamId, + body, + }: { + id: string; + teamId: string; + body: Partial> & { monitors?: string[] }; + }) => { + const existing = await this.maintenanceWindowsRepository.findById(id, teamId); + + const { monitors, ...rest } = body; + const update: Partial = rest; + + if (monitors !== undefined) { + const monitorDocs = await this.monitorsRepository.findByIds(monitors); + const unauthorizedMonitors = monitorDocs.filter((monitor) => monitor.teamId !== teamId); + if (unauthorizedMonitors.length > 0) { + throw new AppError({ + message: "Unauthorized to edit maintenance window for one or more monitors", + service: SERVICE_NAME, + method: "editMaintenanceWindow", + status: 403, + }); + } + update.monitorIds = monitors; + } + + const updated = await this.maintenanceWindowsRepository.updateById(id, teamId, update); + + const now = new Date(); + const wasActive = isWindowActive(existing, now); + const isActive = isWindowActive(updated, now); + + const enteringMaintenance = isActive ? updated.monitorIds.filter((monitorId) => !wasActive || !existing.monitorIds.includes(monitorId)) : []; + + const leavingCandidates = wasActive ? existing.monitorIds.filter((monitorId) => !isActive || !updated.monitorIds.includes(monitorId)) : []; + + if (enteringMaintenance.length > 0) { + await this.monitorsRepository.updateByIds(enteringMaintenance, teamId, { status: "maintenance" }, ["paused"]); + } + + if (leavingCandidates.length > 0) { + await this.flipMonitorsLeavingMaintenance(leavingCandidates, teamId, existing.id, now); + } + + return updated; }; } diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 4ab5c9476..46b81a2ca 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -9,7 +9,8 @@ import type { GamesMap, GroupedGeoCheckResult, } from "@/types/monitor.js"; -import { supportsGeoCheck } from "@/types/monitor.js"; +import { supportsGeoCheck, supportsUptimeDetails } from "@/types/monitor.js"; +import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/types/check.js"; import type { GeoContinent } from "@/types/geoCheck.js"; import type { IChecksRepository, @@ -22,12 +23,15 @@ import type { import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" }; import { AppError } from "@/utils/AppError.js"; import type { ImportedMonitor } from "@/validation/monitorValidation.js"; -import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; +import { IJobQueue } from "@/service/infrastructure/JobQueues/IJobQueue.js"; import { ILogger } from "@/utils/logger.js"; const SERVICE_NAME = "MonitorService"; type DateRangeKey = "recent" | "day" | "week" | "month" | "all"; +const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult => + supportsUptimeDetails(result.monitorType); + export interface IMonitorService { readonly serviceName: string; @@ -51,6 +55,7 @@ export interface IMonitorService { teamId: string; limit?: number; type?: MonitorType | MonitorType[]; + tags?: string | string[]; page?: number; rowsPerPage?: number; filter?: string; @@ -61,6 +66,7 @@ export interface IMonitorService { teamId: string; limit?: number; type?: MonitorType | MonitorType[]; + tags?: string | string[]; page?: number; rowsPerPage?: number; filter?: string; @@ -73,6 +79,7 @@ export interface IMonitorService { // update editMonitor(args: { teamId: string; monitorId: string; body: Partial }): Promise; pauseMonitor(args: { teamId: string; monitorId: string }): Promise; + bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise<{ monitors: Monitor[]; failedCount: number }>; // delete deleteMonitor(args: { teamId: string; monitorId: string }): Promise; @@ -89,7 +96,7 @@ export interface IMonitorService { export class MonitorService implements IMonitorService { static SERVICE_NAME = SERVICE_NAME; - private jobQueue: ISuperSimpleQueue; + private jobQueue: IJobQueue; private logger: ILogger; private games: GamesMap; private monitorsRepository: IMonitorsRepository; @@ -110,7 +117,7 @@ export class MonitorService implements IMonitorService { statusPagesRepository, incidentsRepository, }: { - jobQueue: ISuperSimpleQueue; + jobQueue: IJobQueue; logger: ILogger; games: GamesMap; monitorsRepository: IMonitorsRepository; @@ -215,15 +222,7 @@ export class MonitorService implements IMonitorService { }); const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id); - if ( - checksData.monitorType !== "http" && - checksData.monitorType !== "ping" && - checksData.monitorType !== "docker" && - checksData.monitorType !== "port" && - checksData.monitorType !== "game" && - checksData.monitorType !== "grpc" && - checksData.monitorType !== "websocket" - ) { + if (!isUptimeChecksResult(checksData)) { throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 }); } @@ -360,19 +359,22 @@ export class MonitorService implements IMonitorService { getMonitorsByTeamId = async ({ teamId, type, + tags, filter, }: { teamId: string; type?: MonitorType | MonitorType[]; + tags?: string | string[]; filter?: string; }): Promise => { - return await this.monitorsRepository.findByTeamId(teamId, { type, filter }); + return await this.monitorsRepository.findByTeamId(teamId, { type, tags, filter }); }; getMonitorsWithChecksByTeamId = async ({ teamId, limit, type, + tags, page, rowsPerPage, filter, @@ -382,17 +384,19 @@ export class MonitorService implements IMonitorService { teamId: string; limit?: number; type?: MonitorType | MonitorType[]; + tags?: string | string[]; page?: number; rowsPerPage?: number; filter?: string; field?: string; order?: "asc" | "desc"; }): Promise => { - const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId, { type }); - const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, filter }); + const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId, { type, tags }); + const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, tags, filter }); const monitors = await this.monitorsRepository.findByTeamId(teamId, { limit, type, + tags, page, rowsPerPage, filter, @@ -462,6 +466,43 @@ export class MonitorService implements IMonitorService { return monitor; }; + bulkPauseMonitors = async ({ + teamId, + monitorIds, + pause, + }: { + teamId: string; + monitorIds: string[]; + pause: boolean; + }): Promise<{ monitors: Monitor[]; failedCount: number }> => { + const monitors = await this.monitorsRepository.bulkTogglePause(monitorIds, teamId, pause); + + const results = await Promise.allSettled( + monitors.map(async (monitor) => { + if (monitor.isActive) { + await this.jobQueue.resumeJob(monitor); + } else { + await this.jobQueue.pauseJob(monitor); + } + }) + ); + + let failedCount = 0; + results.forEach((result, index) => { + if (result.status === "rejected") { + failedCount++; + this.logger.error({ + message: `Failed to sync job queue for monitor ${monitors[index]?.id || "unknown"} during bulk ${pause ? "pause" : "resume"}`, + service: SERVICE_NAME, + method: "bulkPauseMonitors", + stack: result.reason instanceof Error ? result.reason.stack : undefined, + }); + } + }); + + return { monitors, failedCount }; + }; + deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => { const monitor = await this.monitorsRepository.deleteById(monitorId, teamId); await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => { diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index 7acca5742..8b2c52159 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -10,7 +10,7 @@ import type { User } from "@/types/index.js"; import { canManageRole, type UserRole } from "@/types/user.js"; import bcrypt from "bcryptjs"; import { AppError } from "@/utils/AppError.js"; -import { ISuperSimpleQueue } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; +import { IJobQueue } from "@/service/infrastructure/JobQueues/IJobQueue.js"; import { IEmailService } from "@/service/infrastructure/emailService.js"; import { EnvConfig, ISettingsService } from "@/service/system/settingsService.js"; import { ILogger } from "@/utils/logger.js"; @@ -55,7 +55,7 @@ export class UserService implements IUserService { private settingsService: ISettingsService; private logger: ILogger; private jwt: JwtType; - private jobQueue: ISuperSimpleQueue; + private jobQueue: IJobQueue; private crypto: CryptoType; private monitorsRepository: IMonitorsRepository; private usersRepository: IUsersRepository; @@ -83,7 +83,7 @@ export class UserService implements IUserService { settingsService: ISettingsService; logger: ILogger; jwt: JwtType; - jobQueue: ISuperSimpleQueue; + jobQueue: IJobQueue; monitorsRepository: IMonitorsRepository; usersRepository: IUsersRepository; invitesRepository: IInvitesRepository; diff --git a/server/src/service/index.ts b/server/src/service/index.ts index 5db8afc4f..2a0974e27 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -10,14 +10,17 @@ export * from "@/service/business/statusPageService.js"; export * from "@/service/business/userService.js"; // Infrastructure services -export * from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; -export * from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +export * from "@/service/infrastructure/JobQueues/IJobQueue.js"; +export * from "@/service/infrastructure/JobQueues/SuperSimpleQueue.js"; +export * from "@/service/infrastructure/JobQueues/LessSimpleQueue.js"; +export * from "@/service/infrastructure/JobQueues/QueueHelper.js"; export * from "@/service/infrastructure/notificationMessageBuilder.js"; export * from "@/service/infrastructure/bufferService.js"; export * from "@/service/infrastructure/emailService.js"; export * from "@/service/infrastructure/globalPingService.js"; export * from "@/service/infrastructure/networkService.js"; export * from "@/service/infrastructure/notificationsService.js"; +export * from "@/service/infrastructure/tagService.js"; export * from "@/service/infrastructure/statusService.js"; // Notification providers @@ -32,6 +35,7 @@ export * from "@/service/infrastructure/notificationProviders/webhook.js"; export * from "@/service/infrastructure/notificationProviders/telegram.js"; export * from "@/service/infrastructure/notificationProviders/pushover.js"; export * from "@/service/infrastructure/notificationProviders/twilio.js"; +export * from "@/service/infrastructure/notificationProviders/ntfy.js"; // System services export * from "@/service/system/settingsService.js"; diff --git a/server/src/service/infrastructure/JobQueues/IJobQueue.ts b/server/src/service/infrastructure/JobQueues/IJobQueue.ts new file mode 100644 index 000000000..ccbca625a --- /dev/null +++ b/server/src/service/infrastructure/JobQueues/IJobQueue.ts @@ -0,0 +1,66 @@ +import { Monitor } from "@/types/monitor.js"; +import { QueueWorker } from "@/types/settings.js"; + +export type QueueJobFailure = { + monitorId: string | number; + monitorUrl: string | null; + monitorType: string | null; + failedAt: number | null; + failCount: number; + failReason: string | null; +}; + +export type QueueMetrics = { + jobs: number; + activeJobs: number; + failingJobs: number; + jobsWithFailures: QueueJobFailure[]; + totalRuns: number; + totalFailures: number; + workers: QueueWorker[]; +}; + +export type QueueJobSummary = { + monitorId: string | number; + monitorUrl: string | null; + monitorType: string | null; + monitorInterval: number | null; + monitorGeoInterval: number | null; + monitorActive: boolean | null; + active: boolean; + lockedBy: string | null; + lockedUntil: number | null; + 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 type QueueJobsPagination = { + page?: number; + rowsPerPage?: number; +}; + +export type QueueJobsPage = { + jobs: QueueJobSummary[]; + count: number; +}; + +export interface IJobQueue { + readonly serviceName: string; + init(): Promise; + addJob(monitorId: string, monitor: Monitor): Promise; + deleteJob(monitor: Monitor): Promise; + pauseJob(monitor: Monitor): Promise; + resumeJob(monitor: Monitor): Promise; + updateJob(monitor: Monitor): Promise; + shutdown(): Promise; + getMetrics(): Promise; + getJobs(pagination: QueueJobsPagination): Promise; + flushQueues(): Promise<{ success: boolean }>; +} diff --git a/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts new file mode 100644 index 000000000..963b41267 --- /dev/null +++ b/server/src/service/infrastructure/JobQueues/LessSimpleQueue.ts @@ -0,0 +1,426 @@ +import { IMonitorsRepository, IQueueWorkersRepository } from "@/repositories/index.js"; +import { ILogger } from "@/utils/logger.js"; +import { MongoStore, Scheduler, IJob, AddJobInput } from "less-simple-scheduler"; +import { IQueueHelper } from "@/service/infrastructure/JobQueues/QueueHelper.js"; +import { Monitor, supportsGeoCheck } from "@/types/monitor.js"; +import { type QueueMetrics, type QueueJobsPagination, type QueueJobsPage, IJobQueue } from "@/service/infrastructure/JobQueues/IJobQueue.js"; +import { type QueueMode } from "@/types/settings.js"; +import { EnvConfig } from "@/service/system/settingsService.js"; +const SERVICE_NAME = "JobQueue"; + +// How long after its last heartbeat a worker is still considered alive. Must be longer than the scheduler's heartbeat +const WORKER_TTL_MS = 30_000; + +// For discriminating job types +type MonitorJob = Omit & { data: Monitor }; +const MONITOR_TEMPLATES = new Set(["monitor-job", "geo-check-job"]); +const isMonitorJob = (job: IJob): job is MonitorJob => MONITOR_TEMPLATES.has(job.template); + +export class LessSimpleQueue implements IJobQueue { + static SERVICE_NAME = SERVICE_NAME; + + private logger: ILogger; + private helper: IQueueHelper; + private monitorsRepository: IMonitorsRepository; + private workersRepository: IQueueWorkersRepository; + private readonly scheduler: Scheduler; + private queueMode: QueueMode; + private heartbeatListener: ((workerId: string) => void) | null = null; + + constructor( + logger: ILogger, + helper: IQueueHelper, + monitorsRepository: IMonitorsRepository, + workersRepository: IQueueWorkersRepository, + scheduler: Scheduler, + queueMode: QueueMode + ) { + this.logger = logger; + this.helper = helper; + this.monitorsRepository = monitorsRepository; + this.workersRepository = workersRepository; + this.scheduler = scheduler; + this.queueMode = queueMode; + this.registerListeners(); + } + + get serviceName() { + return LessSimpleQueue.SERVICE_NAME; + } + + private registerListeners = () => { + this.scheduler.on("scheduler:start", () => { + this.logger.info({ + message: "Scheduler started", + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("scheduler:stop", () => { + this.logger.info({ + message: "Scheduler stopped", + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("scheduler:error", (workerId, error) => { + this.logger.error({ + message: `${workerId} error: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + stack: error instanceof Error ? error.stack : undefined, + }); + }); + + this.scheduler.on("job:locked", (workerId, job) => { + this.logger.debug({ + message: `${workerId} ${job.id} locked`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:abort", (workerId, job, reason) => { + this.logger.warn({ + message: `${workerId} ${job.id} aborted: ${reason}`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:attempt", (workerId, job, attempt) => { + this.logger.debug({ + message: `${workerId} ${job.id} attempt ${attempt}`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:complete", (workerId, job) => { + this.logger.debug({ + message: `${workerId} ${job.id} completed successfully`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:exhausted", (workerId, job, error) => { + this.logger.error({ + message: `${workerId} ${job.id} exhausted all retries: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + stack: error instanceof Error ? error.stack : undefined, + }); + }); + + this.scheduler.on("job:fail", (workerId, job, error, attempt) => { + this.logger.warn({ + message: `${workerId} ${job.id} failed on attempt ${attempt}: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + stack: error instanceof Error ? error.stack : undefined, + }); + }); + + this.scheduler.on("job:start", (workerId, job) => { + this.logger.debug({ + message: `${workerId} ${job.id} started`, + service: SERVICE_NAME, + }); + }); + }; + + static async create( + logger: ILogger, + helper: IQueueHelper, + monitorsRepository: IMonitorsRepository, + workersRepository: IQueueWorkersRepository, + envSettings: EnvConfig, + queueMode: QueueMode + ) { + const store = new MongoStore({ + url: envSettings.dbConnectionString ?? "mongodb://localhost:27017/uptime_db", + }); + const scheduler = new Scheduler(store, { + concurrency: envSettings.queueMode === "primary" ? 0 : 50, + processEvery: 100, + }); + const instance = new LessSimpleQueue(logger, helper, monitorsRepository, workersRepository, scheduler, queueMode); + await instance.init(queueMode); + + return instance; + } + + private startHeartbeat = (queueMode: QueueMode) => { + const beat = ( + workerId: string // This is the heartbeat function that registers workers + ) => + this.workersRepository.upsert(workerId, queueMode).catch((error: unknown) => { + this.logger.warn({ + message: `Worker heartbeat failed: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + method: "startHeartbeat", + }); + }); + beat(this.scheduler.workerId); // Beat immediately to register + if (this.heartbeatListener) { + this.scheduler.off("scheduler:heartbeat", this.heartbeatListener); // remove old listeners + } + this.heartbeatListener = beat; /// Keep a reference to the listener + this.scheduler.on("scheduler:heartbeat", beat); // beat on every heartbeat + }; + + init = async (queueMode: QueueMode = "primary") => { + try { + await this.scheduler.start(); + this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob()); + this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob()); + this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob()); + this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob()); + + // Register in the worker registry + this.startHeartbeat(queueMode); + + // If this is a worker, return early - primary will populate queue + if (queueMode === "worker") { + this.logger.info({ + message: `Worker queue: ${this.scheduler.workerId} initialized`, + service: SERVICE_NAME, + method: "init", + }); + return true; + } + + const monitors = await this.monitorsRepository.findAll(); + if (!monitors) { + return true; + } + const jobInputs = monitors.flatMap((monitor) => this.buildJobInputs(monitor)); + if (jobInputs.length > 0) { + await this.scheduler.addJobs(jobInputs); + } + + this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true, upsert: true }); + this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000, upsert: true }); + + return true; + } catch (error: unknown) { + this.logger.error({ + message: `Failed to initialize LessSimpleQueue: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + method: "init", + }); + return false; + } + }; + + // Builds the scheduler inputs for a monitor: always a monitor-job, plus a + // geo-check job when the monitor supports and enables geo checks. + private buildJobInputs = (monitor: Monitor): AddJobInput[] => { + const inputs: AddJobInput[] = [ + { + id: monitor.id, + template: "monitor-job", + repeat: monitor.interval, + active: monitor.isActive, + data: monitor, + upsert: true, + jitter: true, + }, + ]; + + if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) { + inputs.push({ + id: `${monitor.id}-geo`, + template: "geo-check-job", + repeat: monitor.geoCheckInterval, + active: monitor.isActive, + data: monitor, + upsert: true, + jitter: true, + }); + } + + return inputs; + }; + + addJob = async (_monitorId: string, monitor: Monitor) => { + await this.scheduler.addJobs(this.buildJobInputs(monitor)); + }; + + deleteJob = async (monitor: Monitor) => { + this.scheduler.removeJob(monitor.id); + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); + if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`); + }; + + pauseJob = async (monitor: Monitor) => { + const result = await this.scheduler.pauseJob(monitor.id); + if (result === false) { + throw new Error("Failed to pause monitor"); + } + this.scheduler.updateJob(monitor.id, { data: monitor }); + + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); + if (geoJob) { + const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`); + if (geoRes === false) { + this.logger.error({ + message: `Failed to pause geo check job for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "pauseJob", + }); + } + this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); + } + + this.logger.debug({ + message: `Paused monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "pauseJob", + }); + }; + + resumeJob = async (monitor: Monitor) => { + const result = await this.scheduler.resumeJob(monitor.id); + if (result === false) { + throw new Error("Failed to resume monitor"); + } + this.scheduler.updateJob(monitor.id, { data: monitor }); + + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); + if (geoJob) { + const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`); + if (geoRes === false) { + this.logger.error({ + message: `Failed to resume geo check job for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "resumeJob", + }); + } + this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); + } + this.logger.debug({ + message: `Resumed monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "resumeJob", + }); + }; + + private syncGeoJob = async (monitor: Monitor) => { + const geoJobId = `${monitor.id}-geo`; + const existingGeoJob = await this.scheduler.getJob(geoJobId); + + // If geoChecks have been disabled, or the monitor type doesn't support them, remove + if (!monitor.geoCheckEnabled || !supportsGeoCheck(monitor.type)) { + if (existingGeoJob) this.scheduler.removeJob(geoJobId); + return; + } + + // If the job exists, update it + if (existingGeoJob) { + this.scheduler.updateJob(geoJobId, { repeat: monitor.geoCheckInterval, active: monitor.isActive, data: monitor }); + return; + } + + // Otherwise, create it + this.scheduler.addJob({ + id: geoJobId, + template: "geo-check-job", + repeat: monitor.geoCheckInterval, + active: monitor.isActive, + data: monitor, + upsert: true, + }); + }; + + updateJob = async (monitor: Monitor) => { + this.scheduler.updateJob(monitor.id, { repeat: monitor.interval, data: monitor }); + await this.syncGeoJob(monitor); + }; + + shutdown = async () => { + // Stop beating before we remove this worker, so a heartbeat firing between + // the remove and the scheduler stopping can't re-insert it. + if (this.heartbeatListener) { + this.scheduler.off("scheduler:heartbeat", this.heartbeatListener); + this.heartbeatListener = null; + } + await this.workersRepository.deleteById(this.scheduler.workerId).catch((error: unknown) => { + this.logger.warn({ + message: `Failed to deregister worker on shutdown: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + method: "shutdown", + }); + }); + this.scheduler.stop(); + }; + + getMetrics = async (): Promise => { + const stats = await this.scheduler.getStats(); + + const jobsWithFailures = stats.jobsWithFailures.map((failure) => { + // `data` is untyped at the store level: for monitor jobs it's the Monitor, + // for system jobs (e.g. cleanup) it has no url/type => null + const monitor = failure.data as Monitor | undefined; + return { + monitorId: failure.id, + monitorUrl: monitor?.url ?? null, + monitorType: monitor?.type ?? null, + failedAt: failure.failedAt, + failCount: failure.failCount, + failReason: failure.failReason, + }; + }); + + const workers = await this.workersRepository.findRecent(WORKER_TTL_MS); + return { + jobs: stats.jobs, + activeJobs: stats.activeJobs, + failingJobs: stats.failingJobs, + totalRuns: stats.totalRuns, + totalFailures: stats.totalFailures, + jobsWithFailures, + workers, + }; + }; + + getJobs = async ({ page = 0, rowsPerPage = 0 }: QueueJobsPagination): Promise => { + const listOptions = rowsPerPage > 0 ? { skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage } : undefined; + const [jobs, count] = await Promise.all([this.scheduler.getJobs(listOptions), this.scheduler.countJobs()]); + const mapped = jobs.map((job) => { + const monitor = isMonitorJob(job) ? job.data : undefined; + + return { + monitorId: job.id, + monitorUrl: monitor?.url || null, + monitorType: monitor?.type || null, + monitorInterval: monitor?.interval || null, + monitorGeoInterval: monitor?.geoCheckInterval || null, + monitorActive: monitor?.isActive ?? null, + active: job.active, + repeat: job.repeat ?? null, + lockedBy: job.lockedBy ?? null, + lockedUntil: job.lockedUntil ?? null, + lockedAt: job.lockedAt ?? null, + runCount: job.runCount ?? 0, + failCount: job.failCount ?? 0, + failReason: job.lastError ?? null, + lastRunAt: job.lastStartedAt ?? null, + lastFinishedAt: job.lastFinishedAt ?? null, + lastRunTook: job.lockedAt || !job.lastFinishedAt || !job.lastStartedAt ? null : job.lastFinishedAt - job.lastStartedAt, + lastFailedAt: job.lastFailedAt ?? null, + }; + }); + + return { jobs: mapped, count }; + }; + + flushQueues = async () => { + if (this.queueMode !== "primary") { + // Don't allow workers to flush, though this isn't currently reachable since workers have no API endpoints + this.logger.warn({ message: "Ignoring flush on non-primary worker", service: SERVICE_NAME, method: "flushQueues" }); + return { success: false }; + } + + const flushRes = await this.scheduler.flushJobs(); // Less Simple Queue must flush before stopping as it needs DB + const stopRes = await this.scheduler.stop(); // Destructive, other workers will abort their jobs + const initRes = await this.init(); + return { + success: Boolean(stopRes && flushRes && initRes), + }; + }; +} diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/JobQueues/QueueHelper.ts similarity index 92% rename from server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts rename to server/src/service/infrastructure/JobQueues/QueueHelper.ts index 956d19c98..ea86feae0 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/JobQueues/QueueHelper.ts @@ -11,7 +11,8 @@ import { IncidentService, type IGeoChecksService, } from "@/service/index.js"; -import { CHECK_TTL_SENTINEL, type MaintenanceWindow, type StatusChangeResult } from "@/types/index.js"; +import { CHECK_TTL_SENTINEL, type StatusChangeResult } from "@/types/index.js"; +import { isWindowActive } from "@/utils/maintenanceWindow.js"; import { IMaintenanceWindowsRepository, IMonitorsRepository, @@ -24,7 +25,7 @@ import { import { ILogger } from "@/utils/logger.js"; import { IBufferService } from "@/service/index.js"; -export interface ISuperSimpleQueueHelper { +export interface IQueueHelper { readonly serviceName: string; getHeartbeatJob(): (monitor: Monitor) => Promise; getHeartbeatGeoJob(): (monitor: Monitor) => Promise; @@ -47,7 +48,7 @@ export interface MonitorActionDecision { }; } -export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { +export class QueueHelper implements IQueueHelper { static SERVICE_NAME = SERVICE_NAME; private logger: ILogger; @@ -104,7 +105,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { } get serviceName() { - return SuperSimpleQueueHelper.SERVICE_NAME; + return QueueHelper.SERVICE_NAME; } getHeartbeatJob = () => { @@ -358,32 +359,8 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { async isInMaintenanceWindow(monitorId: string, teamId: string) { const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); - // Check for active maintenance window: - const maintenanceWindowIsActive = maintenanceWindows.reduce((acc: boolean, window: MaintenanceWindow) => { - if (window.active) { - const start = new Date(window.start); - const end = new Date(window.end); - const now = new Date(); - const repeatInterval = window.repeat || 0; - - // If start is < now and end > now, we're in maintenance - if (start <= now && end >= now) return true; - - // If maintenance window was set in the past with a repeat, - // we need to advance start and end to see if we are in range - - while (start < now && repeatInterval !== 0) { - start.setTime(start.getTime() + repeatInterval); - end.setTime(end.getTime() + repeatInterval); - if (start <= now && end >= now) { - return true; - } - } - return acc; - } - return acc; - }, false); - return maintenanceWindowIsActive; + const now = new Date(); + return maintenanceWindows.some((window) => isWindowActive(window, now)); } getCleanupRetentionJob = () => { diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts similarity index 75% rename from server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts rename to server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts index 7260c82c7..42409696b 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/JobQueues/SuperSimpleQueue.ts @@ -1,71 +1,31 @@ import { IMonitorsRepository } from "@/repositories/index.js"; import { ILogger } from "@/utils/logger.js"; import Scheduler from "super-simple-scheduler"; -import { ISuperSimpleQueueHelper } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import { IQueueHelper } from "@/service/infrastructure/JobQueues/QueueHelper.js"; import { Monitor, MonitorType, supportsGeoCheck } from "@/types/monitor.js"; +import { hostname } from "node:os"; +import { randomUUID } from "node:crypto"; +import { QueueMode } from "@/types/settings.js"; +import { IJobQueue, type QueueJobSummary } from "@/service/infrastructure/JobQueues/IJobQueue.js"; + const SERVICE_NAME = "JobQueue"; -type QueueJobFailure = { - monitorId: string | number; - monitorUrl: string | null; - monitorType: string | null; - failedAt: number | null; - failCount: number; - failReason: string | null; -}; - -type QueueMetrics = { - jobs: number; - activeJobs: number; - failingJobs: number; - jobsWithFailures: QueueJobFailure[]; - totalRuns: number; - totalFailures: number; -}; - -type QueueJobSummary = { - monitorId: string | number; - monitorUrl: string | null; - monitorType: string | null; - monitorInterval: number | null; - active: boolean; - lockedAt: number | null; - runCount: number; - failCount: number; - failReason: string | null; - lastRunAt: number | null; - lastFinishedAt: number | null; - lastRunTook: number | null; - lastFailedAt: number | null; -}; - -export interface ISuperSimpleQueue { - readonly serviceName: string; - init(): Promise; - addJob(monitorId: string, monitor: Monitor): Promise; - deleteJob(monitor: Monitor): Promise; - pauseJob(monitor: Monitor): Promise; - resumeJob(monitor: Monitor): Promise; - updateJob(monitor: Monitor): Promise; - shutdown(): Promise; - getMetrics(): Promise; - getJobs(): Promise; - flushQueues(): Promise<{ success: boolean }>; -} - -export class SuperSimpleQueue implements ISuperSimpleQueue { +export class SuperSimpleQueue implements IJobQueue { static SERVICE_NAME = SERVICE_NAME; private logger: ILogger; - private helper: ISuperSimpleQueueHelper; + private helper: IQueueHelper; + private workerId: string; private monitorsRepository: IMonitorsRepository; private readonly scheduler: Scheduler; - constructor(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) { + constructor(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository, scheduler: Scheduler) { this.logger = logger; this.helper = helper; this.monitorsRepository = monitorsRepository; this.scheduler = scheduler; + this.registerListeners(); + this.workerId = `${hostname()}:${process.pid}:${randomUUID()}`; } get serviceName() { @@ -140,8 +100,9 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { }); }; - static async create(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository) { + static async create(logger: ILogger, helper: IQueueHelper, monitorsRepository: IMonitorsRepository) { const scheduler = new Scheduler({ + concurrency: 50, // storeType: "mongo", // storeType: "redis", // dbUri: envSettings.dbConnectionString, @@ -154,8 +115,6 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { init = async () => { try { - this.registerListeners(); - this.scheduler.start(); this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob()); this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob()); @@ -223,8 +182,20 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { if (result === false) { throw new Error("Failed to pause monitor"); } + this.scheduler.updateJob(monitor.id, { data: monitor }); + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); - if (geoJob) await this.scheduler.removeJob(`${monitor.id}-geo`); + if (geoJob) { + const geoRes = await this.scheduler.pauseJob(`${monitor.id}-geo`); + if (geoRes === false) { + this.logger.error({ + message: `Failed to pause geo check job for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "pauseJob", + }); + } + this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); + } this.logger.debug({ message: `Paused monitor ${monitor.id}`, @@ -238,9 +209,20 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { if (result === false) { throw new Error("Failed to resume monitor"); } + this.scheduler.updateJob(monitor.id, { data: monitor }); - await this.scheduler.resumeJob(`${monitor.id}-geo`); - + const geoJob = await this.scheduler.getJob(`${monitor.id}-geo`); + if (geoJob) { + const geoRes = await this.scheduler.resumeJob(`${monitor.id}-geo`); + if (geoRes === false) { + this.logger.error({ + message: `Failed to resume geo check job for monitor ${monitor.id}`, + service: SERVICE_NAME, + method: "resumeJob", + }); + } + this.scheduler.updateJob(`${monitor.id}-geo`, { data: monitor }); + } this.logger.debug({ message: `Resumed monitor ${monitor.id}`, service: SERVICE_NAME, @@ -285,7 +267,26 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { getMetrics = async () => { const jobs = await this.scheduler.getJobs(); - const metrics = jobs.reduce( + const metrics: { + jobs: number; + activeJobs: number; + failingJobs: number; + jobsWithFailures: Array<{ + monitorId: string | number; + monitorUrl: string; + monitorType: MonitorType; + failedAt: number | null; + failCount: number; + failReason: string | null; + }>; + totalRuns: number; + totalFailures: number; + workers: Array<{ + workerId: string; + mode: QueueMode; + lastSeenAt: number; + }>; + } = jobs.reduce( (acc, job) => { const runCount = job.runCount ?? 0; const failCount = job.failCount ?? 0; @@ -328,20 +329,32 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { }>, totalRuns: 0, totalFailures: 0, + workers: [ + { + workerId: this.workerId, + mode: "primary", + lastSeenAt: Date.now(), + }, + ], } ); return metrics; }; - getJobs = async () => { + getJobs = async ({ page = 0, rowsPerPage = 0 }: { page?: number; rowsPerPage?: number }): Promise<{ jobs: QueueJobSummary[]; count: number }> => { const jobs = await this.scheduler.getJobs(); - return jobs.map((job) => { + const mapped = jobs.map((job) => { return { monitorId: job.id, monitorUrl: job.data?.url || null, monitorType: job.data?.type || null, monitorInterval: job.data?.interval || null, + monitorGeoInterval: job.data?.geoCheckInterval || null, + monitorActive: job.data?.isActive ?? null, active: job.active, + repeat: job.repeat ?? null, + lockedBy: job.lockedAt ? this.workerId : null, + lockedUntil: null, lockedAt: job.lockedAt ?? null, runCount: job.runCount ?? 0, failCount: job.failCount ?? 0, @@ -352,6 +365,12 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { lastFailedAt: job.lastFailedAt ?? null, }; }); + + const start = Math.max(page, 0) * rowsPerPage; + return { + jobs: rowsPerPage > 0 ? mapped.slice(start, start + rowsPerPage) : mapped, + count: mapped.length, + }; }; flushQueues = async () => { diff --git a/server/src/service/infrastructure/bufferService.ts b/server/src/service/infrastructure/bufferService.ts index 07be446c6..5a1fb3d12 100755 --- a/server/src/service/infrastructure/bufferService.ts +++ b/server/src/service/infrastructure/bufferService.ts @@ -26,7 +26,7 @@ export class BufferService implements IBufferService { private geoChecksService: IGeoChecksService; constructor(logger: ILogger, checkService: ICheckService, geoChecksService: IGeoChecksService, settingsService: ISettingsService) { - this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 10 : 1000 * 60 * 1; // 1 minute + this.BUFFER_TIMEOUT = settingsService.getSettings().nodeEnv === "development" ? 1000 : 1000 * 60 * 1; // 1 minute this.logger = logger; this.checksService = checkService; this.geoChecksService = geoChecksService; diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index 6cf8caf4f..4428b0477 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -118,6 +118,7 @@ export class EmailService implements IEmailService { systemEmailPool, systemEmailUser, systemEmailAddress, + systemEmailDisplayName, systemEmailPassword, systemEmailConnectionHost, systemEmailTLSServername, @@ -158,10 +159,13 @@ export class EmailService implements IEmailService { return false; } + const trimmedDisplayName = systemEmailDisplayName?.trim(); + const from = trimmedDisplayName && systemEmailAddress ? { name: trimmedDisplayName, address: systemEmailAddress } : systemEmailAddress; + try { const info = await this.transporter.sendMail({ to: to, - from: systemEmailAddress, + from: from, subject: subject, html: html, }); diff --git a/server/src/service/infrastructure/network/DNSProvider.ts b/server/src/service/infrastructure/network/DNSProvider.ts new file mode 100644 index 000000000..92b4eb333 --- /dev/null +++ b/server/src/service/infrastructure/network/DNSProvider.ts @@ -0,0 +1,104 @@ +import { DNSStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; +import type { Resolver } from "dns/promises"; +import { Monitor, MonitorType } from "@/types/monitor.js"; +import { AppError } from "@/utils/AppError.js"; +import { NETWORK_ERROR, timeRequest } from "@/service/infrastructure/network/utils.js"; + +const SERVICE_NAME = "DNSProvider"; + +export class DNSProvider implements IStatusProvider { + readonly type = "dns"; + constructor(private createResolver: () => Resolver) {} + supports(type: MonitorType) { + return type === "dns"; + } + + async handle(monitor: Monitor): Promise> { + try { + const { url, dnsServer, dnsRecordType = "A" } = monitor; + if (!dnsServer) { + throw new Error("DNS server is required for DNS monitoring"); + } + + let hostname: string; + try { + const withScheme = /^[a-z]+:\/\//i.test(url) ? url : `http://${url}`; // Ensure URL has a scheme for proper parsing + hostname = new URL(withScheme).hostname; + } catch { + hostname = url; + } + + const resolver = this.createResolver(); + resolver.setServers([dnsServer]); + + const { + response: results, + responseTime, + error, + } = await timeRequest(() => { + switch (dnsRecordType.toUpperCase()) { + case "A": + return resolver.resolve4(hostname); + case "AAAA": + return resolver.resolve6(hostname); + case "CNAME": + return resolver.resolveCname(hostname); + case "MX": + return resolver.resolveMx(hostname); + case "TXT": + return resolver.resolveTxt(hostname); + case "NS": + return resolver.resolveNs(hostname); + default: + return resolver.resolve(hostname, dnsRecordType); + } + }); + + if (error) { + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: NETWORK_ERROR, + message: error instanceof Error ? error.message : String(error), + responseTime, + payload: { + hostname, + dnsServer, + recordType: dnsRecordType, + resolved: false, + results, + }, + }; + } + + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: true, + code: 200, + message: "Success", + responseTime, + payload: { + hostname, + dnsServer, + recordType: dnsRecordType, + resolved: true, + results, + }, + }; + } catch (error: unknown) { + const originalMessage = error instanceof Error ? error.message : String(error); + throw new AppError({ + message: originalMessage || "Error performing DNS check", + status: 500, + service: SERVICE_NAME, + method: "handle", + details: { url: monitor.url }, + }); + } + } +} diff --git a/server/src/service/infrastructure/network/HttpProvider.ts b/server/src/service/infrastructure/network/HttpProvider.ts index bbc0934f8..247965cbf 100644 --- a/server/src/service/infrastructure/network/HttpProvider.ts +++ b/server/src/service/infrastructure/network/HttpProvider.ts @@ -4,6 +4,7 @@ import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvide import { HttpStatusPayload } from "@/types/network.js"; import { MonitorStatusResponse } from "@/types/network.js"; import { Agent as HttpsAgent } from "https"; +import { Agent as HttpAgent } from "http"; import { Monitor, MonitorType } from "@/types/monitor.js"; import { NETWORK_ERROR } from "@/service/infrastructure/network/utils.js"; import CacheableLookup from "cacheable-lookup"; @@ -11,6 +12,11 @@ import CacheableLookup from "cacheable-lookup"; export class HttpProvider implements IStatusProvider { readonly type = "http"; + // Shared, pooled agents reused across every check + private readonly httpAgent: HttpAgent; + private readonly httpsAgent: HttpsAgent; + private readonly httpsAgentInsecure: HttpsAgent; + constructor( private got: Got, private advancedMatcher: IAdvancedMatcher @@ -23,6 +29,11 @@ export class HttpProvider implements IStatusProvider { }, retry: { limit: 1 }, }); + + const agentOptions = { keepAlive: true, maxSockets: 256, maxFreeSockets: 256 }; + this.httpAgent = new HttpAgent(agentOptions); + this.httpsAgent = new HttpsAgent({ ...agentOptions, rejectUnauthorized: true }); + this.httpsAgentInsecure = new HttpsAgent({ ...agentOptions, rejectUnauthorized: false }); } supports(type: MonitorType) { @@ -38,7 +49,7 @@ export class HttpProvider implements IStatusProvider { status: false, code: error.response?.statusCode ?? NETWORK_ERROR, message: error.message, - responseTime: error.timings?.phases?.total ?? 0, + responseTime: error.timings?.phases?.firstByte ?? error.timings?.phases?.total ?? 0, timings: error.timings, payload: null as T, }; @@ -68,7 +79,8 @@ export class HttpProvider implements IStatusProvider { }; options.agent = { - https: new HttpsAgent({ rejectUnauthorized: !ignoreTlsErrors }), + http: this.httpAgent, + https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent, }; try { @@ -84,7 +96,7 @@ export class HttpProvider implements IStatusProvider { status: false, code: response.statusCode, message: "Response is not JSON", - responseTime: response.timings.phases.total ?? 0, + responseTime: response.timings.phases.firstByte ?? 0, timings: response.timings, payload: response.body as unknown as T, }; @@ -109,7 +121,7 @@ export class HttpProvider implements IStatusProvider { status: response.ok && matchResult.ok, code: response.statusCode, message: matchResult.ok ? (response.statusMessage ?? "OK") : matchResult.message, - responseTime: response.timings.phases.total ?? 0, + responseTime: response.timings.phases.firstByte ?? 0, timings: response.timings, payload, extracted: matchResult.extracted, diff --git a/server/src/service/infrastructure/network/PageSpeedProvider.ts b/server/src/service/infrastructure/network/PageSpeedProvider.ts index 121fe9bf8..0d8560868 100644 --- a/server/src/service/infrastructure/network/PageSpeedProvider.ts +++ b/server/src/service/infrastructure/network/PageSpeedProvider.ts @@ -1,6 +1,6 @@ import { IStatusProvider } from "@/service/infrastructure/network/IStatusProvider.js"; import { MonitorStatusResponse, PageSpeedStatusPayload } from "@/types/network.js"; -import { Monitor, MonitorType } from "@/types/monitor.js"; +import { Monitor, MonitorType, DefaultPageSpeedStrategy } from "@/types/monitor.js"; import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js"; import { ISettingsService } from "@/service/system/settingsService.js"; import { ILogger } from "@/utils/logger.js"; @@ -19,14 +19,15 @@ export class PageSpeedProvider implements IStatusProvider> { - const { url } = monitor; + const { url, strategy } = monitor; try { if (!url) throw new Error("URL is required for PageSpeed monitor"); const dbSettings = await this.settingsService.getDBSettings(); const apiKey = dbSettings?.pagespeedApiKey; + const resolvedStrategy = strategy ?? DefaultPageSpeedStrategy; let pageSpeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent( url - )}&category=seo&category=accessibility&category=best-practices&category=performance`; + )}&strategy=${encodeURIComponent(resolvedStrategy)}&category=seo&category=accessibility&category=best-practices&category=performance`; if (apiKey) { pageSpeedUrl += `&key=${apiKey}`; diff --git a/server/src/service/infrastructure/notificationMessageBuilder.ts b/server/src/service/infrastructure/notificationMessageBuilder.ts index 934163b2a..bc1d7ac82 100644 --- a/server/src/service/infrastructure/notificationMessageBuilder.ts +++ b/server/src/service/infrastructure/notificationMessageBuilder.ts @@ -1,5 +1,5 @@ import type { HardwareStatusPayload, Monitor, MonitorStatusResponse } from "@/types/index.js"; -import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/JobQueues/QueueHelper.js"; import type { NotificationMessage, NotificationType, diff --git a/server/src/service/infrastructure/notificationProviders/ntfy.ts b/server/src/service/infrastructure/notificationProviders/ntfy.ts new file mode 100644 index 000000000..8811cce47 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/ntfy.ts @@ -0,0 +1,128 @@ +const SERVICE_NAME = "NtfyProvider"; +import type { Notification } from "@/types/index.js"; +import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; +import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import got from "got"; + +export class NtfyProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.address || !notification.topic) { + return false; + } + + try { + await got.post(this.buildTopicUrl(notification.address, notification.topic), { + body: getTestMessage(), + headers: { + Title: "Checkmate test notification", + Priority: "default", + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "ntfy test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: err?.stack, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address || !notification.topic) { + return false; + } + + try { + await got.post(this.buildTopicUrl(notification.address, notification.topic), { + body: this.buildNtfyText(message), + headers: { + Title: message.content.title, + Priority: this.mapPriority(message.severity), + Tags: this.mapTags(message.severity), + }, + ...this.gotRequestOptions(), + }); + this.logger.info({ + message: "ntfy notification sent", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "ntfy alert failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + } + + private buildTopicUrl(address: string, topic: string): string { + return `${address.replace(/\/+$/, "")}/${encodeURIComponent(topic)}`; + } + + private buildNtfyText(message: NotificationMessage): string { + const lines = [ + message.content.summary, + "", + "Monitor Details:", + `- Name: ${message.monitor.name}`, + `- URL: ${message.monitor.url}`, + `- Type: ${message.monitor.type}`, + `- Status: ${message.monitor.status}`, + ]; + + if (message.content.details && message.content.details.length > 0) { + lines.push("", "Additional Information:"); + message.content.details.forEach((detail) => lines.push(`- ${detail}`)); + } + + if (message.content.thresholds && message.content.thresholds.length > 0) { + lines.push("", "Threshold Breaches:"); + message.content.thresholds.forEach((breach) => { + lines.push(`- ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`); + }); + } + + if (message.content.incident) { + lines.push("", `${message.clientHost}/infrastructure/${message.monitor.id}`); + } + + return lines.join("\n"); + } + + private mapPriority(severity: NotificationMessage["severity"]): string { + switch (severity) { + case "critical": + return "high"; + case "warning": + return "default"; + case "success": + return "low"; + default: + return "default"; + } + } + + private mapTags(severity: NotificationMessage["severity"]): string { + switch (severity) { + case "critical": + return "rotating_light"; + case "warning": + return "warning"; + case "success": + return "white_check_mark"; + default: + return "information_source"; + } + } +} diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 90e9d637a..09129a8af 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -2,7 +2,7 @@ import type { Monitor, MonitorStatusResponse, Notification } from "@/types/index import type { NotificationMessage } from "@/types/notificationMessage.js"; import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js"; import { INotificationProvider } from "./notificationProviders/INotificationProvider.js"; -import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/JobQueues/QueueHelper.js"; import type { ISettingsService } from "@/service/system/settingsService.js"; import { ILogger } from "@/utils/logger.js"; import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js"; @@ -36,6 +36,7 @@ export class NotificationsService implements INotificationsService { private telegramProvider: INotificationProvider; private pushoverProvider: INotificationProvider; private twilioProvider: INotificationProvider; + private ntfyProvider: INotificationProvider; private logger: ILogger; private settingsService: ISettingsService; private notificationMessageBuilder: INotificationMessageBuilder; @@ -53,6 +54,7 @@ export class NotificationsService implements INotificationsService { telegramProvider: INotificationProvider, pushoverProvider: INotificationProvider, twilioProvider: INotificationProvider, + ntfyProvider: INotificationProvider, settingsService: ISettingsService, logger: ILogger, notificationMessageBuilder: INotificationMessageBuilder @@ -69,6 +71,7 @@ export class NotificationsService implements INotificationsService { this.telegramProvider = telegramProvider; this.pushoverProvider = pushoverProvider; this.twilioProvider = twilioProvider; + this.ntfyProvider = ntfyProvider; this.settingsService = settingsService; this.logger = logger; this.notificationMessageBuilder = notificationMessageBuilder; @@ -112,6 +115,8 @@ export class NotificationsService implements INotificationsService { return await this.pushoverProvider.sendMessage!(notification, notificationMessage); case "twilio": return await this.twilioProvider.sendMessage!(notification, notificationMessage); + case "ntfy": + return await this.ntfyProvider.sendMessage!(notification, notificationMessage); default: this.logger.warn({ message: `Unknown notification type: ${notification.type}`, @@ -178,6 +183,8 @@ export class NotificationsService implements INotificationsService { return await this.pushoverProvider.sendTestAlert(notification); case "twilio": return await this.twilioProvider.sendTestAlert(notification); + case "ntfy": + return await this.ntfyProvider.sendTestAlert(notification); default: return false; } diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index e18fe4aea..617f15944 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -20,8 +20,9 @@ import { AppError } from "@/utils/AppError.js"; import { ILogger } from "@/utils/logger.js"; import { IBufferService } from "./bufferService.js"; import type { HardwareStatusMetrics } from "@/types/network.js"; +import { MAX_RECENT_CHECKS } from "@/types/index.js"; + const SERVICE_NAME = "StatusService"; -const MAX_RECENT_CHECKS = 25; const HARDWARE_ALERT_COUNTER_START = 5; const HARDWARE_METRIC_KEYS = ["cpu", "memory", "disk", "temp"] as const; type HardwareMetricKey = (typeof HARDWARE_METRIC_KEYS)[number]; diff --git a/server/src/service/infrastructure/tagService.ts b/server/src/service/infrastructure/tagService.ts new file mode 100644 index 000000000..5dc6f7cef --- /dev/null +++ b/server/src/service/infrastructure/tagService.ts @@ -0,0 +1,37 @@ +import { Tag } from "@/types/index.js"; +import { ITagsRepository, IMonitorsRepository } from "@/repositories/index.js"; +export interface ITagsService { + createTag(tag: Partial, teamId: string): Promise; + getTag(tagId: string, teamId: string): Promise; + getTagsByTeamId(teamId: string): Promise; + updateTag(tagId: string, teamId: string, patch: Partial): Promise; + deleteTag(tagId: string, teamId: string): Promise; +} + +export class TagsService implements ITagsService { + constructor( + private tagsRepository: ITagsRepository, + private monitorsRepository: IMonitorsRepository + ) {} + + createTag = async (tagData: Partial, teamId: string): Promise => { + tagData.teamId = teamId; + return await this.tagsRepository.create(tagData); + }; + + getTag = async (tagId: string, teamId: string): Promise => { + return await this.tagsRepository.findById(tagId, teamId); + }; + + getTagsByTeamId = async (teamId: string): Promise => { + return await this.tagsRepository.findByTeamId(teamId); + }; + + updateTag = async (tagId: string, teamId: string, patch: Partial): Promise => { + return await this.tagsRepository.updateById(tagId, teamId, patch); + }; + deleteTag = async (tagId: string, teamId: string): Promise => { + await this.monitorsRepository.removeTagFromMonitors(tagId); + return await this.tagsRepository.deleteById(tagId, teamId); + }; +} diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index 32b95d261..dfbf6cfdc 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/service/system/settingsService.ts @@ -1,5 +1,5 @@ import { ISettingsRepository } from "@/repositories/index.js"; -import { type DbType, Settings, SettingsUpdate } from "@/types/index.js"; +import { type DbType, type QueueType, type QueueMode, Settings, SettingsUpdate } from "@/types/index.js"; import { AppError } from "@/utils/AppError.js"; import { ValidatedEnv } from "@/validation/envValidation.js"; import type { StringValue } from "ms"; @@ -13,6 +13,8 @@ export type EnvConfig = { clientHost: string; dbConnectionString: string; dbType: DbType; + queueType: QueueType; + queueMode: QueueMode; statusPageThemesEnabled: boolean; }; @@ -39,6 +41,8 @@ export class SettingsService implements ISettingsService { clientHost: env.CLIENT_HOST, dbConnectionString: env.DB_CONNECTION_STRING, dbType: env.DB_TYPE, + queueType: env.QUEUE_TYPE, + queueMode: env.QUEUE_MODE, statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED, }; } diff --git a/server/src/shutdown.ts b/server/src/shutdown.ts index 877aef325..3c1fc4ee7 100644 --- a/server/src/shutdown.ts +++ b/server/src/shutdown.ts @@ -2,7 +2,7 @@ import { InitializedServices } from "./config/services.js"; import { logger } from "./utils/logger.js"; import type { Server } from "http"; -export const initShutdownListener = (server: Server, services: InitializedServices) => { +export const initShutdownListener = (server: Server | null, services: InitializedServices) => { const SERVICE_NAME = "Server"; let isShuttingDown = false; @@ -15,7 +15,7 @@ export const initShutdownListener = (server: Server, services: InitializedServic logger.info({ message: "Attempting graceful shutdown" }); try { - server.close(); + server?.close(); await services.jobQueue.shutdown(); await services.db.disconnect(); logger.info({ message: "Graceful shutdown complete" }); diff --git a/server/src/types/email.ts b/server/src/types/email.ts index 2ffae187b..6e884319f 100644 --- a/server/src/types/email.ts +++ b/server/src/types/email.ts @@ -5,6 +5,7 @@ export type EmailTransportConfig = Pick< | "systemEmailHost" | "systemEmailPort" | "systemEmailAddress" + | "systemEmailDisplayName" | "systemEmailPassword" | "systemEmailUser" | "systemEmailConnectionHost" diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 89de348e2..ca055ba70 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -15,3 +15,4 @@ export * from "@/types/email.js"; export * from "@/types/team.js"; export * from "@/types/maintenanceWindow.js"; export * from "@/types/dlqItem.js"; +export * from "@/types/tag.js"; diff --git a/server/src/types/maintenanceWindow.ts b/server/src/types/maintenanceWindow.ts index bcbd075a1..4fc446dda 100644 --- a/server/src/types/maintenanceWindow.ts +++ b/server/src/types/maintenanceWindow.ts @@ -1,8 +1,9 @@ -export type DurationUnit = "seconds" | "minutes" | "hours" | "days"; +export const DurationUnits = ["seconds", "minutes", "hours", "days"] as const; +export type DurationUnit = (typeof DurationUnits)[number]; export interface MaintenanceWindow { id: string; - monitorId: string; + monitorIds: string[]; teamId: string; active: boolean; name: string; diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index f29ce75d7..0708297f3 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -3,18 +3,40 @@ export type { CheckSnapshot } from "@/types/check.js"; import type { GeoContinent, GroupedGeoCheck } from "@/types/geoCheck.js"; export type { GeoContinent } from "@/types/geoCheck.js"; -export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"] as const; +export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; +export const PageSpeedStrategies = ["desktop", "mobile"] as const; +export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number]; +export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop"; + export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const; export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type); +export const UptimeDetailsSupportedTypes = [ + "http", + "ping", + "docker", + "port", + "game", + "grpc", + "websocket", + "dns", +] as const satisfies readonly MonitorType[]; +export type UptimeDetailsSupportedType = (typeof UptimeDetailsSupportedTypes)[number]; +export const supportsUptimeDetails = (type: MonitorType): type is UptimeDetailsSupportedType => UptimeDetailsSupportedTypes.some((t) => t === type); + export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; export const MonitorMatchMethods = ["equal", "include", "regex"] as const; export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | ""; +export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; +export type DnsRecordType = (typeof DnsRecordTypes)[number]; + +export const MAX_RECENT_CHECKS = 50; + export interface Monitor { id: string; userId: string; @@ -37,6 +59,7 @@ export interface Monitor { interval: number; uptimePercentage?: number; notifications: string[]; + tags: string[]; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; @@ -49,10 +72,13 @@ export interface Monitor { selectedDisks: string[]; gameId?: string; grpcServiceName?: string; + strategy?: PageSpeedStrategy; group: string | null; geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + dnsServer?: string; + dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 6c7172724..19f063cc6 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -8,6 +8,7 @@ import type { CheckNetworkInterfaceInfo, GotTimings, ILighthouseAudit, + DnsRecordType, Monitor, MonitorMatchMethod, MonitorType, @@ -116,6 +117,14 @@ export interface WebSocketStatusPayload { connected: boolean; } +export interface DNSStatusPayload { + hostname: string; + dnsServer: string; + recordType: DnsRecordType; + resolved: boolean; + results: unknown; +} + export interface MonitorPayloadMap { ping: PingStatusPayload; http: HttpStatusPayload; @@ -126,6 +135,7 @@ export interface MonitorPayloadMap { game: GameStatusPayload; grpc: GrpcStatusPayload; websocket: WebSocketStatusPayload; + dns: DNSStatusPayload; unknown: unknown; } diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index 9d5b914c0..0e389f9e2 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -9,6 +9,7 @@ export const NotificationChannels = [ "telegram", "pushover", "twilio", + "ntfy", ] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; @@ -25,6 +26,7 @@ export interface Notification { accessToken?: string; accountSid?: string; twilioPhoneNumber?: string; + topic?: string; createdAt: string; updatedAt: string; } diff --git a/server/src/types/settings.ts b/server/src/types/settings.ts index eb8452866..4a4ff02a2 100644 --- a/server/src/types/settings.ts +++ b/server/src/types/settings.ts @@ -1,6 +1,18 @@ export const DbTypes = ["mongodb"] as const; export type DbType = (typeof DbTypes)[number]; +export const QueueTypes = ["lessSimpleQueue", "superSimpleQueue"] as const; +export type QueueType = (typeof QueueTypes)[number]; + +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 SettingsThresholds { cpu?: number; memory?: number; @@ -21,6 +33,7 @@ export interface Settings { systemEmailHost?: string; systemEmailPort?: number; systemEmailAddress?: string; + systemEmailDisplayName?: string; systemEmailPassword?: string; systemEmailUser?: string; systemEmailConnectionHost?: string; diff --git a/server/src/types/tag.ts b/server/src/types/tag.ts new file mode 100644 index 000000000..bab6f740a --- /dev/null +++ b/server/src/types/tag.ts @@ -0,0 +1,8 @@ +export interface Tag { + id: string; + teamId: string; + name: string; + color: string; + createdAt: string; + updatedAt: string; +} diff --git a/server/src/utils/maintenanceWindow.ts b/server/src/utils/maintenanceWindow.ts new file mode 100644 index 000000000..aab4efd64 --- /dev/null +++ b/server/src/utils/maintenanceWindow.ts @@ -0,0 +1,28 @@ +import type { MaintenanceWindow } from "@/types/index.js"; + +export const isWindowActive = (window: MaintenanceWindow, now: Date = new Date()): boolean => { + if (!window.active) { + return false; + } + + const start = new Date(window.start); + const end = new Date(window.end); + const repeatInterval = window.repeat || 0; + + if (start <= now && end >= now) { + return true; + } + + // For a recurring window whose first occurrence is in the past, + // advance start/end by the repeat interval until we either catch + // an occurrence covering `now` or move past it. + while (start < now && repeatInterval !== 0) { + start.setTime(start.getTime() + repeatInterval); + end.setTime(end.getTime() + repeatInterval); + if (start <= now && end >= now) { + return true; + } + } + + return false; +}; diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts index e511118e6..0c58b9df1 100644 --- a/server/src/validation/checkValidation.ts +++ b/server/src/validation/checkValidation.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; +import { MonitorTypes } from "@/types/monitor.js"; //**************************************** // Check Validations @@ -11,7 +12,7 @@ export const getChecksParamValidation = z.object({ }); export const getChecksQueryValidation = z.object({ - type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"]).optional(), + type: z.enum(MonitorTypes).optional(), sortOrder: z.enum(["asc", "desc"]), limit: z.coerce.number().optional(), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]), diff --git a/server/src/validation/envValidation.ts b/server/src/validation/envValidation.ts index 3adf4c90c..5280a8036 100644 --- a/server/src/validation/envValidation.ts +++ b/server/src/validation/envValidation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { DbTypes } from "@/types/settings.js"; +import { DbTypes, QueueTypes, QueueModes } from "@/types/settings.js"; import { booleanCoercion } from "./shared.js"; const envSchema = z.object({ @@ -12,6 +12,9 @@ const envSchema = z.object({ DB_CONNECTION_STRING: z.string().min(1, "Database connection string is required"), DB_TYPE: z.enum(DbTypes).default("mongodb"), + QUEUE_TYPE: z.enum(QueueTypes).default("superSimpleQueue"), + QUEUE_MODE: z.enum(QueueModes).default("primary"), + // JWT Authentication JWT_SECRET: z.string().min(1, "JWT_SECRET is required"), TOKEN_TTL: z.string().default("99d"), diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts index cc1d33372..8627d3892 100644 --- a/server/src/validation/index.ts +++ b/server/src/validation/index.ts @@ -21,3 +21,4 @@ export * from "./statusPageValidation.js"; export * from "./notificationValidation.js"; export * from "./userValidation.js"; export * from "./incidentValidation.js"; +export * from "./tagValidation.js"; diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index bee06e7bc..837db28e8 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; +import { DurationUnits } from "@/types/maintenanceWindow.js"; const dateToString = z.coerce.date().transform((d) => d.toISOString()); @@ -7,17 +8,37 @@ const dateToString = z.coerce.date().transform((d) => d.toISOString()); // Maintenance Window Validations //**************************************** -export const createMaintenanceWindowBodyValidation = z.object({ - monitors: z.array(z.string()).min(1, "At least one monitor is required"), - name: z.string().min(1, "Name is required"), - active: z.boolean().optional(), - duration: z.number().min(1, "Duration is required"), - durationUnit: z.enum(["seconds", "minutes", "hours", "days"]), - start: dateToString, - end: dateToString, - repeat: z.number().min(0, "Repeat must be a non-negative number"), - expiry: dateToString.optional(), -}); +export const createMaintenanceWindowBodyValidation = z + .object({ + monitors: z.array(z.string()).min(1, "At least one monitor is required"), + name: z.string().min(1, "Name is required"), + active: z.boolean().optional(), + duration: z.number().min(1, "Duration is required"), + durationUnit: z.enum(DurationUnits), + start: dateToString, + end: dateToString, + repeat: z.number().min(0, "Repeat must be a non-negative number"), + expiry: dateToString.optional(), + }) + .strict() + .superRefine((data, ctx) => { + const start = new Date(data.start).getTime(); + const end = new Date(data.end).getTime(); + if (end <= start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End must be after start", + path: ["end"], + }); + } + if (data.repeat === 0 && end <= Date.now()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End must be in the future for one-time maintenance windows", + path: ["end"], + }); + } + }); export const getMaintenanceWindowByIdParamValidation = z.object({ id: z.string().min(1, "ID is required"), @@ -43,14 +64,29 @@ export const editMaintenanceWindowByIdParamValidation = z.object({ id: z.string().min(1, "ID is required"), }); -export const editMaintenanceByIdWindowBodyValidation = z.object({ - active: z.boolean().optional(), - name: z.string().optional(), - repeat: z.number().optional(), - start: dateToString.optional(), - end: dateToString.optional(), - expiry: dateToString.optional(), - monitors: z.array(z.string()).optional(), - duration: z.number().optional(), - durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(), -}); +export const editMaintenanceByIdWindowBodyValidation = z + .object({ + active: z.boolean().optional(), + name: z.string().optional(), + repeat: z.number().optional(), + start: dateToString.optional(), + end: dateToString.optional(), + expiry: dateToString.optional(), + monitors: z.array(z.string()).min(1, "At least one monitor is required").optional(), + duration: z.number().optional(), + durationUnit: z.enum(DurationUnits).optional(), + }) + .strict() + .superRefine((data, ctx) => { + if (data.start && data.end) { + const start = new Date(data.start).getTime(); + const end = new Date(data.end).getTime(); + if (end <= start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End must be after start", + path: ["end"], + }); + } + } + }); diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index df000ecef..d5164b8ef 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -import { booleanCoercion } from "./shared.js"; +import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; -import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js"; +import { DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes, PageSpeedStrategies } from "@/types/monitor.js"; export const getMonitorByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -20,13 +20,9 @@ export const getMonitorByIdQueryValidation = z.object({ export const getMonitorsByTeamIdParamValidation = z.object({}); export const getMonitorsByTeamIdQueryValidation = z.object({ - type: z - .union([ - z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]), - z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])), - ]) - .optional(), + type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(), filter: z.union([z.string(), z.literal("")]).optional(), + tags: z.union([z.string(), z.array(z.string())]).optional(), }); export const getMonitorsWithChecksQueryValidation = z.object({ @@ -36,12 +32,8 @@ export const getMonitorsWithChecksQueryValidation = z.object({ filter: z.union([z.string(), z.literal("")]).optional(), field: z.string().optional(), order: z.enum(["asc", "desc"]).optional(), - type: z - .union([ - z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]), - z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"])), - ]) - .optional(), + type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(), + tags: z.union([z.string(), z.array(z.string())]).optional(), explain: booleanCoercion.optional(), }); @@ -49,70 +41,112 @@ export const getCertificateParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); -export const createMonitorBodyValidation = z.object({ - _id: z.string().optional(), - name: z.string().min(1, "Name is required"), - description: z.union([z.string(), z.literal("")]).optional(), - type: z.enum(MonitorTypes, "Invalid monitor type"), - statusWindowSize: z.number().min(1).max(20).default(5), - statusWindowThreshold: z.number().min(1).max(100).default(60), - url: z.string().min(1, "URL is required"), - ignoreTlsErrors: z.boolean().default(false), - useAdvancedMatching: z.boolean().default(false), - port: z.number().optional(), - isActive: z.boolean().optional(), - interval: z.number().optional(), - cpuAlertThreshold: z.number().optional(), - memoryAlertThreshold: z.number().optional(), - diskAlertThreshold: z.number().optional(), - tempAlertThreshold: z.number().optional(), - notifications: z.array(z.string()).optional(), - secret: z.string().optional(), - jsonPath: z.union([z.string(), z.literal("")]).optional(), - expectedValue: z.union([z.string(), z.literal("")]).optional(), - matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), - gameId: z.union([z.string(), z.literal("")]).optional(), - grpcServiceName: z.union([z.string(), z.literal("")]).default(""), - selectedDisks: z.array(z.string()).optional(), - group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), - geoCheckEnabled: z.boolean().optional(), - geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), - geoCheckInterval: z.number().min(300000).optional(), -}); +const refineDnsHostname = (body: { type?: string; url?: string }, ctx: z.RefinementCtx) => { + if (body.type === "dns" && body.url && !dnsHostnameRegex.test(body.url)) { + ctx.addIssue({ + code: "custom", + path: ["url"], + message: "Enter a valid domain (e.g. www.example.com)", + }); + } +}; -export const editMonitorBodyValidation = z.object({ - name: z.string().optional(), - type: z.enum(MonitorTypes).optional(), - url: z.string().optional(), - statusWindowSize: z.number().min(1).max(20).default(5), - statusWindowThreshold: z.number().min(1).max(100).default(60), - description: z.union([z.string(), z.literal("")]).optional(), - interval: z.number().optional(), - notifications: z.array(z.string()).optional(), - secret: z.string().optional(), - ignoreTlsErrors: z.boolean().optional(), - useAdvancedMatching: z.boolean().optional(), - jsonPath: z.union([z.string(), z.literal("")]).optional(), - expectedValue: z.union([z.string(), z.literal("")]).optional(), - matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), - port: z.number().min(1).max(65535).optional(), - cpuAlertThreshold: z.number().optional(), - memoryAlertThreshold: z.number().optional(), - diskAlertThreshold: z.number().optional(), - tempAlertThreshold: z.number().optional(), - gameId: z.union([z.string(), z.literal("")]).optional(), - grpcServiceName: z.union([z.string(), z.literal("")]).optional(), - selectedDisks: z.array(z.string()).optional(), - group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), - geoCheckEnabled: z.boolean().optional(), - geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), - geoCheckInterval: z.number().min(300000).optional(), -}); +const refineStrategyType = (body: { type?: string; strategy?: string }, ctx: z.RefinementCtx) => { + if (body.strategy !== undefined && body.type !== undefined && body.type !== "pagespeed") { + ctx.addIssue({ + code: "custom", + path: ["strategy"], + message: "Strategy is only valid for pagespeed monitors", + }); + } +}; + +export const createMonitorBodyValidation = z + .object({ + _id: z.string().optional(), + name: z.string().min(1, "Name is required"), + description: z.union([z.string(), z.literal("")]).optional(), + type: z.enum(MonitorTypes, "Invalid monitor type"), + statusWindowSize: z.number().min(1).max(20).default(5), + statusWindowThreshold: z.number().min(1).max(100).default(60), + url: z.string().min(1, "URL is required"), + ignoreTlsErrors: z.boolean().default(false), + useAdvancedMatching: z.boolean().default(false), + port: z.number().optional(), + isActive: z.boolean().optional(), + interval: z.number().optional(), + cpuAlertThreshold: z.number().optional(), + memoryAlertThreshold: z.number().optional(), + diskAlertThreshold: z.number().optional(), + tempAlertThreshold: z.number().optional(), + notifications: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + secret: z.string().optional(), + jsonPath: z.union([z.string(), z.literal("")]).optional(), + expectedValue: z.union([z.string(), z.literal("")]).optional(), + matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), + gameId: z.union([z.string(), z.literal("")]).optional(), + grpcServiceName: z.union([z.string(), z.literal("")]).default(""), + strategy: z.enum(PageSpeedStrategies).optional(), + selectedDisks: z.array(z.string()).optional(), + group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), + geoCheckEnabled: z.boolean().optional(), + geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), + geoCheckInterval: z.number().min(300000).optional(), + dnsServer: dnsServerValidation.optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), + }) + .superRefine(refineDnsHostname) + .superRefine(refineStrategyType); + +export const editMonitorBodyValidation = z + .object({ + name: z.string().optional(), + type: z.enum(MonitorTypes).optional(), + url: z.string().optional(), + statusWindowSize: z.number().min(1).max(20).default(5), + statusWindowThreshold: z.number().min(1).max(100).default(60), + description: z.union([z.string(), z.literal("")]).optional(), + interval: z.number().optional(), + notifications: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), + secret: z.string().optional(), + ignoreTlsErrors: z.boolean().optional(), + useAdvancedMatching: z.boolean().optional(), + jsonPath: z.union([z.string(), z.literal("")]).optional(), + expectedValue: z.union([z.string(), z.literal("")]).optional(), + matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), + port: z.number().min(1).max(65535).optional(), + cpuAlertThreshold: z.number().optional(), + memoryAlertThreshold: z.number().optional(), + diskAlertThreshold: z.number().optional(), + tempAlertThreshold: z.number().optional(), + gameId: z.union([z.string(), z.literal("")]).optional(), + grpcServiceName: z.union([z.string(), z.literal("")]).optional(), + strategy: z.enum(PageSpeedStrategies).optional(), + selectedDisks: z.array(z.string()).optional(), + group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), + geoCheckEnabled: z.boolean().optional(), + geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), + geoCheckInterval: z.number().min(300000).optional(), + dnsServer: dnsServerValidation.optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), + }) + .superRefine(refineDnsHostname) + .superRefine(refineStrategyType); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); +export const bulkPauseMonitorBodyValidation = z.object({ + monitorIds: z + .array(z.string().min(1, "Monitor ID must not be empty")) + .min(1, "At least one monitor ID is required") + .max(100, "Cannot bulk update more than 100 monitors at once"), + pause: z.boolean(), +}); + export const getUptimeDetailsByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); @@ -122,47 +156,54 @@ export const getUptimeDetailsByIdQueryValidation = z.object({ normalize: booleanCoercion.optional(), }); -const importedMonitorSchema = z.object({ - id: z.string().optional(), - userId: z.string().optional(), - teamId: z.string().optional(), - name: z.string().min(1, "Name is required"), - description: z.union([z.string(), z.literal("")]).optional(), - status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"), - statusWindow: z.array(z.boolean()).default([]), - statusWindowSize: z.number().min(1).max(20).default(5), - statusWindowThreshold: z.number().min(1).max(100).default(60), - type: z.enum(MonitorTypes, "Invalid monitor type"), - ignoreTlsErrors: z.boolean().default(false), - useAdvancedMatching: z.boolean().default(false), - jsonPath: z.union([z.string(), z.literal("")]).optional(), - expectedValue: z.union([z.string(), z.literal("")]).optional(), - matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), - url: z.string().min(1, "URL is required"), - port: z.number().optional(), - isActive: z.boolean().default(true), - interval: z.number().default(60000), - uptimePercentage: z.number().optional(), - notifications: z.array(z.string()).default([]), - secret: z.string().optional(), - cpuAlertThreshold: z.number().default(100), - cpuAlertCounter: z.number().default(5), - memoryAlertThreshold: z.number().default(100), - memoryAlertCounter: z.number().default(5), - diskAlertThreshold: z.number().default(100), - diskAlertCounter: z.number().default(5), - tempAlertThreshold: z.number().default(100), - tempAlertCounter: z.number().default(5), - selectedDisks: z.array(z.string()).default([]), - gameId: z.union([z.string(), z.literal("")]).optional(), - grpcServiceName: z.union([z.string(), z.literal("")]).default(""), - group: z.union([z.string().max(50).trim(), z.null()]).default(null), - geoCheckEnabled: z.boolean().default(false), - geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), - geoCheckInterval: z.number().min(300000).default(300000), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), -}); +const importedMonitorSchema = z + .object({ + id: z.string().optional(), + userId: z.string().optional(), + teamId: z.string().optional(), + name: z.string().min(1, "Name is required"), + description: z.union([z.string(), z.literal("")]).optional(), + status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]).default("initializing"), + statusWindow: z.array(z.boolean()).default([]), + statusWindowSize: z.number().min(1).max(20).default(5), + statusWindowThreshold: z.number().min(1).max(100).default(60), + type: z.enum(MonitorTypes, "Invalid monitor type"), + ignoreTlsErrors: z.boolean().default(false), + useAdvancedMatching: z.boolean().default(false), + jsonPath: z.union([z.string(), z.literal("")]).optional(), + expectedValue: z.union([z.string(), z.literal("")]).optional(), + matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), + url: z.string().min(1, "URL is required"), + port: z.number().optional(), + isActive: z.boolean().default(true), + interval: z.number().default(60000), + uptimePercentage: z.number().optional(), + notifications: z.array(z.string()).default([]), + tags: z.array(z.string()).default([]), + secret: z.string().optional(), + cpuAlertThreshold: z.number().default(100), + cpuAlertCounter: z.number().default(5), + memoryAlertThreshold: z.number().default(100), + memoryAlertCounter: z.number().default(5), + diskAlertThreshold: z.number().default(100), + diskAlertCounter: z.number().default(5), + tempAlertThreshold: z.number().default(100), + tempAlertCounter: z.number().default(5), + selectedDisks: z.array(z.string()).default([]), + gameId: z.union([z.string(), z.literal("")]).optional(), + grpcServiceName: z.union([z.string(), z.literal("")]).default(""), + strategy: z.enum(PageSpeedStrategies).optional(), + group: z.union([z.string().max(50).trim(), z.null()]).default(null), + geoCheckEnabled: z.boolean().default(false), + geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), + geoCheckInterval: z.number().min(300000).default(300000), + dnsServer: dnsServerValidation.optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + }) + .superRefine(refineDnsHostname) + .superRefine(refineStrategyType); export const importMonitorsBodyValidation = z.object({ monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"), @@ -177,3 +218,46 @@ export const getHardwareDetailsByIdParamValidation = z.object({ export const getHardwareDetailsByIdQueryValidation = z.object({ dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), }); + +// Canonical monitor shape returned by /monitors endpoints. Keep aligned with +// what the controllers actually serialize. +export const monitorResponseSchema = z + .object({ + _id: z.string(), + name: z.string(), + description: z.string().optional(), + type: z.enum(MonitorTypes), + url: z.string(), + port: z.number().optional(), + isActive: z.boolean(), + interval: z.number(), + status: z.enum(MonitorStatuses), + statusWindowSize: z.number(), + statusWindowThreshold: z.number(), + ignoreTlsErrors: z.boolean(), + useAdvancedMatching: z.boolean(), + jsonPath: z.string().optional(), + expectedValue: z.string().optional(), + matchMethod: z.enum(MonitorMatchMethods).optional(), + notifications: z.array(z.string()), + tags: z.array(z.string()), + secret: z.string().optional(), + cpuAlertThreshold: z.number(), + memoryAlertThreshold: z.number(), + diskAlertThreshold: z.number(), + tempAlertThreshold: z.number(), + selectedDisks: z.array(z.string()), + gameId: z.string().optional(), + grpcServiceName: z.string().optional(), + group: z.string().nullable().optional(), + geoCheckEnabled: z.boolean(), + geoCheckLocations: z.array(z.enum(GeoContinents)), + geoCheckInterval: z.number(), + dnsServer: z.string().optional(), + dnsRecordType: z.enum(DnsRecordTypes).optional(), + teamId: z.string(), + userId: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .passthrough(); diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index cf7478610..bd3cdd6c2 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -88,6 +88,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ phone: z.string().min(1, "Recipient phone number is required"), twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), }), + // ntfy notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("ntfy"), + address: z.url({ message: "Please enter a valid ntfy server URL" }), + topic: z.string().min(1, "Topic is required"), + }), ]); export const testNotificationBodyValidation = createNotificationBodyValidation; @@ -113,6 +120,7 @@ export const sendTestEmailBodyValidation = z.object({ systemEmailSecure: z.boolean().optional(), systemEmailPool: z.boolean().optional(), systemEmailAddress: z.string().optional(), + systemEmailDisplayName: z.string().optional(), systemEmailPassword: z.string().optional(), systemEmailUser: z.string().optional(), systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(), diff --git a/server/src/validation/queueValidation.ts b/server/src/validation/queueValidation.ts new file mode 100644 index 000000000..61c552751 --- /dev/null +++ b/server/src/validation/queueValidation.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const getQueueJobsQueryValidation = z.object({ + page: z.coerce.number().int().min(0).optional(), + rowsPerPage: z.coerce.number().int().min(1).max(100).optional(), +}); diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index f2c4e4c94..db58c50ed 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -14,6 +14,12 @@ export const updateAppSettingsBodyValidation = z timezone: z.string().optional(), systemEmailHost: z.string().nullable().optional(), systemEmailAddress: z.string().nullable().optional(), + systemEmailDisplayName: z + .string() + .max(100) + .transform((val) => (val.trim() === "" ? null : val.trim())) + .nullable() + .optional(), systemEmailPassword: z.string().nullable().optional(), systemEmailUser: z.string().nullable().optional(), systemEmailConnectionHost: z.string().nullable().optional(), diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts index 28fd13c82..a3e17866b 100644 --- a/server/src/validation/shared.ts +++ b/server/src/validation/shared.ts @@ -4,6 +4,11 @@ import { type UserRole } from "@/types/user.js"; export const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/])[A-Za-z0-9!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/]+$/; +export const passwordValidation = z + .string() + .min(8, "Password must be at least 8 characters") + .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"); + export const nameValidation = z .string() .trim() @@ -15,6 +20,17 @@ export const nameValidation = z export const lowercaseEmailValidation = z.email().transform((val) => val.toLowerCase()); +export const dnsServerValidation = z + .string() + .min(1, "DNS server is required") + .refine((v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)"); + +// Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, +// optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp). +// No scheme, port, path, or whitespace. Total length ≤ 253. Kept in sync with +// the client-side regex in client/src/Validation/monitor.ts. +export const dnsHostnameRegex = /^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; + export const booleanCoercion = z.preprocess((val) => { if (val === "true" || val === true) return true; if (val === "false" || val === false) return false; diff --git a/server/src/validation/tagValidation.ts b/server/src/validation/tagValidation.ts new file mode 100644 index 000000000..9a5513741 --- /dev/null +++ b/server/src/validation/tagValidation.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +//**************************************** +// Tag Validations +//**************************************** + +export const createTagBodyValidation = z.object({ + name: z.string().min(1, "Tag name is required"), + color: z + .string() + .regex(/^#([0-9a-fA-F]{6})$/, "Color must be a 6-digit hex value (e.g. #13715B)") + .optional(), +}); + +export const editTagBodyValidation = createTagBodyValidation; + +export const getTagByIdParamValidation = z.object({ + id: z.string().min(1, "Tag ID is required"), +}); +export const editTagParamValidation = z.object({ + id: z.string().min(1, "Tag ID is required"), +}); +export const deleteTagParamValidation = z.object({ + id: z.string().min(1, "Tag ID is required"), +}); diff --git a/server/src/validation/userValidation.ts b/server/src/validation/userValidation.ts index 9e11c8c57..40e7d8b54 100644 --- a/server/src/validation/userValidation.ts +++ b/server/src/validation/userValidation.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { UserRoles } from "@/types/user.js"; -import { nameValidation, lowercaseEmailValidation, passwordPattern } from "./shared.js"; +import { nameValidation, lowercaseEmailValidation, passwordValidation } from "./shared.js"; //**************************************** // User Validations @@ -27,26 +27,46 @@ export const editSuperadminUserByIdBodyValidation = z.object({ }); export const editUserPasswordByIdBodyValidation = z.object({ - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"), + password: passwordValidation, }); export const createUserBodyValidation = z.object({ firstName: nameValidation, lastName: nameValidation, email: lowercaseEmailValidation, - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"), + password: passwordValidation, role: z.array(z.enum(UserRoles)).min(1, "At least one role is required"), }); -export const editUserBodyValidation = z.object({ - firstName: nameValidation.optional(), - lastName: nameValidation.optional(), - email: lowercaseEmailValidation.optional(), - profilePicture: z.string().optional(), -}); +export const editUserBodyValidation = z + .object({ + firstName: nameValidation.optional(), + lastName: nameValidation.optional(), + email: lowercaseEmailValidation.optional(), + profilePicture: z.string().optional(), + password: passwordValidation.optional(), + newPassword: passwordValidation.optional(), + }) + // Changing a password requires both the current password and the new one. + // The service only runs the change-password flow when both are present, so + // reject a half-filled request + .refine((data) => (data.password === undefined) === (data.newPassword === undefined), { + message: "Both current password and new password are required to change your password", + path: ["newPassword"], + }); + +// Canonical user shape returned by auth/user endpoints. Keep aligned with what +// the controllers actually serialize (password is intentionally omitted). +export const userResponseSchema = z + .object({ + _id: z.string(), + firstName: z.string(), + lastName: z.string(), + email: z.string(), + role: z.array(z.string()), + teamId: z.string().optional(), + profileImage: z.string().nullable().optional(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .passthrough(); diff --git a/server/test/helpers/heartbeatTestHarness.ts b/server/test/helpers/heartbeatTestHarness.ts index ec0c3714e..9b0592afc 100644 --- a/server/test/helpers/heartbeatTestHarness.ts +++ b/server/test/helpers/heartbeatTestHarness.ts @@ -1,5 +1,5 @@ import { jest } from "@jest/globals"; -import { SuperSimpleQueueHelper } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import { QueueHelper } from "../../src/service/infrastructure/JobQueues/QueueHelper.ts"; import { StatusService } from "../../src/service/infrastructure/statusService.ts"; import { IncidentService } from "../../src/service/business/incidentService.ts"; import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts"; @@ -143,7 +143,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) }; - const helper = new SuperSimpleQueueHelper( + const helper = new QueueHelper( logger, networkService as any, statusService as any, diff --git a/server/test/integration/heartbeatJobMaintenance.test.ts b/server/test/integration/heartbeatJobMaintenance.test.ts index 663c2e61e..5df287242 100644 --- a/server/test/integration/heartbeatJobMaintenance.test.ts +++ b/server/test/integration/heartbeatJobMaintenance.test.ts @@ -8,7 +8,7 @@ const makeMaintenanceWindow = (overrides?: Partial): Maintena const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now return { id: "mw-1", - monitorId: "mon-1", + monitorIds: ["mon-1"], teamId: "team-1", active: true, name: "Test Maintenance", @@ -109,6 +109,23 @@ describe("Heartbeat job: maintenance windows", () => { expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1); }); + it("skips checks for every monitor covered by a group window", async () => { + const monitorA = makeMonitor({ id: "mon-a", name: "Monitor A" }); + const monitorB = makeMonitor({ id: "mon-b", name: "Monitor B" }); + h.monitorsRepo.seed(monitorA); + h.monitorsRepo.seed(monitorB); + + // A single group window covers both monitors + h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow({ monitorIds: ["mon-a", "mon-b"] })]); + + await h.heartbeatJob(monitorA); + await h.heartbeatJob(monitorB); + + expect((await h.monitorsRepo.findById("mon-a", "team-1")).status).toBe("maintenance"); + expect((await h.monitorsRepo.findById("mon-b", "team-1")).status).toBe("maintenance"); + expect(h.networkService.requestStatus).not.toHaveBeenCalled(); + }); + it("skips checks when window is in the past (expired)", async () => { const monitor = makeMonitor(); h.monitorsRepo.seed(monitor); diff --git a/server/test/integration/incidentLifecycle.test.ts b/server/test/integration/incidentLifecycle.test.ts index d2429a812..0fe1ddcf8 100644 --- a/server/test/integration/incidentLifecycle.test.ts +++ b/server/test/integration/incidentLifecycle.test.ts @@ -5,7 +5,7 @@ import { createMockLogger } from "../helpers/createMockLogger.ts"; import type { IMonitorsRepository, IUsersRepository } from "../../src/repositories/index.ts"; import type { INotificationMessageBuilder } from "../../src/service/infrastructure/notificationMessageBuilder.ts"; import type { Monitor } from "../../src/types/monitor.ts"; -import type { MonitorActionDecision } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import type { MonitorActionDecision } from "../../src/service/infrastructure/JobQueues/QueueHelper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/network/dnsProvider.test.ts b/server/test/unit/providers/network/dnsProvider.test.ts new file mode 100644 index 000000000..a96731be5 --- /dev/null +++ b/server/test/unit/providers/network/dnsProvider.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; +import { DNSProvider } from "../../../../src/service/infrastructure/network/DNSProvider.ts"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +const mockResolverInstance = { + setServers: jest.fn(), + resolve4: jest.fn<() => Promise>(), + resolve6: jest.fn<() => Promise>(), + resolveCname: jest.fn<() => Promise>(), + resolveMx: jest.fn<() => Promise>(), + resolveTxt: jest.fn<() => Promise>(), + resolveNs: jest.fn<() => Promise>(), + resolve: jest.fn<() => Promise>(), +}; + +const createResolver = () => mockResolverInstance as any; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "dns", + url: "example.com", + dnsServer: "8.8.8.8", + dnsRecordType: "A", + ...overrides, + }) as Monitor; + +beforeEach(() => { + mockResolverInstance.setServers.mockClear(); + mockResolverInstance.resolve4.mockReset().mockResolvedValue(["1.2.3.4"]); + mockResolverInstance.resolve6.mockReset().mockResolvedValue(["::1"]); + mockResolverInstance.resolveCname.mockReset().mockResolvedValue(["alias.example.com"]); + mockResolverInstance.resolveMx.mockReset().mockResolvedValue([{ exchange: "mx.example.com", priority: 10 }]); + mockResolverInstance.resolveTxt.mockReset().mockResolvedValue([["v=spf1"]]); + mockResolverInstance.resolveNs.mockReset().mockResolvedValue(["ns.example.com"]); + mockResolverInstance.resolve.mockReset().mockResolvedValue(["something"]); +}); + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("DNSProvider", { + create: () => new DNSProvider(createResolver), + supportedType: "dns", + unsupportedType: "ping", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("DNSProvider", () => { + it("returns success with results and payload on resolve", async () => { + mockResolverInstance.resolve4.mockResolvedValue(["1.2.3.4"]); + const provider = new DNSProvider(createResolver); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "dns", + status: true, + code: 200, + message: "Success", + payload: { + hostname: "example.com", + dnsServer: "8.8.8.8", + recordType: "A", + resolved: true, + results: ["1.2.3.4"], + }, + }) + ); + expect(typeof result.responseTime).toBe("number"); + }); + + it("calls setServers with the configured dnsServer", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ dnsServer: "1.1.1.1" })); + expect(mockResolverInstance.setServers).toHaveBeenCalledWith(["1.1.1.1"]); + }); + + it("defaults to A record when dnsRecordType is not provided", async () => { + const provider = new DNSProvider(createResolver); + const result = await provider.handle(makeMonitor({ dnsRecordType: undefined })); + expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); + expect(result.payload?.recordType).toBe("A"); + }); + + it("dispatches AAAA records to resolve6", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ dnsRecordType: "AAAA" })); + expect(mockResolverInstance.resolve6).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches CNAME records to resolveCname", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ dnsRecordType: "CNAME" })); + expect(mockResolverInstance.resolveCname).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches MX records to resolveMx", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ dnsRecordType: "MX" })); + expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches TXT records to resolveTxt", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ dnsRecordType: "TXT" })); + expect(mockResolverInstance.resolveTxt).toHaveBeenCalledWith("example.com"); + }); + + it("dispatches NS records to resolveNs", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ dnsRecordType: "NS" })); + expect(mockResolverInstance.resolveNs).toHaveBeenCalledWith("example.com"); + }); + + it("falls back to generic resolve for unrecognized record types", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ dnsRecordType: "SOA" })); + expect(mockResolverInstance.resolve).toHaveBeenCalledWith("example.com", "SOA"); + }); + + it("treats record type case-insensitively", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ dnsRecordType: "mx" })); + expect(mockResolverInstance.resolveMx).toHaveBeenCalledWith("example.com"); + }); + + it("returns failure response with NETWORK_ERROR when resolver rejects", async () => { + mockResolverInstance.resolve4.mockRejectedValue(new Error("ENOTFOUND")); + const provider = new DNSProvider(createResolver); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "dns", + status: false, + code: NETWORK_ERROR, + message: "ENOTFOUND", + payload: expect.objectContaining({ + hostname: "example.com", + dnsServer: "8.8.8.8", + recordType: "A", + resolved: false, + results: null, + }), + }) + ); + expect(typeof result.responseTime).toBe("number"); + }); + + it("stringifies non-Error rejections in failure response", async () => { + mockResolverInstance.resolve4.mockRejectedValue("DNS bombed"); + const provider = new DNSProvider(createResolver); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("DNS bombed"); + }); + + it("throws AppError when dnsServer is missing", async () => { + const provider = new DNSProvider(createResolver); + + await expect(provider.handle(makeMonitor({ dnsServer: undefined }))).rejects.toThrow("DNS server is required for DNS monitoring"); + }); + + it("falls back to default message when underlying error has empty message", async () => { + mockResolverInstance.setServers.mockImplementationOnce(() => { + throw new Error(""); + }); + const provider = new DNSProvider(createResolver); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing DNS check"); + }); + + it("strips scheme from url before querying the resolver", async () => { + const provider = new DNSProvider(createResolver); + const result = await provider.handle(makeMonitor({ url: "https://www.google.ca" })); + + expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("www.google.ca"); + expect(result.payload?.hostname).toBe("www.google.ca"); + }); + + it("strips port and path from url before querying the resolver", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ url: "example.com:53/some/path" })); + + expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); + }); + + it("passes a bare hostname through unchanged", async () => { + const provider = new DNSProvider(createResolver); + await provider.handle(makeMonitor({ url: "example.com" })); + + expect(mockResolverInstance.resolve4).toHaveBeenCalledWith("example.com"); + }); + + it("stringifies non-Error throws from outside timeRequest", async () => { + mockResolverInstance.setServers.mockImplementationOnce(() => { + throw 42; + }); + const provider = new DNSProvider(createResolver); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("42"); + }); +}); diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index 1b814d7b2..2bcab27a3 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -69,7 +69,7 @@ const makeGotResponse = (overrides?: Record) => ({ statusMessage: "OK", headers: { "content-type": "text/html" }, body: "", - timings: { phases: { total: 100 } }, + timings: { phases: { firstByte: 100, total: 120 } }, ...overrides, }); @@ -181,8 +181,8 @@ describe("HttpProvider", () => { ); }); - it("defaults responseTime to 0 when timings.phases.total is undefined", async () => { - mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { total: undefined } } })); + it("defaults responseTime to 0 when timings.phases.firstByte is undefined", async () => { + mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { firstByte: undefined } } })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); @@ -223,11 +223,11 @@ describe("HttpProvider", () => { expect(result.message).toBe("Response is not JSON"); }); - it("defaults responseTime to 0 in non-JSON jsonPath response when total is undefined", async () => { + it("defaults responseTime to 0 in non-JSON jsonPath response when firstByte is undefined", async () => { mockGot.mockResolvedValue( makeGotResponse({ headers: { "content-type": "text/html" }, - timings: { phases: { total: undefined } }, + timings: { phases: { firstByte: undefined } }, }) ); const { provider } = createProvider(); diff --git a/server/test/unit/providers/notifications/ntfyProvider.test.ts b/server/test/unit/providers/notifications/ntfyProvider.test.ts new file mode 100644 index 000000000..d335b5d01 --- /dev/null +++ b/server/test/unit/providers/notifications/ntfyProvider.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts"; +import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; + +const mockGotPost = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); + +const { NtfyProvider } = await import("../../../../src/service/infrastructure/notificationProviders/ntfy.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + return { provider: new NtfyProvider(logger as any), logger }; +}; + +const makeNtfyNotification = (overrides = {}) => + makeNotification({ + address: "https://ntfy.sh/", + topic: "checkmate-alerts", + ...overrides, + }); + +testNotificationProviderContract("NtfyProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNtfyNotification(), +}); + +describe("NtfyProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("posts the test message to the configured topic", async () => { + expect(await createProvider().provider.sendTestAlert(makeNtfyNotification())).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://ntfy.sh/checkmate-alerts", + expect.objectContaining({ + body: expect.any(String), + headers: expect.objectContaining({ Title: "Checkmate test notification" }), + }) + ); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNtfyNotification({ address: "" }))).toBe(false); + }); + + it("returns false when topic is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNtfyNotification({ topic: "" }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNtfyNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalled(); + }); + }); + + describe("sendMessage", () => { + it("posts plain text with severity headers to the configured topic", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeNtfyNotification() as any, makeMessage())).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://ntfy.sh/checkmate-alerts", + expect.objectContaining({ + body: expect.stringContaining("Monitor Details"), + headers: expect.objectContaining({ + Title: "Monitor Down: Test Monitor", + Priority: "high", + Tags: "rotating_light", + }), + }) + ); + }); + + it("url-encodes topics", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNtfyNotification({ topic: "ops alerts" }) as any, makeMessage()); + expect(mockGotPost.mock.calls[0][0]).toBe("https://ntfy.sh/ops%20alerts"); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendMessage(makeNtfyNotification({ address: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false when topic is missing", async () => { + expect(await createProvider().provider.sendMessage(makeNtfyNotification({ topic: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNtfyNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("includes threshold breaches in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNtfyNotification() as any, makeMessageWithThresholds()); + expect(mockGotPost.mock.calls[0][1].body).toContain("CPU"); + }); + + it("includes incident links in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNtfyNotification() as any, makeMessageWithIncident()); + expect(mockGotPost.mock.calls[0][1].body).toContain("/infrastructure/mon-1"); + }); + }); +}); diff --git a/server/test/unit/services/bufferService.test.ts b/server/test/unit/services/bufferService.test.ts index 7a5ee5b88..f1f395e2d 100644 --- a/server/test/unit/services/bufferService.test.ts +++ b/server/test/unit/services/bufferService.test.ts @@ -81,7 +81,7 @@ describe("BufferService", () => { const { logger } = createService("development"); expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ - message: expect.stringContaining("0.01s"), + message: expect.stringContaining("1s"), service: "BufferService", method: "constructor", }) @@ -231,7 +231,7 @@ describe("BufferService", () => { service.addToBuffer(makeCheck()); service.addGeoCheckToBuffer(makeGeoCheck()); - await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(1000); expect(checkService.createChecks).toHaveBeenCalled(); expect(geoChecksService.createGeoChecks).toHaveBeenCalled(); @@ -242,11 +242,11 @@ describe("BufferService", () => { (checkService.createChecks as jest.Mock).mockResolvedValue([]); service.addToBuffer(makeCheck()); - await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(1000); // Add another check and advance again to confirm rescheduling service.addToBuffer(makeCheck({ id: "c2" })); - await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(1000); expect(checkService.createChecks).toHaveBeenCalledTimes(2); }); @@ -256,7 +256,7 @@ describe("BufferService", () => { (checkService.createChecks as jest.Mock).mockRejectedValueOnce(new Error("DB down")); service.addToBuffer(makeCheck()); - await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(1000); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ @@ -268,7 +268,7 @@ describe("BufferService", () => { // Should still reschedule — add another check and flush (checkService.createChecks as jest.Mock).mockResolvedValue([]); service.addToBuffer(makeCheck({ id: "c2" })); - await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(1000); expect(checkService.createChecks).toHaveBeenCalledTimes(2); }); @@ -278,7 +278,7 @@ describe("BufferService", () => { // Override flushBuffer to throw past its own try/catch service.flushBuffer = jest.fn<() => Promise>().mockRejectedValueOnce(new Error("unexpected")); - await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(1000); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ @@ -292,7 +292,7 @@ describe("BufferService", () => { const { service, logger } = createService(); service.flushBuffer = jest.fn<() => Promise>().mockRejectedValueOnce("string error"); - await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(1000); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/server/test/unit/services/diagnosticService.test.ts b/server/test/unit/services/diagnosticService.test.ts index a76f28893..ae57613a1 100644 --- a/server/test/unit/services/diagnosticService.test.ts +++ b/server/test/unit/services/diagnosticService.test.ts @@ -1,5 +1,49 @@ import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; import { DiagnosticService } from "../../../src/service/business/diagnosticService.ts"; +import type { IDb } from "../../../src/db/IDb.ts"; +import type { Mongoose } from "mongoose"; + +// ── Test helpers ───────────────────────────────────────────────────────────── + +const makeDb = (overrides?: { + collections?: Array<{ collectionName: string }>; + collStats?: Record; + dbStats?: Record; + countDocuments?: number; + noDb?: boolean; +}): IDb => { + const collections = overrides?.collections ?? []; + const collStats = overrides?.collStats ?? { + storageSize: 100, + totalIndexSize: 50, + totalSize: 150, + }; + const dbStats = overrides?.dbStats ?? { totalSize: 1000 }; + const countDocuments = overrides?.countDocuments ?? 42; + + const dbApi = overrides?.noDb + ? null + : { + stats: jest.fn<() => Promise>>().mockResolvedValue(dbStats), + collections: jest.fn<() => Promise>>().mockResolvedValue(collections), + command: jest.fn<() => Promise>>().mockResolvedValue(collStats), + collection: jest.fn().mockReturnValue({ + countDocuments: jest.fn<() => Promise>().mockResolvedValue(countDocuments), + }), + }; + + return { + getConnection: jest.fn<() => Promise>().mockResolvedValue({ + connection: { + db: dbApi, + readyState: 1, + host: "localhost", + port: 27017, + name: "uptime_db", + }, + } as unknown as Mongoose), + } as unknown as IDb; +}; // ── Tests ──────────────────────────────────────────────────────────────────── @@ -18,7 +62,7 @@ describe("DiagnosticService", () => { }); it("returns diagnosticService from instance getter", () => { - const service = new DiagnosticService(); + const service = new DiagnosticService(makeDb()); expect(service.serviceName).toBe("diagnosticService"); }); }); @@ -37,12 +81,11 @@ describe("DiagnosticService", () => { }) as any; const clearMarksSpy = jest.spyOn(performance, "clearMarks"); - const service = new DiagnosticService(); + const service = new DiagnosticService(makeDb()); expect(mockObserve).toHaveBeenCalledWith({ entryTypes: ["measure"] }); expect(capturedCallback).not.toBeNull(); - // Invoke the callback manually const mockEntryList = { getEntries: jest.fn().mockReturnValue([]) } as unknown as PerformanceObserverEntryList; capturedCallback!(mockEntryList); @@ -55,68 +98,88 @@ describe("DiagnosticService", () => { }); }); - // ── getCPUUsage ───────────────────────────────────────────────────────── - - describe("getCPUUsage", () => { - it("returns cpu usage metrics after timing period", async () => { - const service = new DiagnosticService(); - const promise = service.getCPUUsage(); - jest.advanceTimersByTime(1000); - const result = await promise; - - expect(result).toHaveProperty("userUsageMs"); - expect(result).toHaveProperty("systemUsageMs"); - expect(result).toHaveProperty("usagePercentage"); - expect(typeof result.userUsageMs).toBe("number"); - expect(typeof result.systemUsageMs).toBe("number"); - expect(typeof result.usagePercentage).toBe("number"); - }); - }); - // ── getSystemStats ────────────────────────────────────────────────────── describe("getSystemStats", () => { - it("returns all diagnostic sections", async () => { - const service = new DiagnosticService(); + it("returns only the slimmed diagnostic shape (no serverStatus/serverInfo/memoryUsage)", async () => { + const service = new DiagnosticService(makeDb()); const promise = service.getSystemStats(); - // getCPUUsage has 1s timeout, then event loop delay has 0ms timeout + // getCPUUsagePercentage waits 1s, then event loop delay waits 0ms jest.advanceTimersByTime(1000); - // Need to flush the microtask queue and advance again for the nested setTimeout(0) await jest.advanceTimersByTimeAsync(10); const result = await promise; - expect(result).toHaveProperty("osStats"); - expect(result.osStats).toHaveProperty("freeMemoryBytes"); - expect(result.osStats).toHaveProperty("totalMemoryBytes"); - - expect(result).toHaveProperty("memoryUsage"); - expect(result.memoryUsage).toHaveProperty("rss"); - expect(result.memoryUsage).toHaveProperty("heapTotal"); - expect(result.memoryUsage).toHaveProperty("heapUsed"); - expect(result.memoryUsage).toHaveProperty("external"); - expect(result.memoryUsage).toHaveProperty("arrayBuffers"); - - expect(result).toHaveProperty("cpuUsage"); - expect(result.cpuUsage).toHaveProperty("userUsageMs"); - - expect(result).toHaveProperty("v8HeapStats"); - expect(result.v8HeapStats).toHaveProperty("totalHeapSizeBytes"); - expect(result.v8HeapStats).toHaveProperty("usedHeapSizeBytes"); - expect(result.v8HeapStats).toHaveProperty("heapSizeLimitBytes"); + expect(Object.keys(result).sort()).toEqual(["cpuUsage", "eventLoopDelayMs", "mongoStats", "osStats", "uptimeMs", "v8HeapStats"].sort()); + expect(result.osStats).toEqual({ totalMemoryBytes: expect.any(Number) }); + expect(result.cpuUsage).toEqual({ usagePercentage: expect.any(Number) }); + expect(result.v8HeapStats).toEqual({ + totalHeapSizeBytes: expect.any(Number), + usedHeapSizeBytes: expect.any(Number), + heapSizeLimitBytes: expect.any(Number), + }); expect(typeof result.eventLoopDelayMs).toBe("number"); - expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0); - expect(typeof result.uptimeMs).toBe("number"); expect(result.uptimeMs).toBeGreaterThan(0); }); + it("returns slimmed mongoStats with `collections` (not `collectionStats`) and no serverStatus/serverInfo", async () => { + const db = makeDb({ + collections: [{ collectionName: "monitors" }, { collectionName: "checks" }], + collStats: { storageSize: 200, totalIndexSize: 80, totalSize: 280 }, + dbStats: { totalSize: 9999 }, + countDocuments: 7, + }); + const service = new DiagnosticService(db); + const promise = service.getSystemStats(); + jest.advanceTimersByTime(1000); + await jest.advanceTimersByTimeAsync(10); + const result = await promise; + + expect(Object.keys(result.mongoStats).sort()).toEqual(["collections", "dbName", "host", "port", "readyState", "totalSize"].sort()); + expect(result.mongoStats.totalSize).toBe(9999); + expect(result.mongoStats.collections).toHaveLength(2); + expect(result.mongoStats.collections[0]).toEqual({ + name: "monitors", + documentCount: 7, + storageSize: 200, + totalIndexSize: 80, + totalSize: 280, + }); + }); + + it("includes bucketCount on timeseries collections, omits it elsewhere", async () => { + const db = makeDb({ + collections: [{ collectionName: "checks" }], + collStats: { storageSize: 1, totalIndexSize: 2, totalSize: 3, timeseries: { bucketCount: 12 } }, + }); + const service = new DiagnosticService(db); + const promise = service.getSystemStats(); + jest.advanceTimersByTime(1000); + await jest.advanceTimersByTimeAsync(10); + const result = await promise; + + expect(result.mongoStats.collections[0]?.bucketCount).toBe(12); + + const db2 = makeDb({ collections: [{ collectionName: "monitors" }] }); + const service2 = new DiagnosticService(db2); + const promise2 = service2.getSystemStats(); + jest.advanceTimersByTime(1000); + await jest.advanceTimersByTimeAsync(10); + const result2 = await promise2; + expect(result2.mongoStats.collections[0]).not.toHaveProperty("bucketCount"); + }); + + it("throws AppError when the mongo connection has no db handle", async () => { + jest.useRealTimers(); + const service = new DiagnosticService(makeDb({ noDb: true })); + await expect(service.getSystemStats()).rejects.toThrow("Database connection is not available"); + jest.useFakeTimers(); + }); + it("reads eventLoopDelay from real performance entries", async () => { jest.useRealTimers(); - const service = new DiagnosticService(); - - // Call getSystemStats with real timers — slow but guarantees real perf entries + const service = new DiagnosticService(makeDb()); const result = await service.getSystemStats(); - expect(typeof result.eventLoopDelayMs).toBe("number"); expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0); jest.useFakeTimers(); @@ -126,7 +189,7 @@ describe("DiagnosticService", () => { { label: "empty entries array", entries: [] }, { label: "undefined entry in array", entries: [undefined] }, ])("defaults eventLoopDelayMs to 0 when $label", async ({ entries }) => { - const service = new DiagnosticService(); + const service = new DiagnosticService(makeDb()); const originalGetEntries = performance.getEntriesByName; const originalMeasure = performance.measure; const originalMark = performance.mark; diff --git a/server/test/unit/services/emailService.test.ts b/server/test/unit/services/emailService.test.ts index 13b234186..d4d275ad8 100644 --- a/server/test/unit/services/emailService.test.ts +++ b/server/test/unit/services/emailService.test.ts @@ -396,5 +396,80 @@ describe("EmailService", () => { }) ); }); + + // ── from-header formatting ─────────────────────────────────────── + + describe("from header formatting", () => { + const sendAndGetFrom = async (overrides: Partial) => { + const transporter = createMockTransporter(); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ nodemailer }); + + await service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig(overrides)); + + const call = (transporter.sendMail as jest.Mock).mock.calls[0]?.[0] as { from: unknown } | undefined; + return call?.from; + }; + + it("uses bare address when systemEmailDisplayName is undefined", async () => { + const from = await sendAndGetFrom({ + systemEmailAddress: "noreply@example.com", + systemEmailDisplayName: undefined, + }); + + expect(from).toBe("noreply@example.com"); + }); + + it("uses bare address when systemEmailDisplayName is empty string", async () => { + const from = await sendAndGetFrom({ + systemEmailAddress: "noreply@example.com", + systemEmailDisplayName: "", + }); + + expect(from).toBe("noreply@example.com"); + }); + + it("uses bare address when systemEmailDisplayName is whitespace-only", async () => { + const from = await sendAndGetFrom({ + systemEmailAddress: "noreply@example.com", + systemEmailDisplayName: " ", + }); + + expect(from).toBe("noreply@example.com"); + }); + + it("uses {name, address} when display name and address are both set", async () => { + const from = await sendAndGetFrom({ + systemEmailAddress: "noreply@example.com", + systemEmailDisplayName: "Checkmate notifications", + }); + + expect(from).toEqual({ + name: "Checkmate notifications", + address: "noreply@example.com", + }); + }); + + it("trims surrounding whitespace from display name", async () => { + const from = await sendAndGetFrom({ + systemEmailAddress: "noreply@example.com", + systemEmailDisplayName: " Checkmate ", + }); + + expect(from).toEqual({ + name: "Checkmate", + address: "noreply@example.com", + }); + }); + + it("falls back to bare address when display name is set but address is empty", async () => { + const from = await sendAndGetFrom({ + systemEmailAddress: "", + systemEmailDisplayName: "Checkmate", + }); + + expect(from).toBe(""); + }); + }); }); }); diff --git a/server/test/unit/services/incidentService.test.ts b/server/test/unit/services/incidentService.test.ts index 0a17cbd79..4274f67d8 100644 --- a/server/test/unit/services/incidentService.test.ts +++ b/server/test/unit/services/incidentService.test.ts @@ -5,7 +5,7 @@ import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from import type { INotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts"; import type { Monitor } from "../../../src/types/monitor.ts"; import type { Incident } from "../../../src/types/index.ts"; -import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import type { MonitorActionDecision } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/lessSimpleQueue.test.ts b/server/test/unit/services/lessSimpleQueue.test.ts new file mode 100644 index 000000000..6351dc5d5 --- /dev/null +++ b/server/test/unit/services/lessSimpleQueue.test.ts @@ -0,0 +1,791 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { Monitor } from "../../../src/types/monitor.ts"; + +// ── Mock Scheduler / MongoStore ────────────────────────────────────────────── + +const createScheduler = () => { + const listeners: Record = {}; + return { + workerId: "host-1:1234:abcd", + on: jest.fn((event: string, cb: Function) => { + listeners[event] = listeners[event] || []; + listeners[event].push(cb); + }), + off: jest.fn((event: string, cb: Function) => { + listeners[event] = (listeners[event] || []).filter((fn) => fn !== cb); + }), + emit: (event: string, ...args: unknown[]) => { + (listeners[event] || []).forEach((cb) => cb(...args)); + }, + start: jest.fn().mockResolvedValue(true), + stop: jest.fn().mockResolvedValue(true), + addTemplate: jest.fn(), + addJob: jest.fn(), + addJobs: jest.fn().mockResolvedValue({ inserted: 0, upserted: 0, modified: 0, failures: [] }), + removeJob: jest.fn(), + getJob: jest.fn().mockResolvedValue(null), + getJobs: jest.fn().mockResolvedValue([]), + countJobs: jest.fn().mockResolvedValue(0), + getStats: jest.fn().mockResolvedValue({ jobs: 0, activeJobs: 0, failingJobs: 0, totalRuns: 0, totalFailures: 0, jobsWithFailures: [] }), + pauseJob: jest.fn().mockResolvedValue(true), + resumeJob: jest.fn().mockResolvedValue(true), + updateJob: jest.fn(), + flushJobs: jest.fn().mockResolvedValue(true), + }; +}; + +const mockSchedulerInstance = createScheduler(); +const MockScheduler = jest.fn().mockReturnValue(mockSchedulerInstance); +const MockMongoStore = jest.fn(); + +jest.unstable_mockModule("less-simple-scheduler", () => ({ + Scheduler: MockScheduler, + MongoStore: MockMongoStore, +})); + +const { LessSimpleQueue } = await import("../../../src/service/infrastructure/JobQueues/LessSimpleQueue.ts"); + +const createQueueHelper = () => ({ + getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()), + getHeartbeatGeoJob: jest.fn().mockReturnValue(() => Promise.resolve()), + getCleanupOrphanedJob: jest.fn().mockReturnValue(() => Promise.resolve()), + getCleanupRetentionJob: jest.fn().mockReturnValue(() => Promise.resolve()), +}); + +const createMonitorsRepo = () => ({ + findAll: jest.fn().mockResolvedValue([]), +}); + +const createWorkersRepo = () => ({ + upsert: jest.fn().mockResolvedValue(undefined), + findRecent: jest.fn().mockResolvedValue([]), + deleteById: jest.fn().mockResolvedValue(undefined), +}); + +const createQueue = (overrides?: Record) => { + const logger = createMockLogger(); + const helper = createQueueHelper(); + const monitorsRepository = createMonitorsRepo(); + const workersRepository = createWorkersRepo(); + const scheduler = createScheduler(); + + const defaults = { logger, helper, monitorsRepository, workersRepository, scheduler, queueMode: "primary", ...overrides }; + + const queue = new LessSimpleQueue( + defaults.logger as any, + defaults.helper as any, + defaults.monitorsRepository as any, + defaults.workersRepository as any, + defaults.scheduler as any, + defaults.queueMode as any + ); + return { queue, ...defaults }; +}; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "http", + interval: 60000, + isActive: true, + geoCheckEnabled: false, + geoCheckInterval: 300000, + geoCheckLocations: [], + ...overrides, + }) as Monitor; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("LessSimpleQueue", () => { + describe("serviceName", () => { + it("returns JobQueue", () => { + const { queue } = createQueue(); + expect(queue.serviceName).toBe("JobQueue"); + }); + }); + + // ── init (primary) ───────────────────────────────────────────────────────── + + describe("init - primary mode", () => { + it("starts scheduler, registers all templates, seeds monitors and system jobs", async () => { + const { queue, scheduler, monitorsRepository } = createQueue(); + + const result = await queue.init(); + + expect(result).toBe(true); + expect(scheduler.start).toHaveBeenCalled(); + expect(scheduler.addTemplate).toHaveBeenCalledWith("monitor-job", expect.any(Function)); + expect(scheduler.addTemplate).toHaveBeenCalledWith("geo-check-job", expect.any(Function)); + expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-orphaned", expect.any(Function)); + expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-retention-job", expect.any(Function)); + expect(monitorsRepository.findAll).toHaveBeenCalled(); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" })); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" })); + }); + + it("registers existing monitors in a single bulk write", async () => { + const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })]; + const { queue, scheduler, monitorsRepository } = createQueue(); + (monitorsRepository.findAll as jest.Mock).mockResolvedValue(monitors); + + await queue.init(); + + expect(scheduler.addJobs).toHaveBeenCalledTimes(1); + expect(scheduler.addJobs).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: "m1", template: "monitor-job", jitter: true }), + expect.objectContaining({ id: "m2", template: "monitor-job", jitter: true }), + ]) + ); + }); + + it("returns true and skips system jobs when findAll returns null", async () => { + const { queue, monitorsRepository, scheduler } = createQueue(); + (monitorsRepository.findAll as jest.Mock).mockResolvedValue(null); + + const result = await queue.init(); + + expect(result).toBe(true); + expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" })); + }); + + it("returns false and logs error on failure", async () => { + const { queue, logger, monitorsRepository } = createQueue(); + (monitorsRepository.findAll as jest.Mock).mockRejectedValue(new Error("db down")); + + const result = await queue.init(); + + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db down") })); + }); + + it("logs error with String(error) for non-Error exceptions", async () => { + const { queue, logger, monitorsRepository } = createQueue(); + (monitorsRepository.findAll as jest.Mock).mockRejectedValue("string error"); + + const result = await queue.init(); + + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("string error") })); + }); + }); + + // ── init (worker) ────────────────────────────────────────────────────────── + + describe("init - worker mode", () => { + it("starts scheduler and registers all templates", async () => { + const { queue, scheduler } = createQueue(); + + const result = await queue.init("worker"); + + expect(result).toBe(true); + expect(scheduler.start).toHaveBeenCalled(); + expect(scheduler.addTemplate).toHaveBeenCalledWith("monitor-job", expect.any(Function)); + expect(scheduler.addTemplate).toHaveBeenCalledWith("geo-check-job", expect.any(Function)); + expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-orphaned", expect.any(Function)); + expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-retention-job", expect.any(Function)); + }); + + it("does NOT seed monitor jobs (no findAll, primary populates the queue)", async () => { + const { queue, monitorsRepository } = createQueue(); + + await queue.init("worker"); + + expect(monitorsRepository.findAll).not.toHaveBeenCalled(); + }); + + it("does NOT seed cleanup system jobs", async () => { + const { queue, scheduler } = createQueue(); + + await queue.init("worker"); + + expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" })); + expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" })); + expect(scheduler.addJob).not.toHaveBeenCalled(); + }); + + it("logs the initialized worker with its workerId", async () => { + const { queue, logger } = createQueue(); + + await queue.init("worker"); + + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Worker queue: host-1:1234:abcd initialized" })); + }); + }); + + // ── registerListeners ────────────────────────────────────────────────────── + + describe("registerListeners (via constructor)", () => { + it("logs on scheduler:start", () => { + const { scheduler, logger } = createQueue(); + scheduler.emit("scheduler:start", "host-1:1234:abcd"); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler started" })); + }); + + it("logs on scheduler:stop", () => { + const { scheduler, logger } = createQueue(); + scheduler.emit("scheduler:stop", "host-1:1234:abcd"); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler stopped" })); + }); + + it("logs on scheduler:error with Error", () => { + const { scheduler, logger } = createQueue(); + scheduler.emit("scheduler:error", "w1", new Error("boom")); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 error: boom" })); + }); + + it("logs on scheduler:error with non-Error", () => { + const { scheduler, logger } = createQueue(); + scheduler.emit("scheduler:error", "w1", "string error"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 error: string error", stack: undefined })); + }); + + it("logs on job:locked", () => { + const { scheduler, logger } = createQueue(); + scheduler.emit("job:locked", "w1", { id: "m1" }); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 locked" })); + }); + + it("logs on job:abort", () => { + const { scheduler, logger } = createQueue(); + scheduler.emit("job:abort", "w1", { id: "m1" }, "timeout"); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 aborted: timeout" })); + }); + + it("logs on job:attempt", () => { + const { scheduler, logger } = createQueue(); + scheduler.emit("job:attempt", "w1", { id: "m1" }, 2); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 attempt 2" })); + }); + + it("logs on job:complete", () => { + const { scheduler, logger } = createQueue(); + scheduler.emit("job:complete", "w1", { id: "m1" }); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 completed successfully" })); + }); + + it("logs on job:exhausted with Error", () => { + const { scheduler, logger } = createQueue(); + scheduler.emit("job:exhausted", "w1", { id: "m1" }, new Error("gave up")); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 exhausted all retries: gave up" })); + }); + + it("logs on job:exhausted with non-Error", () => { + const { scheduler, logger } = createQueue(); + scheduler.emit("job:exhausted", "w1", { id: "m1" }, "gave up"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 exhausted all retries: gave up", stack: undefined })); + }); + + it("logs on job:fail with Error", () => { + const { scheduler, logger } = createQueue(); + scheduler.emit("job:fail", "w1", { id: "m1" }, new Error("oops"), 1); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 failed on attempt 1: oops" })); + }); + + it("logs on job:fail with non-Error", () => { + const { scheduler, logger } = createQueue(); + scheduler.emit("job:fail", "w1", { id: "m1" }, "oops", 1); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 failed on attempt 1: oops", stack: undefined })); + }); + + it("logs on job:start", () => { + const { scheduler, logger } = createQueue(); + scheduler.emit("job:start", "w1", { id: "m1" }); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "w1 m1 started" })); + }); + }); + + // ── addJob ───────────────────────────────────────────────────────────────── + + describe("addJob", () => { + it("adds monitor job to scheduler", async () => { + const { queue, scheduler } = createQueue(); + await queue.addJob("mon-1", makeMonitor()); + expect(scheduler.addJobs).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: "mon-1", template: "monitor-job", repeat: 60000, active: true })]) + ); + }); + + it("adds geo check job when geoCheckEnabled and type supports it", async () => { + const { queue, scheduler } = createQueue(); + await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 })); + expect(scheduler.addJobs).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job", repeat: 300000 })]) + ); + }); + + it("skips geo job when type does not support geo checks", async () => { + const { queue, scheduler } = createQueue(); + await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "hardware" })); + expect(scheduler.addJobs).toHaveBeenCalledWith(expect.not.arrayContaining([expect.objectContaining({ id: "mon-1-geo" })])); + }); + + it("skips geo job when geoCheckEnabled is false", async () => { + const { queue, scheduler } = createQueue(); + await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: false, type: "http" })); + expect(scheduler.addJobs).toHaveBeenCalledWith(expect.not.arrayContaining([expect.objectContaining({ id: "mon-1-geo" })])); + }); + }); + + // ── deleteJob ────────────────────────────────────────────────────────────── + + describe("deleteJob", () => { + it("removes monitor job", async () => { + const { queue, scheduler } = createQueue(); + await queue.deleteJob(makeMonitor()); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1"); + }); + + it("removes geo job if it exists", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + await queue.deleteJob(makeMonitor()); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + }); + + it("does not remove geo job if it does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.deleteJob(makeMonitor()); + expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); + }); + }); + + // ── pauseJob ─────────────────────────────────────────────────────────────── + + describe("pauseJob", () => { + it("pauses monitor and geo job when geo exists", async () => { + const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + await queue.pauseJob(makeMonitor()); + + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo"); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" })); + }); + + it("does not pause geo job when it does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.pauseJob(makeMonitor()); + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.pauseJob).not.toHaveBeenCalledWith("mon-1-geo"); + }); + + it("throws when scheduler.pauseJob returns false for the main job", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); + await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor"); + }); + + it("logs error but does not throw when geo pauseJob returns false", async () => { + const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + (scheduler.pauseJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo"); + + await expect(queue.pauseJob(makeMonitor())).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to pause geo check job for monitor mon-1" })); + }); + }); + + // ── resumeJob ────────────────────────────────────────────────────────────── + + describe("resumeJob", () => { + it("resumes monitor and geo job when geo exists", async () => { + const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + await queue.resumeJob(makeMonitor()); + + expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo"); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" })); + }); + + it("does not resume geo job when it does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.resumeJob(makeMonitor()); + expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.resumeJob).not.toHaveBeenCalledWith("mon-1-geo"); + }); + + it("throws when scheduler.resumeJob returns false for the main job", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); + await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor"); + }); + + it("logs error but does not throw when geo resumeJob returns false", async () => { + const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + (scheduler.resumeJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo"); + + await expect(queue.resumeJob(makeMonitor())).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to resume geo check job for monitor mon-1" })); + }); + }); + + // ── updateJob / syncGeoJob ───────────────────────────────────────────────── + + describe("updateJob", () => { + it("updates monitor job with new interval", async () => { + const { queue, scheduler } = createQueue(); + const monitor = makeMonitor({ interval: 120000 }); + await queue.updateJob(monitor); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ repeat: 120000, data: monitor })); + }); + + it("removes existing geo job when geoCheckEnabled is false", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + await queue.updateJob(makeMonitor({ geoCheckEnabled: false, type: "http" })); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + }); + + it("removes existing geo job when type does not support geo checks", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + await queue.updateJob(makeMonitor({ geoCheckEnabled: true, type: "hardware" })); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + }); + + it("updates existing geo job when geoCheckEnabled", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }); + await queue.updateJob(monitor); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", expect.objectContaining({ repeat: 300000 })); + }); + + it("creates new geo job when enabled but no existing job", async () => { + const { queue, scheduler } = createQueue(); + const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }); + await queue.updateJob(monitor); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job" })); + }); + }); + + // ── shutdown ─────────────────────────────────────────────────────────────── + + describe("shutdown", () => { + it("stops the scheduler", async () => { + const { queue, scheduler } = createQueue(); + await queue.shutdown(); + expect(scheduler.stop).toHaveBeenCalled(); + }); + }); + + // ── getMetrics ───────────────────────────────────────────────────────────── + + describe("getMetrics", () => { + it("returns empty metrics when no jobs", async () => { + const { queue } = createQueue(); + const metrics = await queue.getMetrics(); + expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0, workers: [] }); + }); + + it("maps the store's aggregate counters through", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getStats as jest.Mock).mockResolvedValue({ + jobs: 2, + activeJobs: 1, + failingJobs: 1, + totalRuns: 15, + totalFailures: 2, + jobsWithFailures: [], + }); + const metrics = await queue.getMetrics(); + expect(metrics).toMatchObject({ jobs: 2, activeJobs: 1, failingJobs: 1, totalRuns: 15, totalFailures: 2 }); + }); + + it("derives monitor fields for failing monitor jobs from job data", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getStats as jest.Mock).mockResolvedValue({ + jobs: 1, + activeJobs: 0, + failingJobs: 1, + totalRuns: 10, + totalFailures: 2, + jobsWithFailures: [{ id: "m1", data: { url: "http://a.com", type: "http" }, failedAt: 200, failCount: 2, failReason: "timeout" }], + }); + const metrics = await queue.getMetrics(); + expect(metrics.jobsWithFailures).toEqual([ + { monitorId: "m1", monitorUrl: "http://a.com", monitorType: "http", failedAt: 200, failCount: 2, failReason: "timeout" }, + ]); + }); + + it("leaves monitor fields null for non-monitor (cleanup) failures", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getStats as jest.Mock).mockResolvedValue({ + jobs: 1, + activeJobs: 0, + failingJobs: 1, + totalRuns: 1, + totalFailures: 1, + jobsWithFailures: [{ id: "cleanup-orphaned", data: undefined, failedAt: 100, failCount: 1, failReason: "boom" }], + }); + const metrics = await queue.getMetrics(); + expect(metrics.jobsWithFailures[0]).toEqual({ + monitorId: "cleanup-orphaned", + monitorUrl: null, + monitorType: null, + failedAt: 100, + failCount: 1, + failReason: "boom", + }); + }); + }); + + // ── getMetrics: worker registry ────────────────────────────────────────────── + + describe("getMetrics - worker registry", () => { + it("returns the alive workers from the registry", async () => { + const { queue, workersRepository } = createQueue(); + const alive = [ + { workerId: "host:1:aaa", mode: "primary", lastSeenAt: 1000 }, + { workerId: "host:2:bbb", mode: "worker", lastSeenAt: 2000 }, + ]; + (workersRepository.findRecent as jest.Mock).mockResolvedValue(alive); + + const metrics = await queue.getMetrics(); + + expect(workersRepository.findRecent).toHaveBeenCalledWith(30000); + expect(metrics.workers).toEqual(alive); + }); + }); + + // ── getJobs ──────────────────────────────────────────────────────────────── + + describe("getJobs", () => { + it("maps scheduler jobs to summaries", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { + id: "m1", + template: "monitor-job", + active: true, + repeat: 60000, + lockedAt: null, + runCount: 5, + failCount: 1, + lastError: "timeout", + lastStartedAt: 1000, + lastFinishedAt: 1100, + lastFailedAt: 900, + data: { url: "http://a.com", type: "http", interval: 60000, geoCheckInterval: 300000, isActive: true }, + }, + ]); + const { jobs } = await queue.getJobs({}); + + expect(jobs).toHaveLength(1); + expect(jobs[0]).toEqual({ + monitorId: "m1", + monitorUrl: "http://a.com", + monitorType: "http", + monitorInterval: 60000, + monitorGeoInterval: 300000, + monitorActive: true, + active: true, + repeat: 60000, + lockedBy: null, + lockedUntil: null, + lockedAt: null, + runCount: 5, + failCount: 1, + failReason: "timeout", + lastRunAt: 1000, + lastFinishedAt: 1100, + lastRunTook: 100, + lastFailedAt: 900, + }); + }); + + it("returns null lastRunTook when job is locked", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { + id: "m1", + template: "monitor-job", + active: true, + lockedAt: 999, + runCount: 1, + lastStartedAt: 1000, + lastFinishedAt: 1100, + data: { url: "a", type: "http", interval: 60000 }, + }, + ]); + const { jobs } = await queue.getJobs({}); + expect(jobs[0].lastRunTook).toBeNull(); + }); + + it("leaves monitor fields null for non-monitor jobs", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true, data: undefined }]); + const { jobs } = await queue.getJobs({}); + expect(jobs[0].monitorUrl).toBeNull(); + expect(jobs[0].monitorType).toBeNull(); + expect(jobs[0].monitorInterval).toBeNull(); + expect(jobs[0].monitorGeoInterval).toBeNull(); + expect(jobs[0].monitorActive).toBeNull(); + }); + + it("pushes pagination to the scheduler and returns its total count", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", template: "monitor-job", active: true, data: { url: "a", type: "http", interval: 60000 } }, + ]); + (scheduler.countJobs as jest.Mock).mockResolvedValue(42); + + const { jobs, count } = await queue.getJobs({ page: 2, rowsPerPage: 10 }); + + expect(scheduler.getJobs).toHaveBeenCalledWith({ skip: 20, limit: 10 }); + expect(count).toBe(42); + expect(jobs).toHaveLength(1); + }); + + it("requests all jobs when rowsPerPage is omitted", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.countJobs as jest.Mock).mockResolvedValue(7); + + const { count } = await queue.getJobs({}); + + expect(scheduler.getJobs).toHaveBeenCalledWith(undefined); + expect(count).toBe(7); + }); + }); + + // ── flushQueues ──────────────────────────────────────────────────────────── + + describe("flushQueues", () => { + it("stops, flushes, and reinitializes", async () => { + const { queue, scheduler } = createQueue(); + const result = await queue.flushQueues(); + expect(scheduler.stop).toHaveBeenCalled(); + expect(scheduler.flushJobs).toHaveBeenCalled(); + expect(result.success).toBe(true); + }); + + it("flushes before stopping, so the store is still connected for removeAll", async () => { + const { queue, scheduler } = createQueue(); + const order: string[] = []; + (scheduler.flushJobs as jest.Mock).mockImplementation(async () => { + order.push("flush"); + return true; + }); + (scheduler.stop as jest.Mock).mockImplementation(async () => { + order.push("stop"); + return true; + }); + await queue.flushQueues(); + expect(order).toEqual(["flush", "stop"]); + }); + + it("refuses to flush on a non-primary worker without touching the scheduler", async () => { + const { queue, scheduler } = createQueue({ queueMode: "worker" }); + const result = await queue.flushQueues(); + expect(result.success).toBe(false); + expect(scheduler.flushJobs).not.toHaveBeenCalled(); + expect(scheduler.stop).not.toHaveBeenCalled(); + }); + + it("returns false when flush fails", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.flushJobs as jest.Mock).mockResolvedValue(false); + const result = await queue.flushQueues(); + expect(result.success).toBe(false); + }); + }); + + // ── static create ────────────────────────────────────────────────────────── + + describe("static create", () => { + const envSettings = { dbConnectionString: "mongodb://localhost:27017/test_db" } as any; + + it("creates a MongoStore + Scheduler and inits in primary mode", async () => { + const logger = createMockLogger(); + const helper = createQueueHelper(); + const monitorsRepository = createMonitorsRepo(); + const workersRepository = createWorkersRepo(); + + const instance = await LessSimpleQueue.create( + logger as any, + helper as any, + monitorsRepository as any, + workersRepository as any, + envSettings, + "primary" + ); + + expect(MockMongoStore).toHaveBeenCalledWith(expect.objectContaining({ url: "mongodb://localhost:27017/test_db" })); + expect(MockScheduler).toHaveBeenCalled(); + expect(instance).toBeInstanceOf(LessSimpleQueue); + expect(mockSchedulerInstance.start).toHaveBeenCalled(); + expect(monitorsRepository.findAll).toHaveBeenCalled(); + }); + + it("inits in worker mode without seeding monitors", async () => { + const logger = createMockLogger(); + const helper = createQueueHelper(); + const monitorsRepository = createMonitorsRepo(); + const workersRepository = createWorkersRepo(); + + const instance = await LessSimpleQueue.create( + logger as any, + helper as any, + monitorsRepository as any, + workersRepository as any, + envSettings, + "worker" + ); + + expect(instance).toBeInstanceOf(LessSimpleQueue); + expect(monitorsRepository.findAll).not.toHaveBeenCalled(); + }); + }); + + // ── worker heartbeat / deregistration ──────────────────────────────────────── + + describe("worker heartbeat", () => { + it("writes a heartbeat on init with the workerId and mode (primary)", async () => { + const { queue, workersRepository } = createQueue(); + await queue.init("primary"); + expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "primary"); + }); + + it("writes a heartbeat on init with the workerId and mode (worker)", async () => { + const { queue, workersRepository } = createQueue(); + await queue.init("worker"); + expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "worker"); + }); + + it("beats again on each scheduler:heartbeat event", async () => { + const { queue, scheduler, workersRepository } = createQueue(); + await queue.init("worker"); + (workersRepository.upsert as jest.Mock).mockClear(); + + scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd"); + scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd"); + + expect(workersRepository.upsert).toHaveBeenCalledTimes(2); + expect(workersRepository.upsert).toHaveBeenCalledWith("host-1:1234:abcd", "worker"); + }); + + it("stops beating after shutdown", async () => { + const { queue, scheduler, workersRepository } = createQueue(); + await queue.init("worker"); + await queue.shutdown(); + (workersRepository.upsert as jest.Mock).mockClear(); + + scheduler.emit("scheduler:heartbeat", "host-1:1234:abcd"); + + expect(workersRepository.upsert).not.toHaveBeenCalled(); + }); + + it("deregisters the worker and stops the scheduler on shutdown", async () => { + const { queue, scheduler, workersRepository } = createQueue(); + await queue.init("worker"); + await queue.shutdown(); + expect(workersRepository.deleteById).toHaveBeenCalledWith("host-1:1234:abcd"); + expect(scheduler.stop).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts index 302ff128c..376b8f20d 100644 --- a/server/test/unit/services/maintenanceWindowService.test.ts +++ b/server/test/unit/services/maintenanceWindowService.test.ts @@ -11,6 +11,7 @@ const createMaintenanceWindowsRepo = () => create: jest.fn().mockResolvedValue(makeWindow()), findById: jest.fn().mockResolvedValue(makeWindow()), findByMonitorId: jest.fn().mockResolvedValue([makeWindow()]), + findByMonitorIds: jest.fn().mockResolvedValue([]), findByTeamId: jest.fn().mockResolvedValue([makeWindow()]), updateById: jest.fn().mockResolvedValue(makeWindow()), deleteById: jest.fn().mockResolvedValue(makeWindow()), @@ -23,6 +24,7 @@ const createMonitorsRepo = () => { id: "mon-1", teamId: "team-1" }, { id: "mon-2", teamId: "team-1" }, ]), + updateByIds: jest.fn().mockResolvedValue(0), }) as unknown as jest.Mocked; const createService = (overrides?: { @@ -37,7 +39,7 @@ const createService = (overrides?: { const makeWindow = (overrides?: Partial): MaintenanceWindow => ({ id: "mw-1", - monitorId: "mon-1", + monitorIds: ["mon-1"], teamId: "team-1", active: true, name: "Scheduled Maintenance", @@ -51,6 +53,16 @@ const makeWindow = (overrides?: Partial): MaintenanceWindow = ...overrides, }); +// A window whose start/end straddle "now" — active per isWindowActive +const makeActiveWindow = (overrides?: Partial): MaintenanceWindow => { + const now = Date.now(); + return makeWindow({ + start: new Date(now - 60_000).toISOString(), + end: new Date(now + 60_000).toISOString(), + ...overrides, + }); +}; + const defaultCreateParams = { teamId: "team-1", monitorIDs: ["mon-1", "mon-2"], @@ -80,16 +92,16 @@ describe("MaintenanceWindowService", () => { // ── createMaintenanceWindow ────────────────────────────────────────────── describe("createMaintenanceWindow", () => { - it("creates a maintenance window for each monitor", async () => { + it("creates a single maintenance window covering all monitor IDs", async () => { const { service, maintenanceWindowsRepository } = createService(); await service.createMaintenanceWindow(defaultCreateParams); - expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(2); + expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1); expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith( expect.objectContaining({ teamId: "team-1", - monitorId: "mon-1", + monitorIds: ["mon-1", "mon-2"], name: "Scheduled Maintenance", active: true, duration: 60, @@ -99,7 +111,6 @@ describe("MaintenanceWindowService", () => { end: "2026-04-10T03:00:00Z", }) ); - expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ monitorId: "mon-2" })); }); it("verifies monitor ownership via findByIds", async () => { @@ -130,12 +141,33 @@ describe("MaintenanceWindowService", () => { await expect(service.createMaintenanceWindow(defaultCreateParams)).resolves.toBeUndefined(); }); - it("creates a single window when monitorIDs has one entry", async () => { + it("passes a single-element monitorIds array when only one monitor is provided", async () => { const { service, maintenanceWindowsRepository } = createService(); await service.createMaintenanceWindow({ ...defaultCreateParams, monitorIDs: ["mon-1"] }); expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1); + expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ monitorIds: ["mon-1"] })); + }); + + it("flips monitors to maintenance when the created window is active", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.create as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.createMaintenanceWindow(defaultCreateParams); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "maintenance" }, ["paused"]); + }); + + it("does not touch monitor status when the created window is inactive", async () => { + // default mock returns makeWindow() with past dates → inactive + const { service, monitorsRepository } = createService(); + + await service.createMaintenanceWindow(defaultCreateParams); + + expect(monitorsRepository.updateByIds).not.toHaveBeenCalled(); }); }); @@ -221,6 +253,88 @@ describe("MaintenanceWindowService", () => { expect(result).toBe(deleted); expect(maintenanceWindowsRepository.deleteById).toHaveBeenCalledWith("mw-1", "team-1"); }); + + it("does not touch monitor status when deleting an inactive window", async () => { + // default fixture has past dates → inactive + const { service, monitorsRepository, maintenanceWindowsRepository } = createService(); + + await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); + + expect(monitorsRepository.updateByIds).not.toHaveBeenCalled(); + expect(maintenanceWindowsRepository.findByMonitorIds).not.toHaveBeenCalled(); + }); + + it("flips covered monitors to initializing when deleting an active window", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "initializing" }, ["paused"]); + }); + + it("excludes the deleted window from the overlap check", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] })); + const { service } = createService({ maintenanceWindowsRepository }); + + await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); + + expect(maintenanceWindowsRepository.findByMonitorIds).toHaveBeenCalledWith(["mon-1"], "team-1", "mw-1"); + }); + + it("does not flip a monitor still covered by another active window", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + (maintenanceWindowsRepository.findByMonitorIds as jest.Mock).mockResolvedValue([makeActiveWindow({ id: "mw-2", monitorIds: ["mon-2"] })]); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }, ["paused"]); + }); + + it("skips the overlap lookup when an active window with no monitorIds is deleted", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: [] })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); + + expect(maintenanceWindowsRepository.findByMonitorIds).not.toHaveBeenCalled(); + expect(monitorsRepository.updateByIds).not.toHaveBeenCalled(); + }); + + it("ignores monitorIds in overlapping windows that are outside the leaving set", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] })); + // overlapping window covers mon-1 (in the leaving set) plus mon-99 (unrelated, must be skipped) + (maintenanceWindowsRepository.findByMonitorIds as jest.Mock).mockResolvedValue([ + makeActiveWindow({ id: "mw-2", monitorIds: ["mon-1", "mon-99"] }), + ]); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); + + // mon-1 is still covered by mw-2; mon-99 must not leak into the update + expect(monitorsRepository.updateByIds).not.toHaveBeenCalled(); + }); + + it("makes no monitor updates when every leaving monitor is still covered by another active window", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + (maintenanceWindowsRepository.findByMonitorIds as jest.Mock).mockResolvedValue([ + makeActiveWindow({ id: "mw-2", monitorIds: ["mon-1", "mon-2"] }), + ]); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); + + expect(monitorsRepository.updateByIds).not.toHaveBeenCalled(); + }); }); // ── editMaintenanceWindow ──────────────────────────────────────────────── @@ -248,5 +362,203 @@ describe("MaintenanceWindowService", () => { expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { active: false, repeat: 3600000 }); }); + + it("maps the monitors body field to monitorIds when calling the repository", async () => { + const { service, maintenanceWindowsRepository } = createService(); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { monitors: ["mon-1", "mon-2"] }, + }); + + expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { monitorIds: ["mon-1", "mon-2"] }); + }); + + it("verifies monitor ownership when monitors is in the body", async () => { + const { service, monitorsRepository } = createService(); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { monitors: ["mon-1", "mon-2"] }, + }); + + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"]); + }); + + it("throws 403 when a new monitor belongs to a different team", async () => { + const monitorsRepository = createMonitorsRepo(); + (monitorsRepository.findByIds as jest.Mock).mockResolvedValue([ + { id: "mon-1", teamId: "team-1" }, + { id: "mon-2", teamId: "team-other" }, + ]); + const { service, maintenanceWindowsRepository } = createService({ monitorsRepository }); + + await expect( + service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { monitors: ["mon-1", "mon-2"] }, + }) + ).rejects.toThrow("Unauthorized to edit maintenance window for one or more monitors"); + expect(maintenanceWindowsRepository.updateById).not.toHaveBeenCalled(); + }); + + it("does not call findByIds when monitors is not in the body", async () => { + const { service, monitorsRepository } = createService(); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { name: "Updated Name" }, + }); + + expect(monitorsRepository.findByIds).not.toHaveBeenCalled(); + }); + + // ── transitions ────────────────────────────────────────────────── + + it("does not touch monitor status when window was inactive and stays inactive", async () => { + // default fixture: start/end in 2026-04-10 (past) → inactive + const { service, monitorsRepository } = createService(); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { name: "Updated Name" }, + }); + + expect(monitorsRepository.updateByIds).not.toHaveBeenCalled(); + }); + + it("sets all monitors to maintenance when window transitions inactive → active", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeWindow({ monitorIds: ["mon-1", "mon-2"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { start: new Date(Date.now() - 60_000).toISOString() }, + }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "maintenance" }, ["paused"]); + }); + + it("sets all monitors to initializing when window transitions active → inactive", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"], active: false })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { active: false }, + }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "initializing" }, ["paused"]); + }); + + it("flips only newly added monitors to maintenance when window stays active", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { monitors: ["mon-1", "mon-2"] }, + }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-2"], "team-1", { status: "maintenance" }, ["paused"]); + }); + + it("flips only removed monitors to initializing when window stays active", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { monitors: ["mon-1"] }, + }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-2"], "team-1", { status: "initializing" }, ["paused"]); + }); + + it("makes no monitor updates when window stays active and monitor set is unchanged", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"], name: "Renamed" })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { name: "Renamed" }, + }); + + expect(monitorsRepository.updateByIds).not.toHaveBeenCalled(); + }); + + it("does not flip a leaving monitor to initializing if another active window still covers it", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"], active: false })); + (maintenanceWindowsRepository.findByMonitorIds as jest.Mock).mockResolvedValue([makeActiveWindow({ id: "mw-2", monitorIds: ["mon-2"] })]); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { active: false }, + }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }, ["paused"]); + }); + + it("excludes the current window from the overlap check", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"], active: false })); + const { service } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { active: false }, + }); + + expect(maintenanceWindowsRepository.findByMonitorIds).toHaveBeenCalledWith(["mon-1"], "team-1", "mw-1"); + }); + + it("flips a leaving monitor to initializing if other windows covering it are inactive", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"], active: false })); + (maintenanceWindowsRepository.findByMonitorIds as jest.Mock).mockResolvedValue([ + makeWindow({ id: "mw-2", monitorIds: ["mon-1"], active: false }), + ]); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { active: false }, + }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }, ["paused"]); + }); }); }); diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index cd424acf9..5e73ab77b 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -23,6 +23,7 @@ const createMonitorsRepositoryMock = () => updateById: jest.fn(), updateNotifications: jest.fn(), togglePauseById: jest.fn(), + bulkTogglePause: jest.fn(), deleteById: jest.fn(), deleteByTeamId: jest.fn(), }) as unknown as IMonitorsRepository; @@ -839,6 +840,214 @@ describe("MonitorService", () => { }); }); + describe("bulkPauseMonitors", () => { + it("resumes jobs for monitors that are active after bulk toggle", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [makeMonitor({ id: "m1", isActive: true }), makeMonitor({ id: "m2", isActive: true })]; + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2"], + pause: false, + }); + + expect(result.monitors).toHaveLength(2); + expect(result.failedCount).toBe(0); + expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, false); + expect(jobQueue.resumeJob).toHaveBeenCalledTimes(2); + expect(jobQueue.pauseJob).not.toHaveBeenCalled(); + }); + + it("pauses jobs for monitors that are inactive after bulk toggle", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [makeMonitor({ id: "m1", isActive: false }), makeMonitor({ id: "m2", isActive: false })]; + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2"], + pause: true, + }); + + expect(result.monitors).toHaveLength(2); + expect(result.failedCount).toBe(0); + expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, true); + expect(jobQueue.pauseJob).toHaveBeenCalledTimes(2); + expect(jobQueue.resumeJob).not.toHaveBeenCalled(); + }); + + it("handles mixed active/inactive monitors correctly", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const activeMonitor = makeMonitor({ id: "m1", isActive: true }); + const pausedMonitor = makeMonitor({ id: "m2", isActive: false }); + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([activeMonitor, pausedMonitor]); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2"], + pause: true, + }); + + expect(result.monitors).toHaveLength(2); + expect(result.failedCount).toBe(0); + expect(jobQueue.resumeJob).toHaveBeenCalledTimes(1); + expect(jobQueue.resumeJob).toHaveBeenCalledWith(activeMonitor); + expect(jobQueue.pauseJob).toHaveBeenCalledTimes(1); + expect(jobQueue.pauseJob).toHaveBeenCalledWith(pausedMonitor); + }); + + it("returns empty array when no monitors were affected", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([]); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2"], + pause: true, + }); + + expect(result.monitors).toHaveLength(0); + expect(result.failedCount).toBe(0); + expect(jobQueue.pauseJob).not.toHaveBeenCalled(); + expect(jobQueue.resumeJob).not.toHaveBeenCalled(); + }); + + it("works with a single monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ id: "m1", isActive: false }); + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([monitor]); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1"], + pause: true, + }); + + expect(result.monitors).toHaveLength(1); + expect(result.failedCount).toBe(0); + expect(jobQueue.pauseJob).toHaveBeenCalledWith(monitor); + }); + + it("returns failedCount and logs errors when multiple job operations reject", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [ + makeMonitor({ id: "m1", isActive: false }), + makeMonitor({ id: "m2", isActive: false }), + makeMonitor({ id: "m3", isActive: false }), + ]; + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors); + const jobQueue = createJobQueueMock(); + (jobQueue.pauseJob as jest.Mock) + .mockRejectedValueOnce(new Error("Queue failure 1")) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("Queue failure 2")); + + const logger = createMockLogger(); + const { service } = createService({ monitorsRepository, jobQueue, logger }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2", "m3"], + pause: true, + }); + + expect(result.monitors).toHaveLength(3); + expect(result.failedCount).toBe(2); + expect(logger.error).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Failed to sync job queue for monitor m1"), + method: "bulkPauseMonitors", + }) + ); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Failed to sync job queue for monitor m3"), + method: "bulkPauseMonitors", + }) + ); + }); + + it("logs a resume failure with undefined stack when rejection is not an Error instance", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [makeMonitor({ id: "m1", isActive: true })]; + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors); + const jobQueue = createJobQueueMock(); + // Reject with a non-Error value to exercise the `instanceof Error` false branch + (jobQueue.resumeJob as jest.Mock).mockRejectedValueOnce("queue blew up"); + + const logger = createMockLogger(); + const { service } = createService({ monitorsRepository, jobQueue, logger }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1"], + pause: false, + }); + + expect(result.failedCount).toBe(1); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith({ + message: "Failed to sync job queue for monitor m1 during bulk resume", + service: "MonitorService", + method: "bulkPauseMonitors", + stack: undefined, + }); + }); + + it("logs 'unknown' when the failing monitor has no id", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [makeMonitor({ id: "", isActive: false })]; + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors); + const jobQueue = createJobQueueMock(); + (jobQueue.pauseJob as jest.Mock).mockRejectedValueOnce(new Error("boom")); + + const logger = createMockLogger(); + const { service } = createService({ monitorsRepository, jobQueue, logger }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1"], + pause: true, + }); + + expect(result.failedCount).toBe(1); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Failed to sync job queue for monitor unknown during bulk pause", + }) + ); + }); + + it("logs 'unknown' when the failing monitor at the rejected index is nullish", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + // Defensive: bulkTogglePause returns an array containing a nullish entry; the + // inner map throws TypeError, the forEach hits the `?.` short-circuit branch. + (monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([undefined]); + const logger = createMockLogger(); + const { service } = createService({ monitorsRepository, logger }); + + const result = await service.bulkPauseMonitors({ + teamId: TEAM_ID, + monitorIds: ["m1"], + pause: true, + }); + + expect(result.failedCount).toBe(1); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Failed to sync job queue for monitor unknown during bulk pause", + }) + ); + }); + }); + describe("deleteMonitor", () => { it("deletes monitor and all associated data", async () => { const monitorsRepository = createMonitorsRepositoryMock(); diff --git a/server/test/unit/services/notificationMessageBuilder.test.ts b/server/test/unit/services/notificationMessageBuilder.test.ts index d10bd1604..4403a8bb3 100644 --- a/server/test/unit/services/notificationMessageBuilder.test.ts +++ b/server/test/unit/services/notificationMessageBuilder.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, beforeEach } from "@jest/globals"; import { NotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts"; import type { Monitor, MonitorStatusResponse, HardwareStatusPayload } from "../../../src/types/index.ts"; -import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import type { MonitorActionDecision } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/services/notificationsService.test.ts b/server/test/unit/services/notificationsService.test.ts index cdc1ad320..9527fd3b5 100644 --- a/server/test/unit/services/notificationsService.test.ts +++ b/server/test/unit/services/notificationsService.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; import { NotificationsService } from "../../../src/service/infrastructure/notificationsService.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; import type { Notification, Monitor, MonitorStatusResponse } from "../../../src/types/index.ts"; -import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import type { MonitorActionDecision } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -47,6 +47,7 @@ const createService = (overrides?: Record) => { const telegramProvider = createProvider(); const pushoverProvider = createProvider(); const twilioProvider = createProvider(); + const ntfyProvider = createProvider(); const settingsService = createSettingsService(); const notificationMessageBuilder = createMessageBuilder(); @@ -64,6 +65,7 @@ const createService = (overrides?: Record) => { telegramProvider, pushoverProvider, twilioProvider, + ntfyProvider, settingsService, notificationMessageBuilder, ...overrides, @@ -82,6 +84,7 @@ const createService = (overrides?: Record) => { defaults.telegramProvider as any, defaults.pushoverProvider as any, defaults.twilioProvider as any, + defaults.ntfyProvider as any, defaults.settingsService as any, defaults.logger as any, defaults.notificationMessageBuilder as any @@ -147,7 +150,7 @@ describe("NotificationsService", () => { }); it("routes to correct provider for each notification type", async () => { - const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio"] as const; + const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio", "ntfy"] as const; for (const type of types) { const deps = createService(); (deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]); @@ -165,6 +168,7 @@ describe("NotificationsService", () => { telegram: deps.telegramProvider, pushover: deps.pushoverProvider, twilio: deps.twilioProvider, + ntfy: deps.ntfyProvider, }; expect(providerMap[type].sendMessage).toHaveBeenCalledTimes(1); } @@ -238,30 +242,40 @@ describe("NotificationsService", () => { // ── sendTestNotification ───────────────────────────────────────────────── describe("sendTestNotification", () => { - it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"], ["pushover"], ["twilio"]] as const)( - "routes %s to the correct provider", - async (type) => { - const deps = createService(); - const notification = makeNotification({ type: type as any }); + it.each([ + ["email"], + ["slack"], + ["discord"], + ["pager_duty"], + ["matrix"], + ["webhook"], + ["teams"], + ["telegram"], + ["pushover"], + ["twilio"], + ["ntfy"], + ] as const)("routes %s to the correct provider", async (type) => { + const deps = createService(); + const notification = makeNotification({ type: type as any }); - const result = await deps.service.sendTestNotification(notification); + const result = await deps.service.sendTestNotification(notification); - expect(result).toBe(true); - const providerMap: Record> = { - webhook: deps.webhookProvider, - slack: deps.slackProvider, - matrix: deps.matrixProvider, - pager_duty: deps.pagerDutyProvider, - discord: deps.discordProvider, - email: deps.emailProvider, - teams: deps.teamsProvider, - telegram: deps.telegramProvider, - pushover: deps.pushoverProvider, - twilio: deps.twilioProvider, - }; - expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification); - } - ); + expect(result).toBe(true); + const providerMap: Record> = { + webhook: deps.webhookProvider, + slack: deps.slackProvider, + matrix: deps.matrixProvider, + pager_duty: deps.pagerDutyProvider, + discord: deps.discordProvider, + email: deps.emailProvider, + teams: deps.teamsProvider, + telegram: deps.telegramProvider, + pushover: deps.pushoverProvider, + twilio: deps.twilioProvider, + ntfy: deps.ntfyProvider, + }; + expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification); + }); it("returns false for unknown notification type", async () => { const { service } = createService(); diff --git a/server/test/unit/services/statusService.test.ts b/server/test/unit/services/statusService.test.ts index a17acbcd9..75323943b 100644 --- a/server/test/unit/services/statusService.test.ts +++ b/server/test/unit/services/statusService.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; import { StatusService } from "../../../src/service/infrastructure/statusService.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import { MAX_RECENT_CHECKS } from "../../../src/types/index.ts"; import type { Monitor, MonitorStatus, MonitorStatusResponse, Check, HardwareStatusPayload } from "../../../src/types/index.ts"; import type { IMonitorsRepository, IMonitorStatsRepository, IChecksRepository } from "../../../src/repositories/index.ts"; import type { IBufferService } from "../../../src/service/infrastructure/bufferService.ts"; @@ -280,13 +281,13 @@ describe("StatusService", () => { false, expect.any(Object), 5, - 25, + MAX_RECENT_CHECKS, expect.objectContaining({ status: expect.any(String) }) ); }); - it("pushes check snapshot to recentChecks and trims to 25", async () => { - const existingChecks = Array.from({ length: 25 }, (_, i) => ({ id: `old-${i}` })); + it("pushes check snapshot to recentChecks and trims to MAX_RECENT_CHECKS", async () => { + const existingChecks = Array.from({ length: MAX_RECENT_CHECKS }, (_, i) => ({ id: `old-${i}` })); const monitor = makeMonitor({ recentChecks: existingChecks as any, statusWindow: [], statusWindowSize: 5 }); const { service, monitorsRepository } = createService(); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); @@ -294,8 +295,8 @@ describe("StatusService", () => { await service.updateMonitorStatus(makeStatusResponse(), makeCheck({ id: "new-check" })); - expect(monitor.recentChecks).toHaveLength(25); - expect(monitor.recentChecks[24]).toEqual(expect.objectContaining({ id: "new-check" })); + expect(monitor.recentChecks).toHaveLength(MAX_RECENT_CHECKS); + expect(monitor.recentChecks[MAX_RECENT_CHECKS - 1]).toEqual(expect.objectContaining({ id: "new-check" })); }); it("marks status as down when failure threshold is met", async () => { diff --git a/server/test/unit/services/superSimpleQueue.test.ts b/server/test/unit/services/superSimpleQueue.test.ts index 93563dd00..b6da507ce 100644 --- a/server/test/unit/services/superSimpleQueue.test.ts +++ b/server/test/unit/services/superSimpleQueue.test.ts @@ -35,7 +35,7 @@ jest.unstable_mockModule("super-simple-scheduler", () => ({ default: MockScheduler, })); -const { SuperSimpleQueue } = await import("../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts"); +const { SuperSimpleQueue } = await import("../../../src/service/infrastructure/JobQueues/SuperSimpleQueue.ts"); const createQueueHelper = () => ({ getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()), @@ -291,34 +291,71 @@ describe("SuperSimpleQueue", () => { // ── pauseJob ───────────────────────────────────────────────────────────── describe("pauseJob", () => { - it("pauses monitor and removes geo job if it exists", async () => { + it("pauses monitor and pauses geo job when geo exists", async () => { const { queue, scheduler, logger } = createQueue(); (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); await queue.pauseJob(makeMonitor()); expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); - expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" })); }); - it("does not remove geo job if it does not exist", async () => { + it("refreshes scheduler data with current monitor on pause", async () => { const { queue, scheduler } = createQueue(); - await queue.pauseJob(makeMonitor()); - expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + const monitor = makeMonitor({ isActive: false, status: "paused" }); + + await queue.pauseJob(monitor); + + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", { data: monitor }); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", { data: monitor }); }); - it("throws when scheduler.pauseJob returns false", async () => { + it("does not pause geo job when it does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.pauseJob(makeMonitor()); + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.pauseJob).not.toHaveBeenCalledWith("mon-1-geo"); + }); + + it("does not refresh geo data when geo job does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.pauseJob(makeMonitor()); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ data: expect.anything() })); + expect(scheduler.updateJob).not.toHaveBeenCalledWith("mon-1-geo", expect.anything()); + }); + + it("throws when scheduler.pauseJob returns false for the main job", async () => { const { queue, scheduler } = createQueue(); (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor"); }); + + it("does not refresh data when main pauseJob throws", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); + await expect(queue.pauseJob(makeMonitor())).rejects.toThrow(); + expect(scheduler.updateJob).not.toHaveBeenCalled(); + }); + + it("logs error but does not throw when geo pauseJob returns false", async () => { + const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + (scheduler.pauseJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo"); + + await expect(queue.pauseJob(makeMonitor())).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to pause geo check job for monitor mon-1" })); + }); }); // ── resumeJob ──────────────────────────────────────────────────────────── describe("resumeJob", () => { - it("resumes monitor and geo job", async () => { + it("resumes monitor and geo job when geo exists", async () => { const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); await queue.resumeJob(makeMonitor()); expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); @@ -326,11 +363,64 @@ describe("SuperSimpleQueue", () => { expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" })); }); - it("throws when scheduler.resumeJob returns false", async () => { + it("refreshes scheduler data with current monitor on resume", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + const monitor = makeMonitor({ isActive: true, status: "initializing" }); + + await queue.resumeJob(monitor); + + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", { data: monitor }); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", { data: monitor }); + }); + + it("does not resume geo job when it does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.resumeJob(makeMonitor()); + expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.resumeJob).not.toHaveBeenCalledWith("mon-1-geo"); + }); + + it("does not refresh geo data when geo job does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.resumeJob(makeMonitor()); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ data: expect.anything() })); + expect(scheduler.updateJob).not.toHaveBeenCalledWith("mon-1-geo", expect.anything()); + }); + + it("throws when scheduler.resumeJob returns false for the main job", async () => { const { queue, scheduler } = createQueue(); (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor"); }); + + it("does not refresh data when main resumeJob throws", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); + await expect(queue.resumeJob(makeMonitor())).rejects.toThrow(); + expect(scheduler.updateJob).not.toHaveBeenCalled(); + }); + + it("logs error but does not throw when geo resumeJob returns false", async () => { + const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + (scheduler.resumeJob as jest.Mock).mockImplementation(async (id: string) => id !== "mon-1-geo"); + + await expect(queue.resumeJob(makeMonitor())).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to resume geo check job for monitor mon-1" })); + }); + + it("preserves geo job through a pause/resume cycle", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + + await queue.pauseJob(makeMonitor()); + await queue.resumeJob(makeMonitor()); + + expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1-geo"); + expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo"); + }); }); // ── updateJob ──────────────────────────────────────────────────────────── @@ -396,10 +486,25 @@ describe("SuperSimpleQueue", () => { // ── getMetrics ─────────────────────────────────────────────────────────── describe("getMetrics", () => { - it("returns empty metrics when no jobs", async () => { + it("returns empty job metrics plus this process as the sole worker when no jobs", async () => { const { queue } = createQueue(); const metrics = await queue.getMetrics(); - expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0 }); + expect(metrics).toEqual({ + jobs: 0, + activeJobs: 0, + failingJobs: 0, + jobsWithFailures: [], + totalRuns: 0, + totalFailures: 0, + workers: [ + { + // hostname:pid:uuid, matching less-simple-scheduler's workerId format + workerId: expect.stringMatching(/^.+:\d+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/), + mode: "primary", + lastSeenAt: expect.any(Number), + }, + ], + }); }); it("aggregates metrics from jobs", async () => { @@ -467,6 +572,7 @@ describe("SuperSimpleQueue", () => { { id: "m1", active: true, + repeat: 60000, lockedAt: null, runCount: 5, failCount: 1, @@ -474,10 +580,10 @@ describe("SuperSimpleQueue", () => { lastRunAt: 1000, lastFinishedAt: 1100, lastFailedAt: 900, - data: { url: "http://a.com", type: "http", interval: 60000 }, + data: { url: "http://a.com", type: "http", interval: 60000, geoCheckInterval: 300000, isActive: true }, }, ]); - const jobs = await queue.getJobs(); + const { jobs } = await queue.getJobs({}); expect(jobs).toHaveLength(1); expect(jobs[0]).toEqual({ @@ -485,7 +591,12 @@ describe("SuperSimpleQueue", () => { monitorUrl: "http://a.com", monitorType: "http", monitorInterval: 60000, + monitorGeoInterval: 300000, + monitorActive: true, active: true, + repeat: 60000, + lockedBy: null, + lockedUntil: null, lockedAt: null, runCount: 5, failCount: 1, @@ -502,17 +613,64 @@ describe("SuperSimpleQueue", () => { (scheduler.getJobs as jest.Mock).mockResolvedValue([ { id: "m1", active: true, lockedAt: 999, runCount: 1, data: { url: "a", type: "http", interval: 60000 } }, ]); - const jobs = await queue.getJobs(); + const { jobs } = await queue.getJobs({}); expect(jobs[0].lastRunTook).toBeNull(); }); it("handles jobs with missing data", async () => { const { queue, scheduler } = createQueue(); (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", active: true, data: null }]); - const jobs = await queue.getJobs(); + const { jobs } = await queue.getJobs({}); expect(jobs[0].monitorUrl).toBeNull(); expect(jobs[0].monitorType).toBeNull(); expect(jobs[0].monitorInterval).toBeNull(); + expect(jobs[0].monitorGeoInterval).toBeNull(); + expect(jobs[0].monitorActive).toBeNull(); + expect(jobs[0].repeat).toBeNull(); + }); + + it("exposes monitor.isActive as monitorActive in the summary", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", active: true, data: { url: "a", type: "http", interval: 60000, isActive: true } }, + { id: "m2", active: false, data: { url: "b", type: "http", interval: 60000, isActive: false } }, + ]); + const { jobs } = await queue.getJobs({}); + expect(jobs[0].monitorActive).toBe(true); + expect(jobs[1].monitorActive).toBe(false); + }); + + it("exposes geo check interval and repeat", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", active: true, repeat: 120000, data: { url: "a", type: "http", interval: 60000, geoCheckInterval: 600000 } }, + ]); + const { jobs } = await queue.getJobs({}); + expect(jobs[0].repeat).toBe(120000); + expect(jobs[0].monitorGeoInterval).toBe(600000); + }); + + it("paginates jobs and returns the total count", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue( + Array.from({ length: 25 }, (_, i) => ({ id: `m${i}`, active: true, data: { url: `a${i}`, type: "http", interval: 60000 } })) + ); + + const { jobs, count } = await queue.getJobs({ page: 1, rowsPerPage: 10 }); + expect(count).toBe(25); + expect(jobs).toHaveLength(10); + expect(jobs[0].monitorId).toBe("m10"); + }); + + it("returns all jobs when rowsPerPage is omitted", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue( + Array.from({ length: 25 }, (_, i) => ({ id: `m${i}`, active: true, data: { url: `a${i}`, type: "http", interval: 60000 } })) + ); + + const { jobs, count } = await queue.getJobs({}); + expect(count).toBe(25); + expect(jobs).toHaveLength(25); }); }); diff --git a/server/test/unit/services/superSimpleQueueHelper.test.ts b/server/test/unit/services/superSimpleQueueHelper.test.ts index 4d8ecfbc0..7075051eb 100644 --- a/server/test/unit/services/superSimpleQueueHelper.test.ts +++ b/server/test/unit/services/superSimpleQueueHelper.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { SuperSimpleQueueHelper } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import { QueueHelper } from "../../../src/service/infrastructure/JobQueues/QueueHelper.ts"; import type { Monitor } from "../../../src/types/monitor.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; @@ -59,7 +59,7 @@ const createHelper = (overrides?: Record) => { ...overrides, }; - const helper = new SuperSimpleQueueHelper( + const helper = new QueueHelper( defaults.logger as any, defaults.networkService as any, defaults.statusService as any, @@ -94,7 +94,7 @@ const makeMonitor = (overrides?: Partial): Monitor => // ── Tests ──────────────────────────────────────────────────────────────────── -describe("SuperSimpleQueueHelper", () => { +describe("QueueHelper", () => { describe("serviceName", () => { it("returns JobQueueHelper", () => { const { helper } = createHelper(); diff --git a/server/test/unit/services/tagsService.test.ts b/server/test/unit/services/tagsService.test.ts new file mode 100644 index 000000000..d611d641e --- /dev/null +++ b/server/test/unit/services/tagsService.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { TagsService } from "../../../src/service/infrastructure/tagService.ts"; +import type { Tag } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createTagsRepo = () => ({ + create: jest.fn(), + findById: jest.fn(), + findByTeamId: jest.fn(), + updateById: jest.fn(), + deleteById: jest.fn(), +}); + +const createMonitorsRepo = () => ({ + removeTagFromMonitors: jest.fn(), +}); + +const createService = (overrides?: Record) => { + const tagsRepository = createTagsRepo(); + const monitorsRepository = createMonitorsRepo(); + + const defaults = { + tagsRepository, + monitorsRepository, + ...overrides, + }; + + const service = new TagsService(defaults.tagsRepository as any, defaults.monitorsRepository as any); + + return { service, ...defaults }; +}; + +const makeTag = (overrides?: Partial): Tag => + ({ + id: "tag-1", + teamId: "team-1", + name: "Production", + color: "#13715B", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Tag; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("TagsService", () => { + describe("createTag", () => { + it("sets teamId and delegates to repository", async () => { + const created = makeTag(); + const { service, tagsRepository } = createService(); + (tagsRepository.create as jest.Mock).mockResolvedValue(created); + + const result = await service.createTag({ name: "Production", color: "#13715B" }, "team-1"); + + expect(result).toBe(created); + expect(tagsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ teamId: "team-1", name: "Production", color: "#13715B" })); + }); + }); + + describe("getTag", () => { + it("delegates to repository", async () => { + const tag = makeTag(); + const { service, tagsRepository } = createService(); + (tagsRepository.findById as jest.Mock).mockResolvedValue(tag); + + const result = await service.getTag("tag-1", "team-1"); + + expect(result).toBe(tag); + expect(tagsRepository.findById).toHaveBeenCalledWith("tag-1", "team-1"); + }); + }); + + describe("getTagsByTeamId", () => { + it("delegates to repository", async () => { + const tags = [makeTag()]; + const { service, tagsRepository } = createService(); + (tagsRepository.findByTeamId as jest.Mock).mockResolvedValue(tags); + + const result = await service.getTagsByTeamId("team-1"); + + expect(result).toBe(tags); + expect(tagsRepository.findByTeamId).toHaveBeenCalledWith("team-1"); + }); + }); + + describe("updateTag", () => { + it("delegates to repository", async () => { + const updated = makeTag({ name: "Staging" }); + const { service, tagsRepository } = createService(); + (tagsRepository.updateById as jest.Mock).mockResolvedValue(updated); + + const result = await service.updateTag("tag-1", "team-1", { name: "Staging" }); + + expect(result).toBe(updated); + expect(tagsRepository.updateById).toHaveBeenCalledWith("tag-1", "team-1", { name: "Staging" }); + }); + }); + + describe("deleteTag", () => { + it("removes the tag from monitors and deletes it", async () => { + const deleted = makeTag(); + const { service, tagsRepository, monitorsRepository } = createService(); + (tagsRepository.deleteById as jest.Mock).mockResolvedValue(deleted); + + const result = await service.deleteTag("tag-1", "team-1"); + + expect(result).toBe(deleted); + expect(monitorsRepository.removeTagFromMonitors).toHaveBeenCalledWith("tag-1"); + expect(tagsRepository.deleteById).toHaveBeenCalledWith("tag-1", "team-1"); + }); + + it("detaches the tag from monitors before deleting it", async () => { + const { service, tagsRepository, monitorsRepository } = createService(); + (tagsRepository.deleteById as jest.Mock).mockResolvedValue(makeTag()); + + await service.deleteTag("tag-1", "team-1"); + + const removeOrder = (monitorsRepository.removeTagFromMonitors as jest.Mock).mock.invocationCallOrder[0]; + const deleteOrder = (tagsRepository.deleteById as jest.Mock).mock.invocationCallOrder[0]; + expect(removeOrder).toBeLessThan(deleteOrder); + }); + }); +}); diff --git a/server/test/unit/utils/maintenanceWindow.test.ts b/server/test/unit/utils/maintenanceWindow.test.ts new file mode 100644 index 000000000..5a3a39b6a --- /dev/null +++ b/server/test/unit/utils/maintenanceWindow.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "@jest/globals"; +import { isWindowActive } from "../../../src/utils/maintenanceWindow.ts"; +import type { MaintenanceWindow } from "../../../src/types/index.ts"; + +const makeWindow = (overrides?: Partial): MaintenanceWindow => { + const now = Date.now(); + return { + id: "mw-1", + monitorIds: ["mon-1"], + teamId: "team-1", + active: true, + name: "Scheduled Maintenance", + duration: 60, + durationUnit: "minutes", + repeat: 0, + start: new Date(now - 1000).toISOString(), + end: new Date(now + 1000).toISOString(), + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +}; + +describe("isWindowActive", () => { + it("returns false when window.active is false even if dates span now", () => { + expect(isWindowActive(makeWindow({ active: false }))).toBe(false); + }); + + it("returns true for a one-time window where now is between start and end", () => { + expect(isWindowActive(makeWindow())).toBe(true); + }); + + it("returns false when the one-time window has not started yet", () => { + const now = Date.now(); + expect( + isWindowActive( + makeWindow({ + start: new Date(now + 60_000).toISOString(), + end: new Date(now + 120_000).toISOString(), + }) + ) + ).toBe(false); + }); + + it("returns false when the one-time window has already ended", () => { + const now = Date.now(); + expect( + isWindowActive( + makeWindow({ + start: new Date(now - 120_000).toISOString(), + end: new Date(now - 60_000).toISOString(), + repeat: 0, + }) + ) + ).toBe(false); + }); + + it("returns true when a recurring window's advanced occurrence covers now", () => { + const now = Date.now(); + // 2h ago, 10-min window, hourly repeat — third occurrence (0h) covers now + expect( + isWindowActive( + makeWindow({ + start: new Date(now - 7_200_000).toISOString(), + end: new Date(now - 7_200_000 + 600_000).toISOString(), + repeat: 3_600_000, + }) + ) + ).toBe(true); + }); + + it("returns false when a recurring window's occurrences all miss now", () => { + const now = Date.now(); + // 31min ago, 1-min window, hourly repeat → next occurrence ~29min from now + expect( + isWindowActive( + makeWindow({ + start: new Date(now - 1_860_000).toISOString(), + end: new Date(now - 1_800_000).toISOString(), + repeat: 3_600_000, + }) + ) + ).toBe(false); + }); + + it("respects the explicit `now` parameter for comparisons", () => { + const win = makeWindow({ + start: "2026-01-15T11:00:00Z", + end: "2026-01-15T13:00:00Z", + }); + expect(isWindowActive(win, new Date("2026-01-15T12:00:00Z"))).toBe(true); + expect(isWindowActive(win, new Date("2026-01-15T14:00:00Z"))).toBe(false); + expect(isWindowActive(win, new Date("2026-01-15T10:00:00Z"))).toBe(false); + }); +}); diff --git a/server/test/unit/validation/maintenanceWindowValidation.test.ts b/server/test/unit/validation/maintenanceWindowValidation.test.ts new file mode 100644 index 000000000..a209ba64f --- /dev/null +++ b/server/test/unit/validation/maintenanceWindowValidation.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, jest, afterEach } from "@jest/globals"; +import { + createMaintenanceWindowBodyValidation, + editMaintenanceByIdWindowBodyValidation, +} from "../../../src/validation/maintenanceWindowValidation.ts"; + +const FROZEN_NOW = new Date("2026-05-07T12:00:00Z").getTime(); + +const baseCreateBody = (overrides: Record = {}) => ({ + monitors: ["mon-1"], + name: "Scheduled Maintenance", + duration: 60, + durationUnit: "minutes", + start: new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW + 2 * 60 * 60 * 1000).toISOString(), + repeat: 0, + ...overrides, +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe("createMaintenanceWindowBodyValidation", () => { + it("accepts a valid one-time window with end in the future", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody()); + expect(result.success).toBe(true); + }); + + it("accepts a recurring window even when end is in the past", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse( + baseCreateBody({ + repeat: 1, + start: new Date(FROZEN_NOW - 2 * 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(), + }) + ); + expect(result.success).toBe(true); + }); + + it("rejects when end is before start", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse( + baseCreateBody({ + start: new Date(FROZEN_NOW + 2 * 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(), + }) + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })])); + } + }); + + it("rejects when end equals start", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const sameInstant = new Date(FROZEN_NOW + 60 * 60 * 1000).toISOString(); + const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ start: sameInstant, end: sameInstant })); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })])); + } + }); + + it("rejects a one-time window with end in the past", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse( + baseCreateBody({ + start: new Date(FROZEN_NOW - 2 * 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(), + }) + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "End must be in the future for one-time maintenance windows", + path: ["end"], + }), + ]) + ); + } + }); + + it("rejects a one-time window with end exactly equal to now", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse( + baseCreateBody({ + start: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(), + end: new Date(FROZEN_NOW).toISOString(), + }) + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "End must be in the future for one-time maintenance windows", + path: ["end"], + }), + ]) + ); + } + }); + + it("rejects unknown fields under .strict()", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ teamId: "team-1" })); + expect(result.success).toBe(false); + }); + + it("rejects an invalid durationUnit value", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const result = createMaintenanceWindowBodyValidation.safeParse(baseCreateBody({ durationUnit: "weeks" })); + expect(result.success).toBe(false); + }); + + it("rejects a missing required field", () => { + jest.useFakeTimers().setSystemTime(FROZEN_NOW); + const { name: _name, ...withoutName } = baseCreateBody(); + const result = createMaintenanceWindowBodyValidation.safeParse(withoutName); + expect(result.success).toBe(false); + }); +}); + +describe("editMaintenanceByIdWindowBodyValidation", () => { + it("accepts an empty body (all fields optional)", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({}); + expect(result.success).toBe(true); + }); + + it("accepts a partial update with only active toggled", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ active: false }); + expect(result.success).toBe(true); + }); + + it("rejects unknown fields under .strict()", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ + active: false, + id: "mw-1", + }); + expect(result.success).toBe(false); + }); + + it("rejects when start and end are both present and end <= start", () => { + const start = new Date("2026-05-08T10:00:00Z").toISOString(); + const end = new Date("2026-05-08T09:00:00Z").toISOString(); + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ start, end }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ message: "End must be after start", path: ["end"] })])); + } + }); + + it("does not apply the start/end ordering rule when only end is provided", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ + end: new Date("2020-01-01T00:00:00Z").toISOString(), + }); + expect(result.success).toBe(true); + }); + + it("does not apply the start/end ordering rule when only start is provided", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ + start: new Date("2099-01-01T00:00:00Z").toISOString(), + }); + expect(result.success).toBe(true); + }); + + it("accepts a body without a monitors field (monitors is optional on edit)", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ name: "Updated Name" }); + expect(result.success).toBe(true); + }); + + it("accepts a body with one or more monitors", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ monitors: ["mon-1"] }); + expect(result.success).toBe(true); + }); + + it("rejects an empty monitors array", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ monitors: [] }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([expect.objectContaining({ message: "At least one monitor is required", path: ["monitors"] })]) + ); + } + }); +}); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts new file mode 100644 index 000000000..057ff6ae1 --- /dev/null +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "@jest/globals"; +import { createMonitorBodyValidation, editMonitorBodyValidation, importMonitorsBodyValidation } from "../../../src/validation/monitorValidation.ts"; + +const baseDnsBody = { + name: "DNS check", + type: "dns" as const, + url: "example.com", +}; + +describe("monitorValidation — DNS fields", () => { + describe("createMonitorBodyValidation", () => { + it("retains dnsServer and dnsRecordType on a DNS monitor", () => { + const parsed = createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "8.8.8.8", + dnsRecordType: "A", + }); + + expect(parsed.dnsServer).toBe("8.8.8.8"); + expect(parsed.dnsRecordType).toBe("A"); + }); + + it("accepts every supported DNS record type", () => { + for (const recordType of ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const) { + const parsed = createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "1.1.1.1", + dnsRecordType: recordType, + }); + expect(parsed.dnsRecordType).toBe(recordType); + } + }); + + it("rejects unknown DNS record types", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "8.8.8.8", + dnsRecordType: "BOGUS", + }) + ).toThrow(); + }); + + it("treats DNS fields as optional (other monitor types still validate)", () => { + const parsed = createMonitorBodyValidation.parse({ + name: "HTTP check", + type: "http", + url: "https://example.com", + }); + + expect(parsed.dnsServer).toBeUndefined(); + expect(parsed.dnsRecordType).toBeUndefined(); + }); + + it("rejects a DNS monitor whose url has a scheme or path", () => { + for (const badUrl of ["https://example.com", "example.com/path", "example.com:53", " example.com"]) { + expect(() => + createMonitorBodyValidation.parse({ + ...baseDnsBody, + url: badUrl, + dnsServer: "8.8.8.8", + dnsRecordType: "A", + }) + ).toThrow(); + } + }); + + it("rejects a DNS monitor with an invalid dnsServer", () => { + expect(() => + createMonitorBodyValidation.parse({ + ...baseDnsBody, + dnsServer: "not-an-ip", + dnsRecordType: "A", + }) + ).toThrow(); + }); + + it("accepts service labels with leading underscore (DMARC, SRV, ACME)", () => { + for (const url of ["_dmarc.example.com", "_imaps._tcp.example.com", "_acme-challenge.example.com"]) { + const parsed = createMonitorBodyValidation.parse({ + ...baseDnsBody, + url, + dnsServer: "8.8.8.8", + dnsRecordType: "TXT", + }); + expect(parsed.url).toBe(url); + } + }); + + it("does not enforce the hostname rule for non-DNS monitors", () => { + const parsed = createMonitorBodyValidation.parse({ + name: "HTTP check", + type: "http", + url: "https://example.com/some/path", + }); + + expect(parsed.url).toBe("https://example.com/some/path"); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("retains dnsServer and dnsRecordType on edits", () => { + const parsed = editMonitorBodyValidation.parse({ + dnsServer: "1.1.1.1", + dnsRecordType: "MX", + }); + + expect(parsed.dnsServer).toBe("1.1.1.1"); + expect(parsed.dnsRecordType).toBe("MX"); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("retains dnsServer and dnsRecordType on imported DNS monitors", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [ + { + name: "Imported DNS", + type: "dns", + url: "example.com", + dnsServer: "8.8.4.4", + dnsRecordType: "TXT", + }, + ], + }); + + expect(parsed.monitors[0].dnsServer).toBe("8.8.4.4"); + expect(parsed.monitors[0].dnsRecordType).toBe("TXT"); + }); + }); +}); + +describe("monitorValidation — strategy gating", () => { + describe("createMonitorBodyValidation", () => { + it("accepts strategy on pagespeed monitors", () => { + const parsed = createMonitorBodyValidation.parse({ + name: "PS check", + type: "pagespeed", + url: "https://example.com", + strategy: "mobile", + }); + expect(parsed.strategy).toBe("mobile"); + }); + + it("accepts pagespeed monitors without a strategy", () => { + const parsed = createMonitorBodyValidation.parse({ + name: "PS check", + type: "pagespeed", + url: "https://example.com", + }); + expect(parsed.strategy).toBeUndefined(); + }); + + it("rejects strategy on non-pagespeed monitor types", () => { + for (const type of ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns", "hardware"] as const) { + expect(() => + createMonitorBodyValidation.parse({ + name: "wrong-type check", + type, + url: type === "dns" ? "example.com" : "https://example.com", + strategy: "desktop", + }) + ).toThrow(); + } + }); + + it("allows non-pagespeed monitors without a strategy", () => { + const parsed = createMonitorBodyValidation.parse({ + name: "HTTP check", + type: "http", + url: "https://example.com", + }); + expect(parsed.strategy).toBeUndefined(); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("rejects strategy when type is explicitly non-pagespeed", () => { + expect(() => + editMonitorBodyValidation.parse({ + type: "http", + strategy: "mobile", + }) + ).toThrow(); + }); + + it("allows strategy when type is omitted (partial edit)", () => { + const parsed = editMonitorBodyValidation.parse({ + strategy: "mobile", + }); + expect(parsed.strategy).toBe("mobile"); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("does not inject a default strategy on imported HTTP monitors", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [ + { + name: "Imported HTTP", + type: "http", + url: "https://example.com", + }, + ], + }); + expect(parsed.monitors[0].strategy).toBeUndefined(); + }); + + it("rejects strategy on imported non-pagespeed monitors", () => { + expect(() => + importMonitorsBodyValidation.parse({ + monitors: [ + { + name: "Imported HTTP", + type: "http", + url: "https://example.com", + strategy: "desktop", + }, + ], + }) + ).toThrow(); + }); + }); +}); diff --git a/server/test/unit/validation/userValidation.test.ts b/server/test/unit/validation/userValidation.test.ts new file mode 100644 index 000000000..2e263d1a9 --- /dev/null +++ b/server/test/unit/validation/userValidation.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "@jest/globals"; +import { editUserBodyValidation } from "../../../src/validation/userValidation.ts"; + +const validPassword = "OldPass1!"; +const validNewPassword = "NewPass1!"; + +describe("editUserBodyValidation", () => { + // Regression: the Joi->Zod validation refactor (commit c3a41272a3) dropped + // `password`/`newPassword` from this schema. Because z.object() strips + // unknown keys, the change-password flow in userService.editUser silently + // became a no-op. Both fields must survive parsing. + it("retains password and newPassword on the parsed body", () => { + const result = editUserBodyValidation.safeParse({ + password: validPassword, + newPassword: validNewPassword, + }); + expect(result.success).toBe(true); + expect(result.data).toEqual({ + password: validPassword, + newPassword: validNewPassword, + }); + }); + + it("accepts a profile-only edit with neither password field", () => { + const result = editUserBodyValidation.safeParse({ firstName: "Ada", lastName: "Lovelace" }); + expect(result.success).toBe(true); + }); + + it("rejects password without newPassword", () => { + const result = editUserBodyValidation.safeParse({ password: validPassword }); + expect(result.success).toBe(false); + }); + + it("rejects newPassword without password", () => { + const result = editUserBodyValidation.safeParse({ newPassword: validNewPassword }); + expect(result.success).toBe(false); + }); + + it("rejects a newPassword that does not meet the strength policy", () => { + const result = editUserBodyValidation.safeParse({ + password: validPassword, + newPassword: "weak", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/server/tsconfig.build.json b/server/tsconfig.build.json new file mode 100644 index 000000000..b4d63e55e --- /dev/null +++ b/server/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "noEmit": false + }, + "include": ["src"] +} diff --git a/server/tsconfig.json b/server/tsconfig.json index fe8233c0c..d4544d9bb 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "rootDir": "./src", "allowJs": true, "checkJs": false, "paths": { @@ -15,8 +14,9 @@ "skipLibCheck": true, "noUncheckedIndexedAccess": true, "isolatedModules": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "noEmit": true }, - "include": ["src"], + "include": ["src", "openapi", "scripts"], "exclude": ["tests", "dist", "node_modules"] }