diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..7f80b8533 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto +* text eol=lf \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 16273e9d5..9c9ba0c65 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,27 +1,28 @@ -**(Please remove this line only before submitting your PR. Ensure that all relevant items are checked before submission.)** +**(Please remove this line only before submitting your PR. Ensure that all relevant items are checked before submission.)** ## Describe your changes -Briefly describe the changes you made and their purpose. +Briefly describe the changes you made and their purpose. ## Write your issue number after "Fixes " -Fixes #123 +Fixes #123 ## Please ensure all items are checked off before requesting a review. "Checked off" means you need to add an "x" character between brackets so they turn into checkmarks. - [ ] (Do not skip this or your PR will be closed) I deployed the application locally. -- [ ] (Do not skip this or your PR will be closed) I have performed a self-review and testing of my code. +- [ ] (Do not skip this or your PR will be closed) I have performed a self-reviewing and testing of my code. - [ ] I have included the issue # in the PR. -- [ ] I have added i18n support to visible strings (instead of `
Add
`, use): +- [ ] I have added i18n support to visible strings (instead of `
Add
`, use): + ```Javascript const { t } = useTranslation();
{t('add')}
``` + - [ ] I have **not** included any files that are not related to my pull request, including package-lock and package-json if dependencies have not changed - [ ] I didn't use any hardcoded values (otherwise it will not scale, and will make it difficult to maintain consistency across the application). - [ ] I made sure font sizes, color choices etc are all referenced from the theme. I don't have any hardcoded dimensions. - [ ] My PR is granular and targeted to one specific feature. - [ ] I ran `npm run format` in server and client directories, which automatically formats your code. - [ ] I took a screenshot or a video and attached to this PR if there is a UI change. - diff --git a/.github/workflows/check-tests.yml b/.github/workflows/check-tests.yml new file mode 100644 index 000000000..8d0688eb2 --- /dev/null +++ b/.github/workflows/check-tests.yml @@ -0,0 +1,23 @@ +name: Test Check (Server) +on: + pull_request: + workflow_dispatch: + +jobs: + test-server: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install server dependencies + working-directory: server + run: npm install + + - name: Run server tests + working-directory: server + run: npm test diff --git a/.github/workflows/poeditor-sync.yml b/.github/workflows/poeditor-sync.yml index c9bb92b72..6b6731b2d 100644 --- a/.github/workflows/poeditor-sync.yml +++ b/.github/workflows/poeditor-sync.yml @@ -1,120 +1,138 @@ -name: POEditor Translation Synchronization +name: POEditor Translation Synchronization (disabled) + +# This workflow is intentionally disabled — kept for reference only. +# The full definition is preserved commented-out below. To re-enable, +# delete the stub job and uncomment the section starting at `on:`. on: - # For manual triggering - workflow_dispatch: - inputs: - languages: - description: "Languages to synchronize (comma separated, e.g.: tr,en,es)" - required: false - default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th" - format: - description: "Export format (key_value_json or json)" - required: false - default: "key_value_json" - -permissions: - contents: write - pull-requests: write + workflow_dispatch: jobs: - sync-translations: - runs-on: ubuntu-latest - permissions: - contents: write - issues: write - pull-requests: write + disabled: + if: false + runs-on: ubuntu-latest + steps: + - run: echo "This workflow is disabled. See comments below for the original definition." - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 +# --------------------------------------------------------------------------- +# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing. +# --------------------------------------------------------------------------- - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: "22" +# on: +# # For manual triggering +# workflow_dispatch: +# inputs: +# languages: +# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)" +# required: false +# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th" +# format: +# description: "Export format (key_value_json or json)" +# required: false +# default: "key_value_json" - - name: Create package.json for scripts - run: | - mkdir -p .github/scripts - cat > .github/scripts/package.json << EOF - { - "name": "poeditor-scripts", - "version": "1.0.0", - "private": true, - "type": "module", - "dependencies": { - "axios": "^1.6.0", - "fs-extra": "^11.1.1" - } - } - EOF +# permissions: +# contents: write +# pull-requests: write - - name: Install dependencies - run: | - cd .github/scripts - npm install +# jobs: +# sync-translations: +# runs-on: ubuntu-latest +# permissions: +# contents: write +# issues: write +# pull-requests: write - - name: Download translations from POEditor - env: - POEDITOR_API: ${{ secrets.POEDITOR_API }} - POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }} - LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }} - EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }} - run: | - mkdir -p temp - node .github/scripts/download-translations.js +# steps: +# - name: Checkout code +# uses: actions/checkout@v3 +# with: +# token: ${{ secrets.GITHUB_TOKEN }} +# fetch-depth: 0 - - name: Verify translation files - run: | - echo "Verifying translation files..." - for file in temp/*.json; do - echo "Checking $file" - if [ ! -s "$file" ]; then - echo "Error: $file is empty or does not exist" - exit 1 - fi - # Validate JSON format - cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; } - done - echo "All translation files are valid" +# - name: Setup Node.js +# uses: actions/setup-node@v3 +# with: +# node-version: "22" - - name: Copy translations to project - run: | - mkdir -p client/src/locales - cp -r temp/* client/src/locales/ - echo "Translation files copied to client/src/locales/" +# - name: Create package.json for scripts +# run: | +# mkdir -p .github/scripts +# cat > .github/scripts/package.json << EOF +# { +# "name": "poeditor-scripts", +# "version": "1.0.0", +# "private": true, +# "type": "module", +# "dependencies": { +# "axios": "^1.6.0", +# "fs-extra": "^11.1.1" +# } +# } +# EOF - - name: Get current date - id: date - run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT +# - name: Install dependencies +# run: | +# cd .github/scripts +# npm install - - name: Format client code - run: | - cd client - npm ci - npm run format +# - name: Download translations from POEditor +# env: +# POEDITOR_API: ${{ secrets.POEDITOR_API }} +# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }} +# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }} +# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }} +# run: | +# mkdir -p temp +# node .github/scripts/download-translations.js - - name: Create Pull Request - uses: peter-evans/create-pull-request@v7 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: "feat: translations updated from POEditor" - title: "🌐 Updated Translations from POEditor" - body: | - This PR contains the latest translations from POEditor. +# - name: Verify translation files +# run: | +# echo "Verifying translation files..." +# for file in temp/*.json; do +# echo "Checking $file" +# if [ ! -s "$file" ]; then +# echo "Error: $file is empty or does not exist" +# exit 1 +# fi +# # Validate JSON format +# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; } +# done +# echo "All translation files are valid" - 📅 Update Date: ${{ steps.date.outputs.date }} - 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }} +# - name: Copy translations to project +# run: | +# mkdir -p client/src/locales +# cp -r temp/* client/src/locales/ +# echo "Translation files copied to client/src/locales/" - ⚠️ Please review the translations and approve the PR if everything looks correct. - branch: translation-update-${{ github.run_number }} - delete-branch: true - base: develop - add-paths: | - client/src/locales/*.json - committer: GitHub Action - author: GitHub Action +# - name: Get current date +# id: date +# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT + +# - name: Format client code +# run: | +# cd client +# npm ci +# npm run format + +# - name: Create Pull Request +# uses: peter-evans/create-pull-request@v7 +# with: +# token: ${{ secrets.GITHUB_TOKEN }} +# commit-message: "feat: translations updated from POEditor" +# title: "🌐 Updated Translations from POEditor" +# body: | +# This PR contains the latest translations from POEditor. + +# 📅 Update Date: ${{ steps.date.outputs.date }} +# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }} + +# ⚠️ Please review the translations and approve the PR if everything looks correct. +# branch: translation-update-${{ github.run_number }} +# delete-branch: true +# base: develop +# add-paths: | +# client/src/locales/*.json +# committer: GitHub Action +# author: GitHub Action diff --git a/.github/workflows/production-deploy.yml b/.github/workflows/production-deploy.yml index 532197710..a42efc4a4 100644 --- a/.github/workflows/production-deploy.yml +++ b/.github/workflows/production-deploy.yml @@ -2,7 +2,7 @@ name: Demo deploy on: push: - branches: ["demo"] + branches: [ "demo" ] workflow_dispatch: jobs: docker-build-and-push-client: @@ -84,7 +84,8 @@ jobs: script: | cd checkmate git pull - cd docker/prod + cd docker/unified docker compose down docker compose pull docker compose up -d + docker system prune -af diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index 84be4e483..e7983f249 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -2,7 +2,7 @@ name: Staging deploy on: push: - branches: ["develop"] + branches: [ "develop" ] jobs: docker-build-and-push-client: @@ -78,13 +78,13 @@ jobs: - name: SSH into server and restart container using Docker Compose uses: appleboy/ssh-action@v1.2.2 with: - host: ${{ secrets.STAGING_SERVER_HOST }} - username: ${{ secrets.STAGING_SERVER_USER }} - key: ${{ secrets.STAGING_SERVER_SSH_KEY }} + host: ${{ secrets.DEMO_SERVER_HOST }} + username: ${{ secrets.DEMO_SERVER_USER }} + key: ${{ secrets.DEMO_SERVER_SSH_KEY }} script: | cd checkmate git pull - cd docker/staging + cd docker/unified docker compose down docker compose pull docker compose up -d diff --git a/.gitignore b/.gitignore index 055e3d31e..ff828c1ec 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,8 @@ .VSCodeCounter *.sh mongo -timescaledb node_modules/ docs/architecture docs/reviews docs/todo -docs/frontend -docs/timescale \ No newline at end of file +docs/frontend \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 1535e84cf..63a349ae7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,20 +12,22 @@ Checkmate is an open-source uptime and infrastructure monitoring application. It ```bash cd client npm install -npm run dev # Start dev server at http://localhost:5173 +npm run dev -- --port 10001 --strictPort # Local dev port is 10001 (5173 is used by another project on this machine) npm run build # TypeScript check + production build npm run lint # ESLint (strict, max-warnings 0) npm run format # Prettier formatting npm run format-check # Check formatting ``` +Server `.env` on this machine is configured with `CLIENT_HOST="http://localhost:10001"` to match the client dev port. If you change the client port, keep `.env` in sync. + ### Server (Node.js/Express) ```bash cd server npm install npm run dev # Start with hot-reload (nodemon + tsx) at http://localhost:52345 npm run build # TypeScript compile + path alias resolution -npm run test # Run Mocha tests with c8 coverage +npm run test # Run Jest tests with coverage npm run lint # ESLint v9 npm run lint-fix # Auto-fix lint issues npm run format # Prettier formatting @@ -105,8 +107,78 @@ client/src/ - **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling) - **i18n**: i18next + react-i18next (translations via PoEditor) +--- + +## Backend Architecture Patterns + +### Repository Pattern & Separation of Concerns + +The backend enforces a strict three-layer separation between HTTP handling, business logic, and data access: + +``` +Request → Controller → Service → Repository → MongoDB (Mongoose) +``` + +- **Controllers** (`/controllers`) handle HTTP concerns only: parsing request params, calling the appropriate service, and returning a response via the `responseHandler` middleware. They contain no business logic. +- **Services** (`/service/business`) contain all business logic: deciding whether an incident should be created, whether a notification should fire, what state a monitor is in, etc. +- **Repositories** (`/repositories`) are the sole layer that talks to MongoDB through Mongoose. They expose clean, reusable query methods (e.g. `findByMonitorId`, `createCheck`) so that services never construct raw DB queries directly. + +This separation makes each layer independently testable and keeps Mongoose-specific code out of business logic. When adding a new feature, the pattern to follow is: add a repository method for any new DB query, call it from a service, and expose it via a controller route. + +### Monitoring Flow: From Check to Notification + +Background monitoring runs on a scheduled queue, not on the HTTP request cycle. The high-level flow for uptime monitoring is: + +``` +Pulse (cron) → BullMQ Job → StatusService + ├── performs HTTP/port/ping check + ├── saves Check via CheckRepository + ├── evaluates monitor state change + │ └── calls IncidentService (create / resolve incident) + └── calls NotificationService (email, Slack, Discord, webhook) +``` + +1. **Pulse** (cron scheduler) enqueues a job into a **BullMQ** queue for each active monitor at its configured interval. +2. A **BullMQ worker** picks up the job and calls `StatusService`, which performs the actual check (HTTP request, TCP port probe, ping, etc.). +3. The result is persisted as a `Check` document via the repository layer. +4. `StatusService` compares the new result against the monitor's previous state. If the monitor transitions from up → down (or down → up), it delegates to `IncidentService` to open or resolve an `Incident` document. +5. On a state change, `NotificationService` reads the monitor's configured `Notification` documents and dispatches alerts to all enabled channels (email, Discord, Slack, webhooks). + +### Queue System (BullMQ + Redis) + +Redis serves two roles: job queue storage for BullMQ and ephemeral caching. BullMQ manages concurrency, retries, and backpressure for monitoring jobs, ensuring checks are processed reliably even under load. + +- Each monitor type (HTTP, port, ping, infrastructure) maps to its own queue worker so failures in one type don't block others. +- Job scheduling interval is driven by the `interval` field on the `Monitor` model. +- Failed jobs are retried with configurable backoff before being moved to a dead-letter state. +- Redis is also used to cache frequently read data (e.g. aggregated stats) to reduce MongoDB query pressure. + +When working on anything related to check scheduling, incident lifecycle, or notifications, trace the flow starting from the relevant BullMQ worker rather than from the controller layer. + +--- + ## Code Conventions +### Coding conventions (mandatory) +Read `docs/coding-conventions.md` before touching any `.tsx` or `.ts` file. The doc covers both frontend and backend rules, all enforced in code review. + +**Universal — rule 0:** look at peer files first and follow the established pattern. Don't sneak in novel shapes. + +**Frontend (`client/src`):** +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 @@ -124,12 +196,13 @@ t('your.key') // Never hardcode UI strings - Both use ESLint with strict settings ### Testing -Server tests use Mocha + Chai + Sinon: +Server tests use Jest (with `--experimental-vm-modules` for ESM): ```bash -npm test # Run all tests with coverage -npm test -- --grep "pattern" # Run specific tests +npm test # Run all tests with coverage +npm test -- -t "pattern" # Run tests matching name pattern +npm test -- path/to/file.test.ts # Run a specific file ``` -Test files: `server/tests/**/*.test.js` +Test files: `server/test/**/*.test.ts` ## Database Models @@ -142,4 +215,4 @@ Key Mongoose models in `/server/src/db/models/`: - **StatusPage** - Public status pages - **Notification** - Alert configuration (email, Discord, Slack, webhooks) - **MaintenanceWindow** - Scheduled maintenance periods -- **AppSettings** - Global application settings +- **AppSettings** - Global application settings \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 5421ce769..015470a27 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,69 +1,69 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in the **bluewave-uptime** project and community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community, especially during Hacktoberfest, where we encourage new contributors to join and feel supported. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our community include: - -- Demonstrating empathy and kindness toward other people -- Being respectful of differing opinions, viewpoints, and experiences -- Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -- Focusing on what is best not just for us as individuals, but for the overall community - -Examples of unacceptable behavior include: - -- The use of sexualized language or imagery, and sexual attention or advances of any kind -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or email address, without their explicit permission -- Other conduct which could reasonably be considered inappropriate in a professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. - -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, especially during Hacktoberfest to ensure a positive experience for all contributors. - -## Scope - -This Code of Conduct applies within all project spaces, and it also applies when an individual is officially representing the project or its community in public spaces. Examples of representing our project include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@bluewavelabs.ca. All complaints will be reviewed and investigated promptly and fairly. - -All project team members are obligated to respect the privacy and security of the reporter of any incident. - -## Enforcement Guidelines - -Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction -**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. -**Consequence**: A private, written warning from project maintainers, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. - -### 2. Warning -**Community Impact**: A violation through a single incident or series of actions. -**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. - -### 3. Temporary Ban -**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. -**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban -**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. -**Consequence**: A permanent ban from any sort of public interaction within the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - ---- - -By following this Code of Conduct, we can ensure a welcoming and inclusive environment, encouraging new contributors to engage with the project and the community. Thank you for helping make **bluewave-uptime** a positive space for all! +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in the **checkmate** project and community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community, especially during Hacktoberfest, where we encourage new contributors to join and feel supported. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, especially during Hacktoberfest to ensure a positive experience for all contributors. + +## Scope + +This Code of Conduct applies within all project spaces, and it also applies when an individual is officially representing the project or its community in public spaces. Examples of representing our project include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@bluewavelabs.ca. All complaints will be reviewed and investigated promptly and fairly. + +All project team members are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. +**Consequence**: A private, written warning from project maintainers, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning +**Community Impact**: A violation through a single incident or series of actions. +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +--- + +By following this Code of Conduct, we can ensure a welcoming and inclusive environment, encouraging new contributors to engage with the project and the community. Thank you for helping make **checkmate** a positive space for all! diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 656523f88..17ad26903 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ PS: **We work closely with contributors on our [Discord channel](https://discord Before you dive in, make sure you have these installed: ```bash -# Check Node.js (v16+ required) +# Check Node.js (v20+ required) node --version # Check npm @@ -96,10 +96,16 @@ cd docker/dev Build the Docker images: +Linux: ```bash ./build_images.sh ``` +Windows: +```bash +./build_images.bat +``` + Run MongoDB container: ```bash @@ -234,7 +240,7 @@ docker rm uptime_database_mongo **Need more help?** -- Check the [full documentation](https://docs.checkmate.so) +- Check the [full documentation](https://checkmate.so/docs) - Ask on [Discord](https://discord.com/invite/NAb6H3UTjK) ### Start contributing code? @@ -249,20 +255,6 @@ docker rm uptime_database_mongo Start with [good first issues](https://github.com/bluewave-labs/checkmate/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). -### Improve the documentation? - -Docs live in [checkmate-documentation](https://github.com/bluewave-labs/checkmate-documentation). You can fix typos, add guides, or explain features better. - -### Help with translations? - -We use [PoEditor](https://poeditor.com) for translations. You can: - -- [Sign up and join your language team](https://poeditor.com/join/project/lRUoGZFCsJ). -- Translate UI strings. -- Ask questions on Discord in the relevant #translations channel. - -Make sure all new UI strings in code use `t('key')`. - ### Submit a pull request? Follow the [pull request checklist](#pull-request-checklist). Your PR should: @@ -277,7 +269,7 @@ Follow the [pull request checklist](#pull-request-checklist). Your PR should: ## Code guidelines -- Use ESLint and Prettier (`npm run lint`). +- Use ESLint and Prettier. Run `npm run lint` and `npm run format-check` in both `client` and `server`. If `format-check` reports issues, fix them with `npm run format` before committing. - Follow naming conventions: `camelCase` for variables, `PascalCase` for components, `UPPER_CASE` for constants. - No hard-coded strings — use `t('your.key')` for everything visible. - Use the shared theme and components. No magic numbers or hardcoded styles. @@ -298,7 +290,7 @@ Before submitting your pull request, please confirm the following: - You used the shared theme for any styling — no magic numbers or inline styles. - The pull request addresses only one issue or topic. - You added screenshots or a video for any UI-related changes. -- Your code passes linting and has no TypeScript errors. +- Your code passes `npm run lint`, `npm run format-check`, and `npm run build` in both `client` and `server` with no errors. If one or more of these are missing, we may ask you to update your pull request before we can merge it. @@ -310,7 +302,7 @@ If one or more of these are missing, we may ask you to update your pull request - `master` is used for stable releases. - Use descriptive branch names, like `fix/login-error` or `feat/add-alerts`. - Make sure that you are using the latest version. -- Make sure you run the code locally. The Checkmate [documentation](https://docs.checkmate.so) covers it. +- Make sure you run the code locally. The Checkmate [documentation](https://checkmate.so/docs) covers it. - Find out if the functionality is already covered, maybe by an individual configuration. - Perform a [search](/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. diff --git a/PULLREQUESTS.md b/PULLREQUESTS.md index d1ee8eea9..71f3e7e0a 100644 --- a/PULLREQUESTS.md +++ b/PULLREQUESTS.md @@ -33,4 +33,4 @@ Ensure you execute `npm run format` before submitting your pull requests in both ### Keep PRs focused -It may be tempting to address a bug you suddenly remembered or make some tiny adjustments in some component that bothers you, but don’t! Keep all commits in your pull request fully focused on the specific feature you are working on. Open up another PR if you want to fix a big or work on another feature. +It may be tempting to address a bug you suddenly remembered or make some tiny adjustments in some component that bothers you, but don’t! Keep all commits in your pull request fully focused on the specific feature you are working on. Open up another PR if you want to fix a bug or work on another feature. diff --git a/README.es.md b/README.es.md index 21909b2ed..92237ea3c 100644 --- a/README.es.md +++ b/README.es.md @@ -49,11 +49,11 @@ Puedes ver la última versión de [Checkmate](https://checkmate-demo.bluewavelab ## Guía del usuario -Las instrucciones de uso se pueden encontrar [aquí](https://docs.checkmate.so/checkmate-2.1). 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! :) +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://docs.checkmate.so/checkmate-2.1/users-guide/quickstart). +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. @@ -76,7 +76,7 @@ También puedes ver el consumo de memoria de MongoDB y Redis en el mismo servido 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/bluewave-uptime/discussions) +- [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! @@ -147,7 +147,7 @@ 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://docs.checkmate.so/checkmate-2.1/developers-guide/general-project-structure) y la [visión general](https://bluewavelabs.gitbook.io/checkmate/developers-guide/high-level-overview). +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. diff --git a/README.md b/README.md index 263d41436..135d3fa6e 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,18 @@ ![](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

An open source uptime and infrastructure monitoring application

-image +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) + +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. @@ -22,14 +26,11 @@ 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) - [🔗 User's guide](#users-guide) - [🛠️ Installation](#installation) -- [🏁 Translations](#translations) - [🚀 Performance](#performance) - [💚 Questions & Ideas](#questions--ideas) - [🧩 Features](#features) @@ -37,12 +38,13 @@ 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://checkmate-demo.bluewavelabs.ca/) 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). +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 @@ -56,7 +58,7 @@ Usage instructions can be found [here](https://checkmate.so/docs). See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs). -Alternatively, you can also use [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions. +Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions. ### Using a Custom CA @@ -64,11 +66,6 @@ If you need to monitor internal HTTPS endpoints with certificates from private C For more documentation, see the [docs directory](./docs/). - -## Translations - -If you would like to use Checkmate in your language, please [go to this page](https://poeditor.com/join/project/lRUoGZFCsJ) and register for the language you would like to translate Checkmate to. - ## 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: @@ -91,30 +88,26 @@ 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 +## Monitor Lifecycle -If you would like to sponsor an additional feature, [see this page](https://checkmate.so/sponsored-features). +1. A monitor executes a check (HTTP / ping / port / hardware via Capture agent) +2. The result is stored (success/failure + response time) +3. Recent check results are evaluated against the monitor's configured status change threshold +4. If the monitor's status change threshold is met and the current status is not equal to the previous status, the monitor's state changes (e.g. `initializing`, `up`, `down`, `breached`) +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 @@ -173,8 +166,3 @@ Here's how you can contribute: [![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) -## Our sponsors - -Thanks to [Gitbook](https://gitbook.io/) for giving us a free tier for their documentation platform, and [Poeditor](https://poeditor.com/) providing us a free account to use their i18n services. If you would like to sponsor Checkmate, please send an email to hello@bluewavelabs.ca - -If you would like to sponsor a feature, [see this page](https://checkmate.so/sponsored-features). diff --git a/client/.eslintrc.cjs b/client/.eslintrc.cjs index 86d523a6f..f8bd3d31f 100644 --- a/client/.eslintrc.cjs +++ b/client/.eslintrc.cjs @@ -1,3 +1,104 @@ +// MUI sx keys that have a native (system) prop equivalent. +// Flagged when found inside an sx={{ ... }} object literal — see frontend-conventions.md rule #1. +const SX_NATIVE_EQUIVALENT_KEYS = [ + "color", + "bgcolor", + "backgroundColor", + "borderRadius", + "border", + "borderTop", + "borderBottom", + "borderLeft", + "borderRight", + "width", + "height", + "minHeight", + "maxWidth", + "minWidth", + "maxHeight", + "p", + "pt", + "pb", + "px", + "py", + "pl", + "pr", + "m", + "mt", + "mb", + "mx", + "my", + "ml", + "mr", + "display", + "textAlign", + "fontWeight", + "fontSize", + "lineHeight", + "gap", +]; + +// MUI components that accept system props as top-level attributes. +// sx={{ color, p, mt, ... }} on these can be hoisted into native props. +// Dialog*, Drawer*, Menu*, Tab*, Toolbar etc. only accept sx — don't flag those. +const SX_HOISTABLE_COMPONENTS = [ + "Box", + "Stack", + "Typography", + "Grid", + "Container", + "Paper", + "Card", + "CardContent", + "CardActions", + "CardHeader", + "Divider", + "Link", + "Chip", +]; + +const sxKeySelector = SX_HOISTABLE_COMPONENTS.flatMap((component) => + SX_NATIVE_EQUIVALENT_KEYS.map( + (k) => + `JSXOpeningElement[name.name='${component}'] > JSXAttribute[name.name='sx'] > JSXExpressionContainer > ObjectExpression > Property[key.name='${k}']` + ) +).join(", "); + +const FRONTEND_CONVENTION_RULES = { + // Rule #2 — color/bgcolor/borderColor as a string shorthand like "text.secondary". + // Catches: — should be color={theme.palette.text.secondary}. + "no-restricted-syntax": [ + "warn", + { + selector: + "JSXAttribute[name.name=/^(color|bgcolor|borderColor)$/] > Literal[value=/\\./]", + message: + 'Use the full theme path instead of a string shorthand. e.g. color={theme.palette.text.secondary} not color="text.secondary". See docs/frontend-conventions.md rule #2.', + }, + { + // Rule #1 — sx={{ key: ... }} where key has a native prop equivalent. + selector: sxKeySelector, + message: + "Hoist this key out of `sx` onto the component as a native MUI prop (e.g. color={...}, mt={...}, width={...}). See docs/frontend-conventions.md rule #1.", + }, + { + // Rule #4 — importing the singleton theme from the Theme module. + // Catches: import { theme } from "@/Utils/Theme/Theme" — should be useTheme() inside the component. + selector: + "ImportDeclaration[source.value='@/Utils/Theme/Theme'] > ImportSpecifier[imported.name='theme']", + message: + "Don't import { theme } from @/Utils/Theme/Theme — use the useTheme() hook inside the component. See docs/frontend-conventions.md rule #4.", + }, + { + // Rule #3 (subset) — Typography fontSize={13} is the body default; drop it. + selector: + "JSXOpeningElement[name.name='Typography'] JSXAttribute[name.name='fontSize'] > JSXExpressionContainer > Literal[value=13]", + message: + "fontSize={13} is the Typography body default — drop the prop. For other sizes use typographyLevels tokens. See docs/frontend-conventions.md rule #3.", + }, + ], +}; + module.exports = { root: true, env: { browser: true, es2020: true }, @@ -15,9 +116,43 @@ module.exports = { "react/jsx-no-target-blank": "off", "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react/no-unescaped-entities": "off", + ...FRONTEND_CONVENTION_RULES, }, globals: { __APP_VERSION__: "readonly", process: "readonly", }, + overrides: [ + { + files: ["**/*.ts", "**/*.tsx"], + parser: "@typescript-eslint/parser", + parserOptions: { ecmaVersion: "latest", sourceType: "module" }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + ], + plugins: ["@typescript-eslint", "react-refresh"], + rules: { + "react/jsx-no-target-blank": "off", + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "react/no-unescaped-entities": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + // TS handles these; eslint duplicates create noise on existing files. + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/no-non-null-assertion": "off", + ...FRONTEND_CONVENTION_RULES, + }, + }, + ], }; diff --git a/client/NOTICE b/client/NOTICE new file mode 100644 index 000000000..a72e9c36f --- /dev/null +++ b/client/NOTICE @@ -0,0 +1,18 @@ +Checkmate +Copyright (c) BlueWave Labs + +This product includes assets developed by third parties. + +---------------------------------------------------------------------- + +King chess-piece silhouette used in the Checkmate logo +(client/src/assets/icons/checkmate-icon.svg and + client/public/checkmate_favicon.svg) +is adapted from "Chess klt45.svg" by Cburnett, available at: + https://commons.wikimedia.org/wiki/File:Chess_klt45.svg +Licensed under Creative Commons Attribution-ShareAlike 3.0 Unported +(CC BY-SA 3.0): https://creativecommons.org/licenses/by-sa/3.0/ + +The original artwork has been recolored from the white-with-black-stroke +chess piece to the Checkmate brand green (#13715B) for use as a logo. +Modifications: stroke color change only; geometry unchanged. diff --git a/client/package-lock.json b/client/package-lock.json index 7462d95d7..e2f33e645 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "0.0.0", + "version": "3.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "0.0.0", + "version": "3.7.1", "dependencies": { "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", @@ -18,9 +18,8 @@ "@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", - "flag-icons": "7.3.2", "html2canvas": "^1.4.1", "human-interval": "2.0.1", "i18next": "25.4.2", @@ -50,6 +49,8 @@ "@types/node": "24.5.2", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^4.2.1", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.1", @@ -58,7 +59,7 @@ "lint-staged": "^16.2.7", "prettier": "^3.3.3", "typescript": "5.9.3", - "vite": "6.4.1" + "vite": "^6.4.2" } }, "node_modules/@babel/code-frame": { @@ -2351,6 +2352,238 @@ "version": "0.0.6", "license": "MIT" }, + "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", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "dev": true, @@ -2558,6 +2791,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "dev": true, @@ -2682,14 +2925,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": { @@ -2996,9 +3239,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" @@ -3273,6 +3516,19 @@ "node": ">=6" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "3.0.0", "dev": true, @@ -3815,6 +4071,36 @@ "node": ">=6.0.0" } }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "dev": true, @@ -3891,10 +4177,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/flag-icons": { - "version": "7.3.2", - "license": "MIT" - }, "node_modules/flat-cache": { "version": "3.2.0", "dev": true, @@ -3909,12 +4191,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", @@ -4175,6 +4461,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "license": "MIT", @@ -5010,7 +5317,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": { @@ -5142,6 +5451,16 @@ "node": ">= 0.4" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5157,9 +5476,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": { @@ -5568,7 +5887,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" @@ -5599,7 +5920,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", @@ -5691,14 +6014,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", @@ -6398,6 +6726,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", @@ -6798,6 +7136,19 @@ "node": ">=8.0" } }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "license": "0BSD" @@ -7029,9 +7380,9 @@ } }, "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", "dependencies": { "esbuild": "^0.25.0", @@ -7312,9 +7663,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 2e6b5cfcb..8f7e70820 100644 --- a/client/package.json +++ b/client/package.json @@ -1,13 +1,14 @@ { "name": "client", "private": true, - "version": "0.0.0", + "version": "3.7.1", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "build-dev": "tsc -b && vite build --mode development", - "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "lint": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives", + "lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "format": "prettier --write .", "format-check": "prettier --check ." @@ -26,9 +27,8 @@ "@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", - "flag-icons": "7.3.2", "html2canvas": "^1.4.1", "human-interval": "2.0.1", "i18next": "25.4.2", @@ -58,6 +58,8 @@ "@types/node": "24.5.2", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^4.2.1", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.1", @@ -66,6 +68,6 @@ "lint-staged": "^16.2.7", "prettier": "^3.3.3", "typescript": "5.9.3", - "vite": "6.4.1" + "vite": "^6.4.2" } } diff --git a/client/public/checkmate_favicon.svg b/client/public/checkmate_favicon.svg index e0e6bf6f4..e1701573a 100644 --- a/client/public/checkmate_favicon.svg +++ b/client/public/checkmate_favicon.svg @@ -1,49 +1,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + Checkmate + King silhouette adapted from Cburnett's chess set (Wikimedia Commons, CC BY-SA 3.0). + + + + + + diff --git a/client/scripts/migrate-locales.ts b/client/scripts/migrate-locales.ts new file mode 100644 index 000000000..721321882 --- /dev/null +++ b/client/scripts/migrate-locales.ts @@ -0,0 +1,258 @@ +/** + * Locale Migration Script + * + * Restructures all non-English locale files to match en.json's nested key structure. + * Recovers translations via safe key-matching strategies, uses English placeholders + * for unmatched keys. + * + * Usage: npx tsx client/scripts/migrate-locales.ts + * + * SAFETY: en.json is NEVER modified. Originals are backed up before overwriting. + */ + +import fs from "fs"; +import path from "path"; + +const LOCALES_DIR = path.resolve(import.meta.dirname, "../src/locales"); +const BACKUP_DIR = path.join(LOCALES_DIR, "backup"); +const REPORT_PATH = path.join(LOCALES_DIR, "migration-report.json"); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Flatten a nested object into { "a.b.c": value } pairs */ +function flattenKeys(obj: Record, prefix = ""): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const fullPath = prefix ? `${prefix}.${key}` : key; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + Object.assign(result, flattenKeys(value as Record, fullPath)); + } else { + result[fullPath] = String(value); + } + } + return result; +} + +/** Set a value at a dot-separated path in a nested object */ +function setNestedValue( + obj: Record, + keyPath: string, + value: string +): void { + const parts = keyPath.split("."); + let current = obj; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!(part in current) || typeof current[part] !== "object") { + current[part] = {}; + } + current = current[part] as Record; + } + current[parts[parts.length - 1]] = value; +} + +// ── Build mapping indexes from en.json ─────────────────────────────────────── + +function buildIndexes(enFlat: Record) { + const enKeys = Object.keys(enFlat); + + // last segment -> en key paths + const lastSegIndex: Record = {}; + // last 2 segments -> en key paths + const suffixIndex: Record = {}; + + for (const keyPath of enKeys) { + const parts = keyPath.split("."); + const lastSeg = parts[parts.length - 1].toLowerCase(); + if (!lastSegIndex[lastSeg]) lastSegIndex[lastSeg] = []; + lastSegIndex[lastSeg].push(keyPath); + + if (parts.length >= 2) { + const suffix = parts.slice(-2).join(".").toLowerCase(); + if (!suffixIndex[suffix]) suffixIndex[suffix] = []; + suffixIndex[suffix].push(keyPath); + } + } + + return { lastSegIndex, suffixIndex }; +} + +// ── Mapping logic ──────────────────────────────────────────────────────────── + +type MatchStrategy = "exact" | "suffix-2" | "unique-name" | "english-placeholder"; + +interface KeyMatch { + enKey: string; + value: string; + strategy: MatchStrategy; +} + +function buildMigratedLocale( + enFlat: Record, + langFlat: Record, + indexes: ReturnType +): { matches: KeyMatch[]; result: Record } { + const { lastSegIndex, suffixIndex } = indexes; + const matches: KeyMatch[] = []; + const result: Record = {}; + + // Build reverse lookup: for each orphan key in lang, index by last segment and suffix + const langByLastSeg: Record = {}; + const langBySuffix: Record = {}; + + for (const [langKey, langValue] of Object.entries(langFlat)) { + const parts = langKey.split("."); + const lastSeg = parts[parts.length - 1].toLowerCase(); + if (!langByLastSeg[lastSeg]) langByLastSeg[lastSeg] = []; + langByLastSeg[lastSeg].push({ key: langKey, value: langValue }); + + if (parts.length >= 2) { + const suffix = parts.slice(-2).join(".").toLowerCase(); + if (!langBySuffix[suffix]) langBySuffix[suffix] = []; + langBySuffix[suffix].push({ key: langKey, value: langValue }); + } + } + + for (const enKey of Object.keys(enFlat)) { + const enValue = enFlat[enKey]; + const parts = enKey.split("."); + const lastSeg = parts[parts.length - 1].toLowerCase(); + const suffix = parts.length >= 2 ? parts.slice(-2).join(".").toLowerCase() : null; + + // Strategy 1: Exact path match + if (langFlat[enKey] !== undefined) { + const value = langFlat[enKey]; + setNestedValue(result, enKey, value); + matches.push({ enKey, value, strategy: "exact" }); + continue; + } + + // Strategy 2: 2-segment suffix match (lang key suffix matches en key suffix, both unique) + if (suffix) { + const enCandidates = suffixIndex[suffix]; + const langCandidates = langBySuffix[suffix]; + if ( + enCandidates && + enCandidates.length === 1 && + langCandidates && + langCandidates.length === 1 + ) { + const value = langCandidates[0].value; + setNestedValue(result, enKey, value); + matches.push({ enKey, value, strategy: "suffix-2" }); + continue; + } + } + + // Strategy 3: Unique last-segment match + const enCandidates = lastSegIndex[lastSeg]; + const langCandidates = langByLastSeg[lastSeg]; + if ( + enCandidates && + enCandidates.length === 1 && + langCandidates && + langCandidates.length === 1 + ) { + const value = langCandidates[0].value; + setNestedValue(result, enKey, value); + matches.push({ enKey, value, strategy: "unique-name" }); + continue; + } + + // No match — use English placeholder + setNestedValue(result, enKey, enValue); + matches.push({ enKey, value: enValue, strategy: "english-placeholder" }); + } + + return { matches, result }; +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +function main() { + // Load en.json + const enPath = path.join(LOCALES_DIR, "en.json"); + const en = JSON.parse(fs.readFileSync(enPath, "utf8")); + const enFlat = flattenKeys(en); + const enKeyCount = Object.keys(enFlat).length; + const indexes = buildIndexes(enFlat); + + console.log(`English: ${enKeyCount} keys (source of truth — NOT modified)\n`); + + // Find all non-English locale files + const localeFiles = fs + .readdirSync(LOCALES_DIR) + .filter( + (f: string) => + f.endsWith(".json") && f !== "en.json" && f !== "migration-report.json" + ); + + if (localeFiles.length === 0) { + console.log("No locale files to migrate."); + return; + } + + // Create backup directory + if (!fs.existsSync(BACKUP_DIR)) { + fs.mkdirSync(BACKUP_DIR, { recursive: true }); + } + + const report: Record< + string, + { + total: number; + exact: number; + suffix: number; + uniqueName: number; + placeholder: number; + } + > = {}; + + for (const file of localeFiles) { + const lang = file.replace(".json", ""); + const filePath = path.join(LOCALES_DIR, file); + + // Backup original + const backupPath = path.join(BACKUP_DIR, file); + fs.copyFileSync(filePath, backupPath); + + // Load and flatten + const langData = JSON.parse(fs.readFileSync(filePath, "utf8")); + const langFlat = flattenKeys(langData); + + // Migrate + const { matches, result } = buildMigratedLocale(enFlat, langFlat, indexes); + + // Write migrated file + fs.writeFileSync(filePath, JSON.stringify(result, null, "\t") + "\n", "utf8"); + + // Compute stats + const stats = { + total: enKeyCount, + exact: matches.filter((m) => m.strategy === "exact").length, + suffix: matches.filter((m) => m.strategy === "suffix-2").length, + uniqueName: matches.filter((m) => m.strategy === "unique-name").length, + placeholder: matches.filter((m) => m.strategy === "english-placeholder").length, + }; + report[lang] = stats; + + const translated = stats.exact + stats.suffix + stats.uniqueName; + const pct = ((translated / enKeyCount) * 100).toFixed(1); + console.log( + `${lang.padEnd(8)} ${String(translated).padStart(4)} translated (${pct}%) | ` + + `exact: ${stats.exact} suffix: ${stats.suffix} name: ${stats.uniqueName} ` + + `placeholder: ${stats.placeholder}` + ); + } + + // Write report + fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, "\t") + "\n", "utf8"); + + const totalLangs = localeFiles.length; + console.log( + `\nMigrated ${totalLangs} locale files. Originals backed up to locales/backup/` + ); + console.log(`Report written to ${REPORT_PATH}`); +} + +main(); diff --git a/client/src/App.tsx b/client/src/App.tsx index 15c94dbb5..5a1a36455 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -33,6 +33,14 @@ function App() { body: { backgroundColor: theme.palette.background.default, }, + ".Toastify__progress-bar": { + height: 2, + bottom: 0, + top: "auto", + }, + ".Toastify__progress-bar--bg": { + display: "none", + }, }} /> @@ -41,12 +49,23 @@ function App() { 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..ba2bc12ff 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[]; @@ -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; @@ -86,17 +90,17 @@ export const HistogramResponseTime = ({ {data.map((check, index) => { @@ -108,23 +112,23 @@ export const HistogramResponseTime = ({ : theme.palette.error.light; const bar = ( @@ -133,8 +137,8 @@ export const HistogramResponseTime = ({ return isPlaceholder ? ( {bar} diff --git a/client/src/Components/design-elements/BaseChart.tsx b/client/src/Components/design-elements/BaseChart.tsx index de87395eb..c6d8298a7 100644 --- a/client/src/Components/design-elements/BaseChart.tsx +++ b/client/src/Components/design-elements/BaseChart.tsx @@ -8,7 +8,7 @@ import type { ResponsiveStyleValue } from "@mui/system"; import { useTheme } from "@mui/material/styles"; type BaseChartProps = React.PropsWithChildren<{ - icon: React.ReactNode; + icon?: React.ReactNode; title: string; width?: number | string; maxWidth?: number | string; @@ -17,7 +17,6 @@ type BaseChartProps = React.PropsWithChildren<{ export const BaseChart = ({ children, - icon, title, width = "100%", maxWidth = "100%", @@ -38,34 +37,12 @@ export const BaseChart = ({ gap={theme.spacing(LAYOUT.MD)} flex={1} > - - {icon && ( - - {icon} - - )} - {title} - + {title} + {children} diff --git a/client/src/Components/design-elements/BasePage.tsx b/client/src/Components/design-elements/BasePage.tsx index 691e770dc..c955fb6b7 100644 --- a/client/src/Components/design-elements/BasePage.tsx +++ b/client/src/Components/design-elements/BasePage.tsx @@ -1,18 +1,19 @@ import Logo from "@/assets/icons/checkmate-icon.svg?react"; import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; -import Alert from "@mui/material/Alert"; import Link from "@mui/material/Link"; -import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { LAYOUT } from "@/Utils/Theme/constants"; import { ErrorFallback, EmptyFallback, EmptyMonitorFallback, - BaseFallback, } from "@/Components/design-elements/Fallback"; +import { EmptyState } from "@/Components/design-elements/EmptyState"; +import { NoticeBanner } from "@/Components/design-elements/NoticeBanner"; import { Breadcrumb } from "@/Components/design-elements/Breadcrumb"; +import { PageHeader } from "@/Components/design-elements/PageHeader"; import CircularProgress from "@mui/material/CircularProgress"; -import { HeaderAuthControls } from "@/Pages/Auth/components/HeaderAuthControls"; +import { LanguageSelector, SwitchTheme } from "@/Components/inputs"; import type { StackProps } from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; @@ -21,16 +22,15 @@ import { Link as RouterLink } from "react-router-dom"; import { Typography } from "@mui/material"; export const PageSpeedKeyPriorityFallback = () => { + const theme = useTheme(); + const { t } = useTranslation(); return ( - - - + { ), }} /> - - - + + } + /> ); }; @@ -55,6 +58,9 @@ interface BasePageProps extends StackProps { error?: boolean; children: React.ReactNode; breadcrumbOverride?: string[]; + headerKey?: string; + backTo?: string; + backLabel?: string; } export const BasePage = ({ @@ -62,9 +68,13 @@ export const BasePage = ({ error, children, breadcrumbOverride, + headerKey, + backTo, + backLabel, ...props }: BasePageProps) => { const theme = useTheme(); + const { t } = useTranslation(); if (loading) { return ( @@ -95,7 +105,16 @@ export const BasePage = ({ spacing={theme.spacing(LAYOUT.LG)} {...props} > - + {headerKey ? ( + + ) : ( + + )} {children} ); @@ -105,11 +124,12 @@ interface BasePageWithStatesProps extends StackProps { loading: boolean; error: any; totalCount: number; - bullets: string[] | unknown; + description?: string; page: string; actionButtonText: string; actionLink: string; children: React.ReactNode; + headerKey?: string; } export const BasePageWithStates = ({ @@ -117,10 +137,11 @@ export const BasePageWithStates = ({ error, totalCount, page, - bullets, + description, actionButtonText, actionLink, children, + headerKey, ...props }: BasePageWithStatesProps) => { const showLoading = loading && totalCount === 0; @@ -128,7 +149,7 @@ export const BasePageWithStates = ({ if (!loading && totalCount === 0) { return ( {children} @@ -155,6 +177,7 @@ interface MonitorBasePageWithStatesProps extends StackProps { actionLink?: string; children: React.ReactNode; priorityFallback?: React.ReactNode; + headerKey?: string; } export const MonitorBasePageWithStates = ({ @@ -165,6 +188,7 @@ export const MonitorBasePageWithStates = ({ actionLink, children, priorityFallback, + headerKey, ...props }: MonitorBasePageWithStatesProps) => { const { t } = useTranslation(); @@ -172,15 +196,7 @@ export const MonitorBasePageWithStates = ({ const showLoading = loading && totalCount === 0; if (priorityFallback) { - return ( - - {priorityFallback} - - ); + return <>{priorityFallback}; } if (!loading && totalCount === 0) { @@ -188,7 +204,7 @@ export const MonitorBasePageWithStates = ({ @@ -199,6 +215,7 @@ export const MonitorBasePageWithStates = ({ {children} @@ -215,51 +232,163 @@ export const BaseAuthPage = ({ title, subtitle, children, - ...props + component, + onSubmit, }: BaseAuthPageProps) => { const theme = useTheme(); + const { t } = useTranslation(); return ( - - - - - - - + - {title} - - {subtitle} + + + + + + + + + + {t("common.appName")} + + + + + {title} + + + {subtitle} + + + {children} + - {children} +
+ + + {t("pages.auth.brandPanel.eyebrow")} + + + {t("pages.auth.brandPanel.tagline")} + + + {t("pages.auth.brandPanel.description")} + + + + v{__APP_VERSION__} + - + ); }; diff --git a/client/src/Components/design-elements/EmptyState.tsx b/client/src/Components/design-elements/EmptyState.tsx new file mode 100644 index 000000000..a8850d516 --- /dev/null +++ b/client/src/Components/design-elements/EmptyState.tsx @@ -0,0 +1,158 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { Link as RouterLink } from "react-router-dom"; +import { Button } from "@/Components/inputs"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { EmptyStateIllustration } from "./EmptyStateIllustration"; + +import type { ReactNode } from "react"; + +interface EmptyStateProps { + title: string; + description?: ReactNode; + actionText?: string; + actionTo?: string; + onAction?: () => void; + secondaryText?: string; + secondaryHref?: string; + compact?: boolean; + fullscreen?: boolean; + alert?: ReactNode; + children?: ReactNode; +} + +export const EmptyState = ({ + title, + description, + actionText, + actionTo, + onAction, + secondaryText, + secondaryHref, + compact = false, + fullscreen = false, + alert, + children, +}: EmptyStateProps) => { + const theme = useTheme(); + + if (compact) { + return ( + + + + + {title} + + {description && ( + + {description} + + )} + + + ); + } + + return ( + + + + {title} + + {description && ( + + {description} + + )} + {alert && ( + + {alert} + + )} + {(actionText || secondaryText) && ( + + {actionText && (actionTo || onAction) && ( + + )} + {secondaryText && secondaryHref && ( + + {secondaryText} → + + )} + + )} + {children} + + ); +}; diff --git a/client/src/Components/design-elements/EmptyStateIllustration.tsx b/client/src/Components/design-elements/EmptyStateIllustration.tsx new file mode 100644 index 000000000..d4557bc9a --- /dev/null +++ b/client/src/Components/design-elements/EmptyStateIllustration.tsx @@ -0,0 +1,197 @@ +import { useTheme } from "@mui/material/styles"; + +interface EmptyStateIllustrationProps { + width?: number | string; + height?: number | string; +} + +const PALETTE = { + light: { + grid: "#CBD5E1", + backFill: "#F5F7FA", + backStroke: "#D5DBE3", + backInner: "#B6C2CF", + frontFill: "#EAF5F0", + frontStroke: "#7DBFAA", + frontInner: "#4DAF94", + }, + dark: { + grid: "#3A4452", + backFill: "#475569", + backStroke: "#64748B", + backInner: "#CBD5E1", + frontFill: "#0F5D4A", + frontStroke: "#4DAF94", + frontInner: "#B5E2D2", + }, +}; + +export const EmptyStateIllustration = ({ + width = 160, + height = 120, +}: EmptyStateIllustrationProps) => { + const theme = useTheme(); + const p = theme.palette.mode === "dark" ? PALETTE.dark : PALETTE.light; + return ( + + ); +}; diff --git a/client/src/Components/design-elements/Fallback.tsx b/client/src/Components/design-elements/Fallback.tsx index 003bda647..989c2bed4 100644 --- a/client/src/Components/design-elements/Fallback.tsx +++ b/client/src/Components/design-elements/Fallback.tsx @@ -1,12 +1,8 @@ import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import { BulletPointCheck, SkeletonCard } from "@/Components/design-elements"; -import { Button } from "@/Components/inputs"; -import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; - -import { useNavigate } from "react-router"; -import useMediaQuery from "@mui/material/useMediaQuery"; import { useTheme } from "@mui/material/styles"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { EmptyState } from "./EmptyState"; + import type { StackProps } from "@mui/material/Stack"; interface BaseFallbackProps extends StackProps { @@ -15,30 +11,13 @@ interface BaseFallbackProps extends StackProps { export const BaseFallback = ({ children, ...props }: BaseFallbackProps) => { const theme = useTheme(); - const isSmall = useMediaQuery(theme.breakpoints.down("md")); - return ( - {children} ); @@ -51,126 +30,56 @@ export const ErrorFallback = ({ title: string; subtitle: string; }) => { - const theme = useTheme(); return ( - - - {title} - - {subtitle} - + ); }; export const EmptyFallback = ({ title, - bullets, + description, actionButtonText, actionLink, }: { title: string; - bullets: any; + description?: string; actionButtonText: string; actionLink: string; }) => { - const theme = useTheme(); - const navigate = useNavigate(); return ( - - - {title} - - {Array.isArray(bullets) && - bullets?.map((bullet: string) => ( - - ))} - - - - - - + ); }; export const EmptyMonitorFallback = ({ - page, title, - bullets, + description, actionButtonText, actionLink, }: { page: string; title: string; - bullets: any; + description?: string; actionButtonText: string; actionLink: string; }) => { - const theme = useTheme(); - const navigate = useNavigate(); return ( - - - - {title} - - - {Array.isArray(bullets) && - bullets?.map((bullet: string, index: number) => ( - - ))} - - - - - - + ); }; diff --git a/client/src/Components/design-elements/Gauge.tsx b/client/src/Components/design-elements/Gauge.tsx index fda42df3c..a377a5925 100644 --- a/client/src/Components/design-elements/Gauge.tsx +++ b/client/src/Components/design-elements/Gauge.tsx @@ -114,6 +114,7 @@ export const DetailGauge = ({ upperValue, lowerLabel, lowerValue, + maxWidth = 225, }: { title: string; progress: number; @@ -121,13 +122,14 @@ export const DetailGauge = ({ upperValue?: string | number; lowerLabel?: string; lowerValue?: string | number; + maxWidth?: number; }) => { const theme = useTheme(); return ( { return ( { = { + info: "ⓘ", + warning: "⚠", + error: "✕", +}; + +interface NoticeBannerProps { + severity?: Severity; + children: React.ReactNode; +} + +export const NoticeBanner = ({ severity = "info", children }: NoticeBannerProps) => { + const theme = useTheme(); + const tone = theme.palette[severity].main; + return ( + + + {SEVERITY_GLYPH[severity]} + + + {children} + + + ); +}; diff --git a/client/src/Components/design-elements/PageHeader.tsx b/client/src/Components/design-elements/PageHeader.tsx new file mode 100644 index 000000000..84b0f61c6 --- /dev/null +++ b/client/src/Components/design-elements/PageHeader.tsx @@ -0,0 +1,164 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Box from "@mui/material/Box"; +import Tooltip from "@mui/material/Tooltip"; +import { Link as RouterLink } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material/styles"; +import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react"; + +import type { ReactNode } from "react"; + +const EXTERNAL_LINKS = { + support: "https://discord.com/invite/NAb6H3UTjK", + docs: "https://checkmate.so/docs", + changelog: "https://github.com/bluewave-labs/checkmate/releases", +}; + +interface PageHeaderProps { + title: string; + description?: ReactNode; + backTo?: string; + backLabel?: string; +} + +export const PageHeader = ({ + title, + description, + backTo, + backLabel, +}: PageHeaderProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + + const linkItems: { key: keyof typeof EXTERNAL_LINKS; icon: ReactNode }[] = [ + { + key: "support", + icon: ( + + ), + }, + { + key: "docs", + icon: ( + + ), + }, + { + key: "changelog", + icon: ( + + ), + }, + ]; + + return ( + + + {backTo && backLabel && ( + + + {backLabel} + + )} + + {title} + + {description && ( + + {description} + + )} + + + {linkItems.map((item, idx) => ( + + + {item.icon} + + + ))} + + + ); +}; diff --git a/client/src/Components/design-elements/SplitBox.tsx b/client/src/Components/design-elements/SplitBox.tsx index 08f245178..3cb0637b8 100644 --- a/client/src/Components/design-elements/SplitBox.tsx +++ b/client/src/Components/design-elements/SplitBox.tsx @@ -53,7 +53,7 @@ export const ConfigBox = ({ rightContent, }: { title: string; - subtitle: string; + subtitle: React.ReactNode; leftContent?: React.ReactNode; rightContent: React.ReactNode; }) => { @@ -63,9 +63,9 @@ export const ConfigBox = ({ left={ {title} diff --git a/client/src/Components/design-elements/StatBox.tsx b/client/src/Components/design-elements/StatBox.tsx index 01a8e9835..954120fe5 100644 --- a/client/src/Components/design-elements/StatBox.tsx +++ b/client/src/Components/design-elements/StatBox.tsx @@ -69,7 +69,12 @@ export const StatBox = ({ > - {title} + + {title} + {tooltip && ( { overflow: "hidden", position: "relative", flex: 1, - padding: theme.spacing(4), + padding: theme.spacing(6), ...sx, }} > @@ -65,9 +65,13 @@ const StatusBox = ({ {label} 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/Table.tsx b/client/src/Components/design-elements/Table.tsx index 505f79fea..a02f9be8f 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -18,10 +18,9 @@ import { ChevronsRight, ChevronLeft, ChevronRight, - Coffee, Ellipsis, - PartyPopper, } from "lucide-react"; +import { EmptyState } from "./EmptyState"; import TablePagination from "@mui/material/TablePagination"; import type { TablePaginationOwnProps } from "@mui/material/TablePagination"; @@ -36,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; }; @@ -110,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} @@ -218,7 +243,7 @@ export function DataTable< {headers.map((header, index) => { return ( header.onClick!(e, row) : undefined @@ -548,49 +573,18 @@ export const Pagination = ({ ...props }: PaginationProps) => { ); }; -const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) => { - const theme = useTheme(); +const EmptyView = ({ text }: { text?: string; positive?: boolean }) => { const { t } = useTranslation(); - const Icon = positive ? PartyPopper : Coffee; + const theme = useTheme(); return ( - - - - - - {text ?? t("common.table.empty")} - - + + ); }; diff --git a/client/src/Components/design-elements/Tooltip.tsx b/client/src/Components/design-elements/Tooltip.tsx index f83b1331c..f9bbbb92b 100644 --- a/client/src/Components/design-elements/Tooltip.tsx +++ b/client/src/Components/design-elements/Tooltip.tsx @@ -16,16 +16,15 @@ export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) => slotProps={{ tooltip: { sx: { - background: + backgroundColor: theme.palette.mode === "dark" - ? "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)" - : "linear-gradient(135deg, #2c3e50 0%, #1a252f 100%)", - backgroundColor: "transparent", - color: "#ffffff", + ? theme.palette.grey[900] + : theme.palette.grey[800], + color: theme.palette.common.white, fontSize: "13px", padding: `${theme.spacing(4)} ${theme.spacing(5)}`, borderRadius: `${theme.shape.borderRadius}px`, - boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)", + boxShadow: theme.shadows[6], }, }, }} diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index 45114116b..130ccd831 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -1,5 +1,8 @@ export * from "./Breadcrumb"; +export * from "./PageHeader"; export * from "./BasePage"; +export * from "./EmptyState"; +export * from "./EmptyStateIllustration"; export * from "./Fallback"; export * from "./SkeletonCard"; export * from "./BulletPointCheck"; @@ -19,6 +22,8 @@ export * from "./Gauge"; export * from "./Tabs"; export * from "./SplitBox"; export * from "./TextLink"; +export * from "./NoticeBanner"; export * from "./OfflineBanner"; export * from "./Avatar"; +export * from "./StatusCodeLabel"; export * from "./StrategyBadge"; diff --git a/client/src/Components/inputs/Button.tsx b/client/src/Components/inputs/Button.tsx index 54f776075..4eae654b2 100644 --- a/client/src/Components/inputs/Button.tsx +++ b/client/src/Components/inputs/Button.tsx @@ -1,19 +1,43 @@ import Button from "@mui/material/Button"; import type { ButtonProps } from "@mui/material/Button"; -import { useTheme } from "@mui/material/styles"; +import { useTheme, darken } from "@mui/material/styles"; +import { HOVER } from "@/Utils/Theme/constants"; + +const PALETTE_COLORS = [ + "primary", + "secondary", + "error", + "warning", + "info", + "success", +] as const; +type PaletteColor = (typeof PALETTE_COLORS)[number]; export const ButtonInput = ({ sx, ...props }: ButtonProps) => { const theme = useTheme(); - const outlinedSx = + + const hoverOverrides: Record = { boxShadow: "none" }; + + if (props.variant === "outlined") { + hoverOverrides.borderColor = theme.palette.text.secondary; + } + + if (props.variant === "contained") { + const colorKey = (props.color ?? "primary") as PaletteColor; + const palette = theme.palette[colorKey]; + if (palette && typeof palette === "object" && "main" in palette) { + hoverOverrides.backgroundColor = darken(palette.main, HOVER.DARKEN); + } + } + + const variantSx = props.variant === "outlined" ? { color: theme.palette.text.primary, borderColor: theme.palette.divider, - "&:hover": { - borderColor: theme.palette.text.secondary, - }, } : {}; + return ( } > - + { const { t } = useTranslation(); const navigate = useNavigate(); const isAdmin = useIsAdmin(); + const { pagedRows, paginationProps } = useClientPagination(users); const headers: Header[] = [ { @@ -51,10 +54,13 @@ export const TeamTable = ({ users }: TeamTableProps) => { }; return ( - + <> +
+ {users.length > 0 && } + ); }; diff --git a/client/src/Pages/Account/index.tsx b/client/src/Pages/Account/index.tsx index fbe8db64b..ac05cd519 100644 --- a/client/src/Pages/Account/index.tsx +++ b/client/src/Pages/Account/index.tsx @@ -25,7 +25,7 @@ const Account = ({ open = "profile" }: AccountProps) => { }, [open]); return ( - + setActiveTab(newValue)} diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx index ec95a0ce3..9de5f1df5 100644 --- a/client/src/Pages/Auth/Login/index.tsx +++ b/client/src/Pages/Auth/Login/index.tsx @@ -1,6 +1,7 @@ import { BaseAuthPage, TextLink } from "@/Components/design-elements"; import { Button, TextField } from "@/Components/inputs"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; @@ -9,7 +10,7 @@ import type { LoginFormData } from "@/Validation/login"; import { useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; import { setAuthState } from "@/Features/Auth/authSlice"; -import { usePost } from "@/Hooks/UseApi"; +import { useLazyGet, usePost } from "@/Hooks/UseApi"; const LoginPage = () => { const { t } = useTranslation(); @@ -17,6 +18,17 @@ const LoginPage = () => { const navigate = useNavigate(); const { post, loading } = usePost(); + const [isCheckingAdmin, setIsCheckingAdmin] = useState(true); + const { get } = useLazyGet(); + + useEffect(() => { + get("/auth/users/superadmin").then((res) => { + if (res?.data === false) navigate("/register", { replace: true }); + else setIsCheckingAdmin(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const { schema, defaults } = useLoginForm(); const { control, handleSubmit } = useForm({ @@ -24,6 +36,8 @@ const LoginPage = () => { defaultValues: defaults, }); + if (isCheckingAdmin) return null; + const onSubmit = async (data: LoginFormData) => { if (loading) return; @@ -82,12 +96,6 @@ const LoginPage = () => { linkText={t("pages.auth.login.links.forgotPassword.linkText")} href="/forgot-password" /> - ); }; diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index a2a439023..92c712bda 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -1,16 +1,17 @@ import { BaseAuthPage } from "@/Components/design-elements"; import { Button, TextField } from "@/Components/inputs"; +import Alert from "@mui/material/Alert"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useRegisterForm } from "@/Hooks/useRegisterForm"; import type { RegisterFormData } from "@/Validation/register"; import { useTranslation } from "react-i18next"; -import { usePost, useGet } from "@/Hooks/UseApi"; +import { useLazyGet, usePost } from "@/Hooks/UseApi"; import { setAuthState } from "@/Features/Auth/authSlice"; import { useDispatch } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; import type { AuthResponse } from "@/Types/User"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; interface RegisterPayload { user: Omit; @@ -32,21 +33,23 @@ const RegisterPage = () => { const { post: verifyToken } = usePost<{ token: string }, InviteVerifyResponse>(); const hasVerified = useRef(false); - const { data: superAdminExists, isLoading: isCheckingAdmin } = useGet( - token ? null : "/auth/users/superadmin" - ); + const [isCheckingAdmin, setIsCheckingAdmin] = useState(!token); + const { get } = useLazyGet(); + + useEffect(() => { + if (token) return; + get("/auth/users/superadmin").then((res) => { + if (res?.data === true) navigate("/login", { replace: true }); + else setIsCheckingAdmin(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const { control, handleSubmit, setError, reset } = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); - useEffect(() => { - if (superAdminExists === true) { - navigate("/login", { replace: true }); - } - }, [superAdminExists, navigate]); - useEffect(() => { if (!token || hasVerified.current) return; hasVerified.current = true; @@ -92,6 +95,23 @@ const RegisterPage = () => { title={t("pages.auth.register.title")} subtitle={t("pages.auth.register.subtitle")} > + {!token && ( + ({ + fontSize: 13, + lineHeight: 1.55, + color: theme.palette.text.secondary, + backgroundColor: theme.palette.action.hover, + border: `1px solid ${theme.palette.divider}`, + borderRadius: 1, + "& .MuiAlert-message": { padding: 0 }, + })} + > + {t("pages.auth.register.setupNotice")} + + )} state.ui.timezone); - const getHeaders = (t: Function, uiTimezone: string) => { + const getHeaders = () => { const headers: Header[] = [ { id: "monitorName", @@ -63,13 +67,10 @@ export const ChecksTable = ({ id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - const code = row.statusCode; - if (!code) return "N/A"; - const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative"; return ( - ); }, @@ -85,7 +86,7 @@ export const ChecksTable = ({ return headers; }; - const headers = getHeaders(t, uiTimezone); + const headers = getHeaders(); const handlePageChange = ( _e: React.MouseEvent | null, diff --git a/client/src/Pages/Checks/index.tsx b/client/src/Pages/Checks/index.tsx index 108547f3e..93364af44 100644 --- a/client/src/Pages/Checks/index.tsx +++ b/client/src/Pages/Checks/index.tsx @@ -1,6 +1,7 @@ import Stack from "@mui/material/Stack"; import { BasePage, + EmptyState, TotalChecksBox, UpChecksBox, DownChecksBox, @@ -102,8 +103,19 @@ const Checks = () => { const downChecks = summaryResponse?.downChecks || 0; const upChecks = totalChecks - (summaryResponse?.downChecks || 0); + const noMonitors = !isLoadingMonitors && (monitorsResponse?.length ?? 0) === 0; + if (noMonitors) { + return ( + + ); + } + return ( - + { "pages.createMonitor.form.type.optionWebSocketDescription" )} /> + )} @@ -370,7 +411,20 @@ const CreateMonitorPage = () => { + ), + }} + /> + } rightContent={ {/* URL/Host/Container field - not shown for hardware */} @@ -518,6 +572,74 @@ const CreateMonitorPage = () => { )} /> )} + {generalSettingsConfig.showDnsServer && ( + ( + + )} + /> + )} + {generalSettingsConfig.showDnsRecordType && ( + ( + + )} + /> + )} { /> { + const theme = useTheme(); + + return ( + + {children} + + ); +}; + +const Cell = ({ children }: { children: React.ReactNode }) => ( + {children} +); + export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => { const { t } = useTranslation(); const theme = useTheme(); @@ -28,27 +46,24 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => { if (!incident) { return null; } + return ( - - {t("pages.incidents.dialog.details.title")} - + {t("pages.incidents.dialog.details.title")} - - {t("pages.incidents.dialog.details.overview")} - - - + {t("pages.incidents.dialog.details.overview")} - {t("pages.incidents.dialog.details.status")} + + {t("pages.incidents.dialog.details.status")} + { {monitor && ( <> - {t("pages.incidents.dialog.details.monitor")} + + {t("pages.incidents.dialog.details.monitor")} + - {monitor.name ?? "N/A"} + {monitor.name ?? "N/A"} - {t("pages.incidents.dialog.details.url")} + {t("pages.incidents.dialog.details.url")} - {monitor.url ?? "N/A"} + {monitor.url ?? "N/A"} )} @@ -78,51 +95,53 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => { - - {t("pages.incidents.dialog.details.analysis")} - - + {t("pages.incidents.dialog.details.analysis")} - {t("pages.incidents.dialog.details.timeline")} + {t("pages.incidents.dialog.details.timeline")} - {t("pages.incidents.dialog.details.detailsLabel")} + {t("pages.incidents.dialog.details.detailsLabel")} - + - + - {t("pages.incidents.dialog.details.startedAt")} + {t("pages.incidents.dialog.details.startedAt")} - + {formatDateWithTz(incident.startTime, "D MMM YYYY, h:mm A", uiTimezone)} - + - {t("pages.incidents.dialog.details.statusCode")} + {t("pages.incidents.dialog.details.statusCode")} - {incident.statusCode ?? "N/A"} + + + - {t("pages.incidents.dialog.details.downtime")} + {t("pages.incidents.dialog.details.downtime")} - {getIncidentsDuration(incident)} + {getIncidentsDuration(incident)} - {t("pages.incidents.dialog.details.message")} + {t("pages.incidents.dialog.details.message")} - {incident.message ?? "N/A"} + {incident.message ?? "N/A"} @@ -130,60 +149,53 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => { {!incident.status && ( - + {t("pages.incidents.dialog.details.resolutionDetails")} - - + - {t("pages.incidents.dialog.details.resolvedAt")} + {t("pages.incidents.dialog.details.resolvedAt")} - + {incident.endTime ? formatDateWithTz(incident.endTime, "D MMM YYYY, h:mm A", uiTimezone) : "N/A"} - + - - {t("pages.incidents.dialog.details.resolutionType")} - + {t("pages.incidents.dialog.details.resolutionType")} - + {incident.resolutionType ? t( `pages.incidents.dialog.details.resolutionTypes.${incident.resolutionType}` ) : "N/A"} - + {incident.resolvedBy && ( <> - - {t("pages.incidents.dialog.details.resolvedBy")} - + {t("pages.incidents.dialog.details.resolvedBy")} - - {incident.resolvedByEmail ?? incident.resolvedBy} - + {incident.resolvedByEmail ?? incident.resolvedBy} )} {incident.comment && ( <> - {t("pages.incidents.dialog.details.comment")} + {t("pages.incidents.dialog.details.comment")} - {incident.comment} + {incident.comment} )} diff --git a/client/src/Pages/Incidents/Components/CardSummary.tsx b/client/src/Pages/Incidents/Components/CardSummary.tsx index 938455808..60dadaa74 100644 --- a/client/src/Pages/Incidents/Components/CardSummary.tsx +++ b/client/src/Pages/Incidents/Components/CardSummary.tsx @@ -1,8 +1,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import Divider from "@mui/material/Divider"; import Grid from "@mui/material/Grid"; -import { BaseBox, ValueLabel } from "@/Components/design-elements"; +import { BaseBox, Icon, ValueLabel } from "@/Components/design-elements"; import { CircleCheck, TriangleAlert, Bell, Wrench, Globe } from "lucide-react"; import Box from "@mui/material/Box"; @@ -25,6 +24,7 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => { alignItems="center" justifyContent="space-between" gap={theme.spacing(2)} + minHeight={32} > { gap={theme.spacing(2)} > {icon} - {label} + {label} {value} @@ -67,15 +67,11 @@ export const SummaryCard = ({ > {title} - {children} @@ -97,10 +93,13 @@ export const SummaryCardActiveIncidents = ({ const activeCount = summary.totalActive; const hasActive = activeCount > 0; const color = hasActive ? theme.palette.error.main : theme.palette.success.main; - const icon = hasActive ? ( - - ) : ( - + const icon = ( + + + ); const msg = t("pages.incidents.summaryCard.activeIncidents.active", { count: activeCount, @@ -133,10 +132,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) => container alignItems="center" spacing={2} - sx={{ - width: "100%", - py: theme.spacing(0.5), - }} + sx={{ width: "100%", minHeight: 32 }} > gap: theme.spacing(2), }} > - + size={{ xs: 12, md: 6, lg: 4 }} sx={{ textAlign: { xs: "left", md: "right" }, - fontWeight: 500, + fontWeight: 600, }} > {duration} @@ -190,22 +184,17 @@ export const SummaryCardLatestIncidents = ({ summary, }: SummaryCardLatestIncidentsProps) => { const { t } = useTranslation(); - const theme = useTheme(); const latestIncidents = summary?.latestIncidents ?? []; return ( - - {latestIncidents.slice(0, 3).map((incident, index) => ( - - - {index < latestIncidents.length - 1 && ( - - )} - - ))} - + {latestIncidents.slice(0, 3).map((incident) => ( + + ))} ); }; @@ -224,17 +213,17 @@ export const SummaryCardStats = ({ summary }: SummaryCardStatsProps) => { return ( } + icon={} label={t("pages.incidents.summaryCard.incidentStats.totalIncidents")} value={summary?.total || 0} /> } + icon={} label={t("pages.incidents.summaryCard.incidentStats.mostAffectedMonitor")} value={mostAffected} /> } + icon={} label={t("pages.incidents.summaryCard.incidentStats.avgResolutionTime")} value={ summary.total > 0 diff --git a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx index a0f4eb15a..af7cba105 100644 --- a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx +++ b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx @@ -75,7 +75,7 @@ export const ControlsIncidentFilter = ({ variant="contained" onClick={onClearFilters} > - {t("pages.incidents.filters.clearFilters")} + {t("common.buttons.clearFilters")} )} diff --git a/client/src/Pages/Incidents/Components/DialogResolution.tsx b/client/src/Pages/Incidents/Components/DialogResolution.tsx index 99dd898dd..de5a9c4d5 100644 --- a/client/src/Pages/Incidents/Components/DialogResolution.tsx +++ b/client/src/Pages/Incidents/Components/DialogResolution.tsx @@ -1,9 +1,7 @@ import { useState, useEffect } from "react"; -import Box from "@mui/material/Box"; import { Dialog, TextField } from "@/Components/inputs"; import { usePut } from "@/Hooks/UseApi"; import { useTranslation } from "react-i18next"; -import { useTheme } from "@mui/material"; interface DialogResolutionProps { open: boolean; @@ -19,7 +17,6 @@ export const DialogResolution = ({ onResolved, }: DialogResolutionProps) => { const { t } = useTranslation(); - const theme = useTheme(); const [comment, setComment] = useState(""); const { put: resolveIncident, loading: isResolving } = usePut(); @@ -50,22 +47,19 @@ export const DialogResolution = ({ onCancel={handleCancel} onConfirm={handleConfirm} confirmColor="error" - cancelColor="primary" loading={isResolving} maxWidth="sm" fullWidth > - - setComment(e.target.value)} - fullWidth - /> - + setComment(e.target.value)} + fullWidth + /> ); }; 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/Incidents/index.tsx b/client/src/Pages/Incidents/index.tsx index 9003f6956..63cd18889 100644 --- a/client/src/Pages/Incidents/index.tsx +++ b/client/src/Pages/Incidents/index.tsx @@ -1,4 +1,4 @@ -import { BasePage } from "@/Components/design-elements"; +import { BasePage, EmptyState } from "@/Components/design-elements"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Divider from "@mui/material/Divider"; @@ -15,6 +15,7 @@ import { HeaderTimeRange } from "@/Components/common"; import { ControlsIncidentFilter } from "@/Pages/Incidents/Components/ControlsIncidentFilter"; import { useGet } from "@/Hooks/UseApi"; +import { LAYOUT } from "@/Utils/Theme/constants"; import { useState, useEffect, useMemo } from "react"; import { useParams } from "react-router-dom"; import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident"; @@ -153,8 +154,20 @@ const IncidentsPage = () => { refetchSummary(); }; + const trulyEmpty = summaryData !== undefined && (summaryData?.total ?? 0) === 0; + + if (trulyEmpty) { + return ( + + ); + } + return ( - + { setSelectedResolutionType={setFilter} onClearFilters={handleClearFilters} /> - {activeIncidentsCount > 0 ? ( + {activeIncidentsCount > 0 && ( <> {t("pages.incidents.table.activeIncidents")} @@ -193,18 +207,12 @@ const IncidentsPage = () => { /> - ) : ( - - {t("pages.incidents.summaryCard.activeIncidents.active_zero")} - )} {t("pages.incidents.table.resolvedIncidents")} diff --git a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx index 54e3cc1ed..c7ebac5be 100644 --- a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx @@ -1,4 +1,6 @@ import Grid from "@mui/material/Grid"; +import Stack from "@mui/material/Stack"; +import { NoticeBanner } from "@/Components/design-elements"; import { HistogramInfrastructure } from "@/Components/monitors"; import { useTranslation } from "react-i18next"; @@ -6,6 +8,7 @@ import type { HardwareCheckStats } from "@/Types/Monitor"; import { useMemo } from "react"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; +import { LAYOUT } from "@/Utils/Theme/constants"; const formatBytesToMB = (value: number) => `${(value / (1024 * 1024)).toFixed(2)} MB`; @@ -60,6 +63,11 @@ const getChartConfigs = ( return configs; }; +const hasAnyNetworkTraffic = (checks: HardwareCheckStats[]): boolean => + checks.some((c) => + c.net?.some((iface) => iface.bytesSentPerSecond > 0 || iface.deltaBytesRecv > 0) + ); + export const InfraNetworkCharts = ({ checks, dateRange, @@ -74,35 +82,43 @@ export const InfraNetworkCharts = ({ () => getChartConfigs(theme, checks, t), [theme, checks, t] ); + const showNoTrafficNotice = chartConfigs.length > 0 && !hasAnyNetworkTraffic(checks); return ( - - {chartConfigs.map((config) => { - return ( - - - - ); - })} - + + {showNoTrafficNotice && ( + + {t("pages.infrastructure.charts.labels.netNoTraffic")} + + )} + + {chartConfigs.map((config) => { + return ( + + + + ); + })} + + ); }; diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx index 994ddcea1..e6458ea34 100644 --- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx +++ b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx @@ -1,4 +1,5 @@ import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements"; import type { Header } from "@/Components/design-elements/Table"; @@ -12,6 +13,24 @@ import { useNavigate } from "react-router-dom"; import { usePost } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; +import { Checkbox } from "@/Components/inputs"; + +interface InfraMonitorsTableProps { + monitors: Monitor[]; + refetch: () => void; + setSelectedMonitor: (monitor: Monitor | null) => void; + sortField: string; + setSortField: (field: string) => void; + sortOrder: "asc" | "desc"; + setSortOrder: (order: "asc" | "desc") => void; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; + selectedRows?: string[]; + onSelectionChange?: (selected: string[]) => void; +} export const InfraMonitorsTable = ({ monitors, @@ -26,29 +45,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 +87,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) { @@ -80,14 +102,6 @@ export const InfraMonitorsTable = ({ const getActions = (monitor: Monitor): ActionMenuItem[] => { return [ - { - id: 1, - label: t("pages.common.monitors.actions.openSite"), - action: () => { - window.open(monitor.url, "_blank", "noreferrer"); - }, - closeMenu: true, - }, { id: 2, label: t("pages.common.monitors.actions.details"), @@ -109,13 +123,6 @@ export const InfraMonitorsTable = ({ navigate(`/infrastructure/configure/${monitor.id}`); }, }, - // { - // id: 5, - // label: "Clone", - // action: () => { - - // }, - // }, { id: 6, label: @@ -160,8 +167,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 ; @@ -256,6 +292,11 @@ export const InfraMonitorsTable = ({ onRowClick={(row) => { navigate(`/infrastructure/${row.id}`); }} + getRowSx={(row) => ({ + backgroundColor: isRowSelected(row.id) + ? theme.palette.action.selected + : "inherit", + })} /> { const { t } = useTranslation(); @@ -37,6 +43,7 @@ const InfrastructureMonitors = () => { 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); @@ -97,6 +104,15 @@ 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); @@ -122,6 +138,7 @@ const InfrastructureMonitors = () => { return ( { }} /> + + {!isLoading && ( + + + + + )} + { ); setPage(0); }} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} /> { const theme = useTheme(); + const { t } = useTranslation(); const { data: diagnostics, isLoading: _isLoading, @@ -16,9 +19,35 @@ export const TabDiagnostics = () => { } = useGet("/diagnostic/system", {}, { refreshInterval: 5000 }); return ( - - - + + + + + {t("pages.logs.diagnostics.sections.runtimeStats.title")} + + + {t("pages.logs.diagnostics.sections.runtimeStats.description")} + + + + + + + + {t("pages.logs.diagnostics.sections.memoryCpu.title")} + + + {t("pages.logs.diagnostics.sections.memoryCpu.description")} + + + + ); }; diff --git a/client/src/Pages/Logs/TabLogs.tsx b/client/src/Pages/Logs/TabLogs.tsx index a05726b42..4bf59e149 100644 --- a/client/src/Pages/Logs/TabLogs.tsx +++ b/client/src/Pages/Logs/TabLogs.tsx @@ -4,6 +4,7 @@ import Typography from "@mui/material/Typography"; import { useSelector } from "react-redux"; import { Select } from "@/Components/inputs"; import { TableLogs } from "@/Pages/Logs/components/TableLogs"; +import { EmptyState } from "@/Components/design-elements"; import { useTheme } from "@mui/material"; import { useGet } from "@/Hooks/UseApi"; @@ -15,7 +16,7 @@ import type { RootState } from "@/Types/state"; export const TabLogs = () => { const theme = useTheme(); - const { data: logs } = useGet("/logs"); + const { data: logs, isLoading } = useGet("/logs"); const [selectedLogLevel, setSelectedLogLevel] = useState("all"); const [page, setPage] = useState(0); const rowsPerPage = useSelector( @@ -33,6 +34,16 @@ export const TabLogs = () => { const paginatedLogs = filteredLogs?.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) ?? []; + if (!isLoading && (logs?.length ?? 0) === 0) { + return ( + + ); + } + return (
{ - navigate(`/notifications/configure/${row.id}`); - }} - /> + <> +
{ + navigate(`/notifications/configure/${row.id}`); + }} + /> + {notifications.length > 0 && } + ); }; diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 85568f8bd..0f6d9b652 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -147,30 +147,33 @@ const NotificationsCreatePage = () => { /> } /> - {watchedType !== "matrix" && watchedType !== "telegram" && ( - ( - - )} - /> - } - /> - )} + {watchedType !== "matrix" && + watchedType !== "telegram" && + watchedType !== "pushover" && + watchedType !== "twilio" && ( + ( + + )} + /> + } + /> + )} {watchedType === "telegram" && ( { } /> )} + {watchedType === "pushover" && ( + + ( + + )} + /> + ( + + )} + /> + + } + /> + )} + {watchedType === "twilio" && ( + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + } + /> + )} {watchedType === "matrix" && ( { return ( { 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 302ce9ab2..e14366e0f 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -24,6 +24,7 @@ import type { SettingsFormData, SettingsFormInput } from "@/Validation/settings" import { useState } from "react"; import { Controller } from "react-hook-form"; import { TextField, Button, FieldLabel, SliderWithLabel } from "@/Components/inputs"; +import { languageNames } from "@/Components/inputs/LanguageSelector"; import { Box, Typography } from "@mui/material"; import { useDelete } from "@/Hooks/UseApi"; @@ -127,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) => { @@ -181,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, }), @@ -277,6 +281,7 @@ export const SettingsPage = () => { return ( @@ -326,7 +331,7 @@ export const SettingsPage = () => { key={lang} value={lang} > - {lang.toUpperCase()} + {languageNames[lang] ?? lang} ))} @@ -575,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={ @@ -678,6 +704,24 @@ export const SettingsPage = () => { )} /> + {/* Email Display Name (Optional) */} + ( + + )} + /> + {/* Email User (Optional) */} { setIsDemoMonitorsDialogOpen(false); }} loading={isDeletingAllMonitors} - /> + confirmColor="error" + confirmText={t("common.buttons.removeMonitors")} + > + + {t("pages.settings.form.removeMonitors.dialog.paragraph")} + + - {/* Sticky Save Button */} diff --git a/client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx b/client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx new file mode 100644 index 000000000..d68e1d0c1 --- /dev/null +++ b/client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx @@ -0,0 +1,66 @@ +import { useTranslation } from "react-i18next"; +import Stack from "@mui/material/Stack"; +import RadioGroup from "@mui/material/RadioGroup"; +import MenuItem from "@mui/material/MenuItem"; +import { useTheme } from "@mui/material/styles"; +import { RadioWithDescription, Select } from "@/Components/inputs"; +import { + STATUS_PAGE_THEMES, + STATUS_PAGE_THEME_MODES, + type StatusPageTheme, + type StatusPageThemeMode, +} from "@/Types/StatusPage"; + +interface Props { + theme: StatusPageTheme; + themeMode: StatusPageThemeMode; + onThemeChange: (value: StatusPageTheme) => void; + onThemeModeChange: (value: StatusPageThemeMode) => void; +} + +export const ThemePicker = ({ + theme, + themeMode, + onThemeChange, + onThemeModeChange, +}: Props) => { + const { t } = useTranslation(); + const muiTheme = useTheme(); + + return ( + + onThemeChange(value as StatusPageTheme)} + > + + {STATUS_PAGE_THEMES.map((option) => ( + + ))} + + + + + + ); +}; diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index f09d9e47d..5ef5095ea 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -32,8 +32,10 @@ import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage" import { getMonitorTypeLabel } from "@/Types/StatusPage"; import timezones from "@/Utils/timezones.json"; import { useNavigate, useParams } from "react-router-dom"; +import { mutate } from "swr"; import axios from "axios"; import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls"; +import { ThemePicker } from "./Components/ThemePicker"; const monitorsUrl = (() => { const params = new URLSearchParams(); @@ -48,6 +50,9 @@ interface TimezoneOption { name: string; } +const buildStatusPageKey = (slug: string | null | undefined) => + slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null; + const CreateStatusPage = () => { const theme = useTheme(); const { t } = useTranslation(); @@ -58,9 +63,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 ?? []; @@ -139,6 +142,8 @@ const CreateStatusPage = () => { fd.append("showUptimePercentage", String(data.showUptimePercentage)); fd.append("showAdminLoginLink", String(data.showAdminLoginLink)); fd.append("showInfrastructure", String(data.showInfrastructure)); + if (data.theme) fd.append("theme", data.theme); + if (data.themeMode) fd.append("themeMode", data.themeMode); data.monitors.forEach((monitorId) => { fd.append("monitors[]", monitorId); @@ -178,6 +183,10 @@ const CreateStatusPage = () => { } if (result) { + await mutate(buildStatusPageKey(data.url)); + if (!isCreate && url !== data.url) { + await mutate(buildStatusPageKey(url)); + } navigate(`/status/${data.url}`); } }; @@ -438,6 +447,30 @@ const CreateStatusPage = () => { } /> + ( + ( + + )} + /> + )} + /> + } + /> { window.open( - `/status/public/${statusPage.url}`, + `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`, "_blank", "noopener,noreferrer" ); diff --git a/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx b/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx deleted file mode 100644 index 32a15d64b..000000000 --- a/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import { Gauge } from "@/Components/design-elements"; -import { useTheme } from "@mui/material/styles"; -import { useTranslation } from "react-i18next"; -import prettyBytes from "pretty-bytes"; -import type { Monitor } from "@/Types/Monitor"; -import Grid from "@mui/material/Grid"; -import useMediaQuery from "@mui/material/useMediaQuery"; -import Box from "@mui/material/Box"; -import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; - -const GAUGE_RADIUS = 60; -const GAUGE_STROKE_WIDTH = 12; -const PERCENTAGE_MULTIPLIER = 100; - -interface StatusPageMonitor extends Monitor { - checks?: Monitor["recentChecks"]; -} - -interface MetricDetail { - label: string; - value: string; -} - -interface MetricConfig { - key: string; - label: string; - hasData: boolean; - progress: number; - details: MetricDetail[]; -} - -const MetricDetailRow = ({ label, value }: MetricDetail) => { - const theme = useTheme(); - return ( - - - {label} - - {value} - - ); -}; - -interface MetricItemProps { - label: string; - progress: number; - details?: MetricDetail[]; -} - -const MetricItem = ({ label, progress, details }: MetricItemProps) => { - const theme = useTheme(); - const isSmall = useMediaQuery(theme.breakpoints.down("md")); - return ( - - - - {label} - - {details && details.length > 0 && ( - - {details.map((detail) => ( - - ))} - - )} - - ); -}; - -type LatestCheck = NonNullable[number]; - -const buildCpuMetric = ( - check: LatestCheck, - t: (key: string) => string -): MetricConfig | null => { - if (!check.cpu || typeof check.cpu.usage_percent !== "number") { - return null; - } - const usagePercent = (check.cpu.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER; - return { - key: "cpu", - label: t("pages.statusPages.monitorsList.infrastructure.cpu"), - hasData: true, - progress: usagePercent, - details: [ - { - label: t("pages.statusPages.monitorsList.infrastructure.usage"), - value: `${usagePercent.toFixed(2)}%`, - }, - ], - }; -}; - -const buildMemoryMetric = ( - check: LatestCheck, - t: (key: string) => string -): MetricConfig | null => { - if ( - !check.memory || - typeof check.memory.usage_percent !== "number" || - typeof check.memory.used_bytes !== "number" || - typeof check.memory.total_bytes !== "number" - ) { - return null; - } - return { - key: "memory", - label: t("pages.statusPages.monitorsList.infrastructure.memory"), - hasData: true, - progress: (check.memory.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER, - details: [ - { - label: t("pages.statusPages.monitorsList.infrastructure.used"), - value: prettyBytes(check.memory.used_bytes ?? 0), - }, - { - label: t("pages.statusPages.monitorsList.infrastructure.total"), - value: prettyBytes(check.memory.total_bytes ?? 0), - }, - ], - }; -}; - -const buildDiskMetric = ( - check: LatestCheck, - t: (key: string) => string -): MetricConfig | null => { - if (!check.disk || check.disk.length === 0) { - return null; - } - const disks = check.disk; - const avgUsagePercent = - (disks.reduce((acc, disk) => acc + (disk?.usage_percent ?? 0), 0) / disks.length) * - PERCENTAGE_MULTIPLIER; - const totalUsedBytes = disks.reduce((acc, disk) => acc + (disk?.used_bytes ?? 0), 0); - const totalTotalBytes = disks.reduce((acc, disk) => acc + (disk?.total_bytes ?? 0), 0); - - return { - key: "disk", - label: t("pages.statusPages.monitorsList.infrastructure.disk"), - hasData: true, - progress: avgUsagePercent, - details: [ - { - label: t("pages.statusPages.monitorsList.infrastructure.used"), - value: prettyBytes(totalUsedBytes), - }, - { - label: t("pages.statusPages.monitorsList.infrastructure.total"), - value: prettyBytes(totalTotalBytes), - }, - ], - }; -}; - -export const InfrastructureMetrics = ({ monitor }: { monitor: StatusPageMonitor }) => { - const theme = useTheme(); - const { t } = useTranslation(); - - const latestCheck = monitor.recentChecks?.[0] ?? monitor.checks?.[0]; - - if (!latestCheck) { - return ( - - {t("pages.statusPages.monitorsList.noData")} - - ); - } - - const metrics: MetricConfig[] = [ - buildCpuMetric(latestCheck, t), - buildMemoryMetric(latestCheck, t), - buildDiskMetric(latestCheck, t), - ].filter((m): m is MetricConfig => m !== null); - - if (metrics.length === 0) { - return ( - - {t("pages.statusPages.monitorsList.noData")} - - ); - } - - return ( - - {metrics.map(({ key, label, progress, details }) => ( - - ))} - - ); -}; diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx deleted file mode 100644 index 248a69c9b..000000000 --- a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import Stack from "@mui/material/Stack"; -import { useTranslation } from "react-i18next"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common"; -import { StatusLabel, BaseBox } from "@/Components/design-elements"; -import { SwitchComponent } from "@/Components/inputs"; -import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics"; - -import { useTheme, type Theme } from "@mui/material/styles"; -import { useSelector } from "react-redux"; -import { useState } from "react"; -import type { Monitor } from "@/Types/Monitor"; -import type { StatusPage } from "@/Types/StatusPage"; -import { getMonitorTypeLabel } from "@/Types/StatusPage"; -import type { RootState } from "@/Types/state"; -import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; - -interface StatusPageMonitor extends Monitor { - checks?: Monitor["recentChecks"]; -} - -interface MonitorsListProps { - statusPage: StatusPage; - monitors: StatusPageMonitor[]; -} - -const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => { - const bg = - monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light; - return { - backgroundColor: bg, - color: theme.palette.background.paper, - padding: `${theme.spacing(SPACING.SM)} ${theme.spacing(SPACING.LG)}`, - borderRadius: theme.shape.borderRadius, - }; -}; - -const MonitorHeader = ({ - monitor, - showURL, -}: { - monitor: StatusPageMonitor; - showURL: boolean; -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - - return ( - - - - - {monitor.name} - - - {getMonitorTypeLabel(monitor.type, t)} - - - {showURL && monitor.url && ( - - {monitor.url} - - )} - - - - ); -}; - -const MonitorContent = ({ - monitor, - statusPage, - chartType, -}: { - monitor: StatusPageMonitor; - statusPage: StatusPage; - chartType: string; -}) => { - const theme = useTheme(); - - if (monitor.type === "hardware") { - if (statusPage.showInfrastructure === false) return null; - return ; - } - - if (statusPage.showCharts === false) return null; - - const checks = monitor.checks?.slice().reverse() ?? []; - return ( - - {chartType === "histogram" ? ( - - ) : ( - - )} - - ); -}; - -export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => { - const theme = useTheme(); - const { t } = useTranslation(); - const showURL = useSelector((state: RootState) => state.ui?.showURL); - const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram"); - - return ( - - {statusPage.showCharts && ( - - {t("pages.statusPages.monitorsList.chartTypeHeatmap")} - setChartType(e.target.checked ? "histogram" : "heatmap")} - /> - - {t("pages.statusPages.monitorsList.chartTypeHistogram")} - - - )} - - {monitors.map((monitor) => ( - - - - - ))} - - ); -}; diff --git a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx b/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx deleted file mode 100644 index b5c52b32b..000000000 --- a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { AlertTriangle, CircleCheck } from "lucide-react"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; - -import { useTranslation } from "react-i18next"; -import type { Theme } from "@mui/material"; -import { useTheme } from "@mui/material"; -import type { Monitor } from "@/Types/Monitor"; - -const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => { - const monitorsStatus: Record = { - icon: , - }; - - if (monitors.every((monitor) => monitor.status === "up")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allUp"); - monitorsStatus.color = theme.palette.success.main; - monitorsStatus.icon = ; - return monitorsStatus; - } else if (monitors.every((monitor) => monitor.status === "down")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.allDown"); - monitorsStatus.color = theme.palette.error.main; - return monitorsStatus; - } else if (monitors.some((monitor) => monitor.status === "down")) { - monitorsStatus.msg = t("pages.statusPages.statusBar.degraded"); - monitorsStatus.color = theme.palette.warning.main; - return monitorsStatus; - } else { - monitorsStatus.msg = t("pages.statusPages.statusBar.unknown"); - monitorsStatus.color = theme.palette.warning.main; - return monitorsStatus; - } -}; - -interface StatusBarProps { - monitors: Monitor[]; -} - -export const StatusBar = ({ monitors }: StatusBarProps) => { - const theme = useTheme(); - const { t } = useTranslation(); - const monitorsStatus = getMonitorStatus(monitors, theme, t); - - return ( - - {monitorsStatus.icon} - {monitorsStatus.msg} - - ); -}; diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 78b7cba41..9c4001fae 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -1,18 +1,64 @@ import { BasePage, BaseFallback } from "@/Components/design-elements"; -import { StatusBar } from "@/Pages/StatusPage/Status/Components/StatusBar"; -import { MonitorsList } from "@/Pages/StatusPage/Status/Components/MonitorsList"; import Typography from "@mui/material/Typography"; import { Link } from "react-router-dom"; import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; -import { useMediaQuery, useTheme } from "@mui/material"; +import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useLocation, useParams } from "react-router-dom"; import { useGet } from "@/Hooks/UseApi"; -import type { StatusPageResponse } from "@/Types/StatusPage"; -import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls"; +import { resolveStatusPageTheme } from "@/Types/StatusPage"; +import { + PUBLIC_STATUS_PAGE_PREFIX, + type StatusPageResponse, + type StatusPageTheme, +} from "@/Types/StatusPage"; +import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls"; +import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; +import { + BaseStatusPage, + type ThemeConfig, +} from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage"; +import { BrowserFrame } from "@/Pages/StatusPage/Status/themes/BrowserFrame"; +import { refinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles"; +import { RefinedHeader } from "@/Pages/StatusPage/Status/themes/refined/RefinedHeader"; +import { RefinedHero } from "@/Pages/StatusPage/Status/themes/refined/RefinedHero"; +import { modernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles"; +import { ModernHeader } from "@/Pages/StatusPage/Status/themes/modern/ModernHeader"; +import { ModernHero } from "@/Pages/StatusPage/Status/themes/modern/ModernHero"; +import { boldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles"; +import { BoldHeader } from "@/Pages/StatusPage/Status/themes/bold/BoldHeader"; +import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero"; +import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles"; +import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader"; +import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero"; + +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 = () => { const theme = useTheme(); @@ -20,8 +66,8 @@ const StatusPageView = () => { const { url } = useParams(); const isAdmin = useIsAdmin(); const location = useLocation(); - const isSmall = useMediaQuery(theme.breakpoints.down("md")); - const isPublic = location.pathname.startsWith("/status/public"); + + const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX); const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null; @@ -65,48 +111,50 @@ const StatusPageView = () => { ); } - let sx: React.CSSProperties = {}; + const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)]; + const themedRenderer = ( + + ); + + // Public route: render directly on the viewport, themed background covers everything. if (isPublic) { - sx.paddingTop = theme.spacing(20); - sx.paddingLeft = isSmall ? "5vw" : "20vw"; - sx.paddingRight = isSmall ? "5vw" : "20vw"; + return ( + + {themedRenderer} + + ); } - const logoSrc = statusPage.logo?.data - ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}` - : null; - + const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; return ( - {logoSrc && ( - - )} - - + + {themedRenderer} + ); }; diff --git a/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx new file mode 100644 index 000000000..b893aa85c --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from "react"; +import Box from "@mui/material/Box"; +import { useStatusPageTheme } from "./StatusPageThemeProvider"; + +interface Props { + url: string; + children: ReactNode; +} + +export const BrowserFrame = ({ url, children }: Props) => { + const { tokens } = useStatusPageTheme(); + + return ( + + + + + + + + + {url} + + + {children} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx new file mode 100644 index 000000000..f79991c3b --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx @@ -0,0 +1,153 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { + DEFAULT_STATUS_PAGE_THEME, + resolveStatusPageTheme, + resolveStatusPageThemeMode, + type StatusPageTheme, + type StatusPageThemeMode, +} from "@/Types/StatusPage"; +import { resolveTimezone } from "@/Utils/TimeUtils"; +import { themeTokens, type StatusPageThemeTokens } from "./tokens"; + +type ResolvedMode = "light" | "dark"; + +interface StatusPageThemeContextValue { + theme: StatusPageTheme; + mode: ResolvedMode; + tokens: StatusPageThemeTokens; + timezone: string; +} + +const StatusPageThemeContext = createContext(null); + +const resolveSystemMode = (): ResolvedMode => { + if (typeof window === "undefined" || !window.matchMedia) return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +}; + +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 + * where no admin shell sits behind). Defaults to false for admin + * previews that already have an app background. + */ + paintBody?: boolean; + /** + * When true, the provider's wrapper div does NOT paint its own + * background or set a min-height. Use this when the wrapper is + * inside a clipped container (e.g. BrowserFrame) that already sets + * the visible background, so the wrapper's bg doesn't bleed past the + * container's rounded corners. + */ + transparent?: boolean; + children: ReactNode; +} + +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); + + useEffect(() => { + if (resolvedThemeMode !== "auto") return; + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + // Reconcile on first paint whenever we (re-)enter auto mode — only + // when it disagrees with the stored value, so we don't trigger an + // extra render on the common hot path. + const next: ResolvedMode = mq.matches ? "dark" : "light"; + setSystemMode((prev) => (prev === next ? prev : next)); + const handler = (e: MediaQueryListEvent) => + setSystemMode(e.matches ? "dark" : "light"); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [resolvedThemeMode]); + + useEffect(() => { + if (!paintBody || typeof document === "undefined") return; + const html = document.documentElement; + const body = document.body; + const prev = { + htmlBg: html.style.background, + bodyBg: body.style.background, + bodyMargin: body.style.margin, + htmlColorScheme: html.style.colorScheme, + }; + const resolvedMode = resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode; + const bg = themeTokens[resolvedTheme][resolvedMode].bg; + html.style.background = bg; + body.style.background = bg; + body.style.margin = "0"; + html.style.colorScheme = resolvedMode; + return () => { + html.style.background = prev.htmlBg; + body.style.background = prev.bodyBg; + body.style.margin = prev.bodyMargin; + html.style.colorScheme = prev.htmlColorScheme; + }; + }, [paintBody, resolvedTheme, resolvedThemeMode, systemMode]); + + const resolvedMode: ResolvedMode = + resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode; + const tokens = themeTokens[resolvedTheme][resolvedMode]; + + const value = useMemo( + () => ({ + theme: resolvedTheme, + mode: resolvedMode, + tokens, + timezone: resolvedTimezone, + }), + [resolvedTheme, resolvedMode, tokens, resolvedTimezone] + ); + + return ( + +
+ {children} +
+
+ ); +}; + +export const useStatusPageTheme = (): StatusPageThemeContextValue => { + const ctx = useContext(StatusPageThemeContext); + if (!ctx) { + return { + 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/styles.ts b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts new file mode 100644 index 000000000..9153ab994 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts @@ -0,0 +1,405 @@ +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"; +export type BoldGaugeFill = "ok" | "warm" | "hot"; + +export interface BoldStyles { + page: SxProps; + top: SxProps; + brand: SxProps; + logoConic: SxProps; + logoImg: SxProps; + hero: SxProps; + heroTitle: SxProps; + heroCheck: (tone: OverallTone) => SxProps; + heroSub: 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: BoldHeatCell) => SxProps; + histogram: SxProps; + bar: (kind: BoldBarKind, heightPct: number) => SxProps; + chartStats: SxProps; + infra: SxProps; + infraEmpty: SxProps; + gauge: SxProps; + gaugeLabel: SxProps; + gaugeValue: SxProps; + gaugeBar: SxProps; + gaugeFill: (level: BoldGaugeFill, widthPct: number) => SxProps; + gaugeSub: SxProps; + footer: SxProps; +} + +export const boldStyles = ( + tokens: StatusPageThemeTokens, + isDark: boolean +): BoldStyles => { + const heatCellBg: Record = { + fast: tokens.up, + med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`, + slow: tokens.warn, + down: tokens.down, + empty: tokens.border, + }; + const heatCellShadow: Record = { + fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`, + med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`, + slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`, + down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`, + empty: "none", + }; + + const barBg: Record = { + up: tokens.up, + down: tokens.down, + empty: tokens.border, + }; + + const gaugeFillBg: Record = { + ok: `linear-gradient(90deg, ${tokens.up}, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%))`, + warm: `linear-gradient(90deg, ${tokens.warn}, #fbbf24)`, + hot: `linear-gradient(90deg, ${tokens.down}, #fb7185)`, + }; + + const heroBgLight = `linear-gradient(135deg, color-mix(in srgb, ${tokens.up} 8%, transparent), transparent 60%), ${tokens.surface}`; + const heroBgDark = `linear-gradient(135deg, color-mix(in srgb, ${tokens.up} 16%, transparent), transparent 60%), ${tokens.surface}`; + + const heroGlowLight = `radial-gradient(400px 200px at 20% 30%, color-mix(in srgb, ${tokens.up} 14%, transparent), transparent 60%), radial-gradient(300px 200px at 90% 80%, color-mix(in srgb, ${tokens.upStrong} 10%, transparent), transparent 60%)`; + const heroGlowDark = `radial-gradient(400px 200px at 20% 30%, color-mix(in srgb, ${tokens.up} 32%, transparent), transparent 60%), radial-gradient(300px 200px at 90% 80%, color-mix(in srgb, ${tokens.upStrong} 22%, transparent), transparent 60%)`; + + return { + page: { + flex: "1 0 auto", + maxWidth: 1040, + width: "100%", + mx: "auto", + p: "56px 24px 96px", + fontFamily: BOLD_SANS_STACK, + fontSize: 14, + lineHeight: 1.55, + color: tokens.text, + WebkitFontSmoothing: "antialiased", + }, + + top: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + mb: "36px", + }, + brand: { + display: "flex", + alignItems: "center", + gap: "14px", + fontWeight: 700, + fontSize: 16, + letterSpacing: "-0.01em", + color: tokens.text, + }, + logoConic: { + width: 36, + height: 36, + borderRadius: "12px", + background: `conic-gradient(from 140deg, ${tokens.up}, ${tokens.upStrong}, #0f766e, ${tokens.up})`, + display: "grid", + placeItems: "center", + color: "#05070a", + fontWeight: 900, + fontSize: 14, + boxShadow: `0 8px 24px color-mix(in srgb, ${tokens.up} 30%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.2)`, + }, + logoImg: { maxHeight: 36, maxWidth: 140, objectFit: "contain" }, + + hero: { + position: "relative", + borderRadius: "22px", + padding: "36px 36px 32px", + mb: "28px", + background: isDark ? heroBgDark : heroBgLight, + border: `1px solid ${tokens.border}`, + overflow: "hidden", + "&::before": { + content: '""', + position: "absolute", + inset: 0, + background: isDark ? heroGlowDark : heroGlowLight, + pointerEvents: "none", + opacity: 0.8, + zIndex: 0, + ...(isDark ? { mixBlendMode: "screen" } : {}), + }, + "& > *": { position: "relative", zIndex: 1 }, + }, + heroTitle: { + m: 0, + fontSize: 34, + fontWeight: 800, + letterSpacing: "-0.02em", + lineHeight: 1.1, + color: tokens.text, + display: "flex", + alignItems: "center", + gap: "10px", + }, + heroCheck: (tone) => ({ + display: "inline-grid", + placeItems: "center", + width: 32, + height: 32, + borderRadius: "50%", + background: toneColor(tone, tokens), + color: tone === "down" ? "#fff" : "#05070a", + flexShrink: 0, + boxShadow: `0 0 0 6px ${toneSoft(tone, tokens)}`, + }), + heroSub: { mt: "10px", mb: 0, color: tokens.textMuted, fontSize: 14 }, + + chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "16px" }, + chartSwitch: { + display: "inline-flex", + border: `1px solid ${tokens.border}`, + borderRadius: "999px", + background: tokens.surface, + p: "3px", + gap: "2px", + }, + chartSwitchButton: (active) => ({ + border: 0, + background: active ? tokens.upSoft : "transparent", + fontFamily: "inherit", + fontSize: 11, + padding: "8px 18px", + cursor: "pointer", + color: active ? tokens.up : tokens.textMuted, + fontWeight: 700, + textTransform: "uppercase", + letterSpacing: "0.12em", + transition: "background 0.2s, color 0.2s", + borderRadius: "999px", + "&:hover": { color: active ? tokens.up : tokens.text }, + }), + + monitorList: { + listStyle: "none", + m: 0, + p: 0, + display: "flex", + flexDirection: "column", + gap: "14px", + }, + card: { + background: tokens.surface, + border: `1px solid ${tokens.border}`, + borderRadius: "18px", + overflow: "hidden", + transition: "transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), border-color 0.2s", + "&:hover": { + transform: "translateY(-3px)", + borderColor: `color-mix(in srgb, ${tokens.up} 40%, ${tokens.border})`, + }, + }, + cardRow: { + display: "grid", + gridTemplateColumns: "1fr auto", + alignItems: "center", + gap: "24px", + p: "22px 28px", + }, + cardLeft: { minWidth: 0 }, + monitorName: { + fontWeight: 700, + fontSize: 17, + letterSpacing: "-0.01em", + color: tokens.text, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + monitorMeta: { + display: "flex", + gap: "10px", + alignItems: "center", + mt: "6px", + flexWrap: "wrap", + }, + pill: { + fontSize: 10, + textTransform: "uppercase", + letterSpacing: "0.1em", + color: tokens.textMuted, + background: `color-mix(in srgb, ${tokens.surface} 80%, ${tokens.bg})`, + border: `1px solid ${tokens.border}`, + padding: "3px 10px", + borderRadius: "999px", + fontWeight: 700, + }, + pillHardware: { + fontSize: 10, + textTransform: "uppercase", + letterSpacing: "0.1em", + padding: "3px 10px", + borderRadius: "999px", + fontWeight: 700, + color: tokens.up, + borderColor: `color-mix(in srgb, ${tokens.up} 40%, transparent)`, + border: `1px solid color-mix(in srgb, ${tokens.up} 40%, transparent)`, + background: tokens.upSoft, + }, + monitorUrl: { + fontSize: 12, + color: tokens.textMuted, + fontFamily: MONO_STACK, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: 300, + }, + + badge: (tone) => ({ + fontSize: 11, + fontWeight: 800, + padding: "6px 14px", + borderRadius: "999px", + display: "inline-flex", + alignItems: "center", + gap: "7px", + textTransform: "uppercase", + letterSpacing: "0.08em", + whiteSpace: "nowrap", + background: toneSoft(tone, tokens), + color: toneColor(tone, tokens), + "&::before": { + content: '""', + width: 6, + height: 6, + borderRadius: "50%", + background: "currentColor", + boxShadow: "0 0 8px currentColor", + }, + }), + + heatmap: { + padding: "0 28px 22px", + display: "grid", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, + gap: "3px", + height: 48, + }, + heatmapCell: (kind) => ({ + borderRadius: "3px", + background: heatCellBg[kind], + boxShadow: heatCellShadow[kind], + opacity: kind === "empty" ? 0.4 : 1, + transition: "transform 0.15s", + "&:hover": { transform: "scaleY(1.2)" }, + }), + + histogram: { + padding: "0 28px", + display: "grid", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, + gap: "3px", + alignItems: "flex-end", + height: 48, + }, + bar: (kind, heightPct) => ({ + background: barBg[kind], + borderRadius: "3px", + minHeight: 4, + opacity: kind === "empty" ? 0.4 : 1, + height: `${heightPct}%`, + }), + chartStats: { + padding: "0 28px 22px", + fontSize: 11, + color: tokens.textMuted, + fontVariantNumeric: "tabular-nums", + fontWeight: 700, + letterSpacing: "0.05em", + }, + + infra: { + padding: "18px 28px 26px", + display: "grid", + gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, + gap: "16px", + }, + infraEmpty: { + padding: "18px 28px 26px", + color: tokens.textMuted, + fontSize: 13, + }, + gauge: { + border: `1px solid ${tokens.border}`, + borderRadius: "16px", + p: "16px 18px", + background: `color-mix(in srgb, ${tokens.surface} 80%, ${tokens.bg})`, + }, + gaugeLabel: { + fontSize: 10, + color: tokens.textMuted, + textTransform: "uppercase", + letterSpacing: "0.12em", + fontWeight: 700, + }, + gaugeValue: { + fontSize: 30, + fontWeight: 800, + letterSpacing: "-0.02em", + mt: "6px", + fontVariantNumeric: "tabular-nums", + color: tokens.text, + }, + gaugeBar: { + height: 6, + background: tokens.border, + borderRadius: "3px", + overflow: "hidden", + mt: "12px", + }, + gaugeFill: (level, widthPct) => ({ + display: "block", + height: "100%", + background: gaugeFillBg[level], + borderRadius: "3px", + transition: "width 0.9s cubic-bezier(0.2, 0.8, 0.2, 1)", + width: `${Math.max(0, Math.min(100, widthPct))}%`, + }), + gaugeSub: { + fontSize: 11, + color: tokens.textMuted, + mt: "10px", + fontVariantNumeric: "tabular-nums", + }, + + footer: { + textAlign: "center", + color: tokens.textMuted, + fontSize: 12, + mt: "52px", + "& a": { + color: tokens.up, + textDecoration: "underline", + textUnderlineOffset: "3px", + fontWeight: 800, + }, + }, + }; +}; 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/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts new file mode 100644 index 000000000..57128180e --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts @@ -0,0 +1,341 @@ +import type { SxProps, Theme } from "@mui/material/styles"; +import type { StatusPageThemeTokens } from "../tokens"; +import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; +import { + EDITORIAL_SECONDARY_SANS_STACK, + 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"; +export type EditorialGaugeFill = "ok" | "warm" | "hot"; + +export interface EditorialStyles { + page: SxProps; + top: SxProps; + brandWrap: SxProps; + logoImg: SxProps; + brandEyebrow: SxProps; + brandTitle: SxProps; + statusLine: SxProps; + statusDot: (tone: OverallTone) => SxProps; + dateline: 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: EditorialHeatCell) => SxProps; + histogram: SxProps; + bar: (kind: EditorialBarKind, heightPct: number) => SxProps; + chartStats: SxProps; + infra: SxProps; + infraEmpty: SxProps; + gauge: SxProps; + gaugeLabel: SxProps; + gaugeValue: SxProps; + gaugeBar: SxProps; + gaugeFill: (level: EditorialGaugeFill, widthPct: number) => SxProps; + gaugeSub: SxProps; + footer: SxProps; +} + +export const editorialStyles = ( + tokens: StatusPageThemeTokens, + _isDark: boolean +): EditorialStyles => { + const heatCellBg: Record = { + fast: tokens.up, + med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`, + slow: tokens.warn, + down: tokens.down, + empty: tokens.border, + }; + + const barBg: Record = { + up: tokens.up, + down: tokens.down, + empty: tokens.border, + }; + + const gaugeFillBg: Record = { + ok: tokens.up, + warm: tokens.warn, + hot: tokens.down, + }; + + return { + page: { + flex: "1 0 auto", + maxWidth: 760, + width: "100%", + mx: "auto", + p: "64px 24px 96px", + fontFamily: SERIF_STACK, + fontSize: 15, + lineHeight: 1.6, + color: tokens.text, + WebkitFontSmoothing: "antialiased", + }, + + top: { + display: "flex", + alignItems: "flex-end", + justifyContent: "space-between", + mb: "48px", + borderBottom: `2px solid ${tokens.text}`, + pb: "18px", + }, + brandWrap: { display: "flex", flexDirection: "column" }, + logoImg: { maxHeight: 48, maxWidth: 160, objectFit: "contain", mb: "8px" }, + brandEyebrow: { + m: 0, + fontSize: 11, + textTransform: "uppercase", + letterSpacing: "0.3em", + fontWeight: 700, + color: tokens.textMuted, + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + }, + brandTitle: { + mt: "6px", + mb: 0, + fontSize: 40, + fontWeight: 700, + letterSpacing: "-0.02em", + lineHeight: 1, + color: tokens.text, + }, + + statusLine: { + fontSize: 22, + fontWeight: 400, + m: 0, + mb: "8px", + letterSpacing: "-0.01em", + lineHeight: 1.35, + color: tokens.text, + fontFamily: SERIF_STACK, + }, + statusDot: (tone) => ({ + display: "inline-block", + width: 10, + height: 10, + borderRadius: "50%", + background: toneColor(tone, tokens), + mr: "10px", + verticalAlign: "middle", + }), + dateline: { + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + fontSize: 11, + textTransform: "uppercase", + letterSpacing: "0.12em", + color: tokens.textMuted, + m: 0, + mb: "56px", + }, + + chartSwitchWrap: { + display: "flex", + justifyContent: "flex-end", + mb: "20px", + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + }, + chartSwitch: { + display: "inline-flex", + border: `1px solid ${tokens.text}`, + "& > button + button": { borderLeft: `1px solid ${tokens.text}` }, + }, + chartSwitchButton: (active) => ({ + fontFamily: "inherit", + fontSize: 10, + textTransform: "uppercase", + letterSpacing: "0.2em", + padding: "6px 14px", + border: 0, + background: active ? tokens.text : "transparent", + color: active ? tokens.bg : tokens.text, + cursor: "pointer", + fontWeight: 700, + }), + + monitorList: { + listStyle: "none", + m: 0, + p: 0, + display: "flex", + flexDirection: "column", + }, + card: { + background: "transparent", + border: 0, + borderBottom: `1px solid ${tokens.border}`, + mb: 0, + py: "20px", + "&:last-of-type": { borderBottom: 0 }, + }, + cardRow: { + display: "grid", + gridTemplateColumns: "1fr auto", + alignItems: "baseline", + gap: "20px", + }, + cardLeft: { minWidth: 0 }, + monitorName: { + fontSize: 20, + fontWeight: 700, + letterSpacing: "-0.015em", + color: tokens.text, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + monitorMeta: { + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + fontSize: 12, + color: tokens.textMuted, + mt: "6px", + display: "flex", + gap: "12px", + flexWrap: "wrap", + alignItems: "center", + }, + pill: { + textTransform: "uppercase", + letterSpacing: "0.12em", + fontWeight: 700, + color: tokens.textMuted, + }, + pillHardware: { + textTransform: "uppercase", + letterSpacing: "0.12em", + fontWeight: 700, + color: tokens.up, + }, + monitorUrl: { + fontFamily: MONO_STACK, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: 280, + fontSize: 12, + }, + + badge: (tone) => ({ + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + fontSize: 10, + fontWeight: 700, + padding: "4px 10px", + borderRadius: 0, + textTransform: "uppercase", + letterSpacing: "0.15em", + whiteSpace: "nowrap", + background: toneSoft(tone, tokens), + color: toneColor(tone, tokens), + }), + + heatmap: { + mt: "16px", + display: "grid", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, + gap: "2px", + height: 40, + }, + heatmapCell: (kind) => ({ + background: heatCellBg[kind], + opacity: kind === "empty" ? 0.5 : 1, + }), + + histogram: { + mt: "16px", + display: "grid", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, + gap: "2px", + alignItems: "flex-end", + height: 40, + }, + bar: (kind, heightPct) => ({ + background: barBg[kind], + minHeight: 3, + opacity: kind === "empty" ? 0.5 : 1, + height: `${heightPct}%`, + }), + chartStats: { + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + fontSize: 11, + color: tokens.textMuted, + fontVariantNumeric: "tabular-nums", + textTransform: "uppercase", + letterSpacing: "0.1em", + }, + + infra: { + display: "grid", + gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, + gap: "24px", + mt: "20px", + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + }, + infraEmpty: { mt: "20px", color: tokens.textMuted, fontSize: 13 }, + gauge: {}, + gaugeLabel: { + fontSize: 10, + color: tokens.textMuted, + textTransform: "uppercase", + letterSpacing: "0.18em", + fontWeight: 700, + }, + gaugeValue: { + fontSize: 28, + fontWeight: 700, + letterSpacing: "-0.02em", + mt: "4px", + fontVariantNumeric: "tabular-nums", + fontFamily: SERIF_STACK, + color: tokens.text, + }, + gaugeBar: { height: 2, background: tokens.border, mt: "10px" }, + gaugeFill: (level, widthPct) => ({ + display: "block", + height: "100%", + background: gaugeFillBg[level], + transition: "width 0.8s", + width: `${Math.max(0, Math.min(100, widthPct))}%`, + }), + gaugeSub: { + fontSize: 11, + color: tokens.textMuted, + mt: "8px", + fontVariantNumeric: "tabular-nums", + }, + + footer: { + textAlign: "center", + color: tokens.textMuted, + fontSize: 11, + mt: "56px", + textTransform: "uppercase", + letterSpacing: "0.2em", + fontFamily: EDITORIAL_SECONDARY_SANS_STACK, + "& a": { + color: tokens.text, + textDecoration: "none", + borderBottom: `1px solid ${tokens.text}`, + pb: "2px", + fontWeight: 700, + }, + }, + }; +}; 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 new file mode 100644 index 000000000..1ee3af741 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts @@ -0,0 +1,429 @@ +import type { SxProps, Theme } from "@mui/material/styles"; +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"; +export type ModernGaugeFill = "ok" | "warm" | "hot"; + +export interface ModernStyles { + page: SxProps; + top: SxProps; + brand: SxProps; + logoGrad: SxProps; + logoImg: SxProps; + hero: SxProps; + pulse: (tone: OverallTone) => SxProps; + statusCopy: SxProps; + heroTitle: SxProps; + heroSub: SxProps; + heroIcon: (tone: OverallTone) => 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: ModernHeatCell) => SxProps; + histogram: SxProps; + bar: (kind: ModernBarKind, heightPct: number) => SxProps; + chartStats: SxProps; + infra: SxProps; + infraEmpty: SxProps; + gauge: SxProps; + gaugeLabel: SxProps; + gaugeValue: SxProps; + gaugeBar: SxProps; + gaugeFill: (level: ModernGaugeFill, widthPct: number) => SxProps; + gaugeSub: SxProps; + footer: SxProps; +} + +const lightShadow = + "0 2px 4px rgba(16, 24, 40, 0.04), 0 12px 32px rgba(16, 24, 40, 0.06)"; +const darkShadow = "0 2px 6px rgba(0, 0, 0, 0.5), 0 20px 40px rgba(0, 0, 0, 0.4)"; +const lightShadowHover = + "0 4px 8px rgba(16, 24, 40, 0.05), 0 16px 40px rgba(16, 24, 40, 0.08)"; +const darkShadowHover = "0 4px 10px rgba(0, 0, 0, 0.55), 0 24px 48px rgba(0, 0, 0, 0.45)"; + +const pillBase = { + fontSize: 10, + textTransform: "uppercase" as const, + letterSpacing: "0.08em", + padding: "3px 9px", + borderRadius: "999px", + fontWeight: 600, +}; + +const pulseKeyframes = keyframes` + 0% { transform: scale(1); opacity: 0.5; } + 100% { transform: scale(2.4); opacity: 0; } +`; + +const fadeInKeyframes = keyframes` + to { opacity: 1; transform: translateY(0); } +`; + +export const modernStyles = ( + tokens: StatusPageThemeTokens, + isDark: boolean +): ModernStyles => { + const cardShadow = isDark ? darkShadow : lightShadow; + const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover; + + const heatCellBg: Record = { + fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`, + med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`, + slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`, + down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`, + empty: tokens.border, + }; + + const barBg: Record = { + up: tokens.up, + down: tokens.down, + empty: tokens.border, + }; + + const gaugeFillBg: Record = { + ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`, + warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`, + hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`, + }; + + return { + page: { + flex: "1 0 auto", + maxWidth: 980, + width: "100%", + mx: "auto", + p: "56px 20px 80px", + fontFamily: SANS_STACK, + fontSize: 14, + lineHeight: 1.5, + color: tokens.text, + WebkitFontSmoothing: "antialiased", + }, + + top: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + mb: "32px", + }, + brand: { + display: "flex", + alignItems: "center", + gap: "12px", + fontWeight: 700, + fontSize: 15, + letterSpacing: "-0.01em", + color: tokens.text, + }, + logoGrad: { + width: 32, + height: 32, + borderRadius: "10px", + background: `linear-gradient(135deg, ${tokens.up}, ${tokens.upStrong})`, + display: "grid", + placeItems: "center", + color: "#fff", + fontWeight: 700, + fontSize: 14, + boxShadow: `0 4px 10px color-mix(in srgb, ${tokens.up} 40%, transparent)`, + }, + logoImg: { maxHeight: 32, maxWidth: 120, objectFit: "contain" }, + + hero: { + position: "relative", + borderRadius: "20px", + padding: "28px 32px", + mb: "24px", + display: "flex", + alignItems: "center", + gap: "20px", + background: tokens.surface, + border: `1px solid ${tokens.border}`, + boxShadow: cardShadow, + }, + pulse: (tone) => { + const c = toneColor(tone, tokens); + return { + position: "relative", + width: 14, + height: 14, + borderRadius: "50%", + background: c, + color: c, + flexShrink: 0, + "&::before, &::after": { + content: '""', + position: "absolute", + inset: 0, + borderRadius: "50%", + background: "currentColor", + opacity: 0.5, + animation: `${pulseKeyframes} 2s ease-out infinite`, + }, + "&::after": { animationDelay: "1s" }, + "@media (prefers-reduced-motion: reduce)": { + "&::before, &::after": { animation: "none" }, + }, + }; + }, + statusCopy: { flex: 1, minWidth: 0 }, + heroTitle: { + m: 0, + mb: "4px", + fontSize: 22, + fontWeight: 700, + letterSpacing: "-0.02em", + color: tokens.text, + }, + heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 }, + heroIcon: (tone) => ({ + color: toneColor(tone, tokens), + display: "flex", + alignItems: "center", + }), + + chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "14px" }, + chartSwitch: { + display: "inline-flex", + border: `1px solid ${tokens.border}`, + borderRadius: "999px", + background: tokens.surface, + p: "3px", + gap: "2px", + }, + chartSwitchButton: (active) => ({ + border: 0, + background: active ? tokens.upSoft : "transparent", + fontFamily: "inherit", + fontSize: 11, + padding: "6px 16px", + cursor: "pointer", + color: active ? tokens.up : tokens.textMuted, + fontWeight: 600, + textTransform: "uppercase", + letterSpacing: "0.08em", + transition: "background 0.2s, color 0.2s", + borderRadius: "999px", + "&:hover": { color: active ? tokens.up : tokens.text }, + }), + + monitorList: { + listStyle: "none", + m: 0, + p: 0, + display: "flex", + flexDirection: "column", + gap: "14px", + }, + card: { + background: tokens.surface, + border: `1px solid ${tokens.border}`, + borderRadius: "16px", + boxShadow: cardShadow, + overflow: "hidden", + transition: "transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.2s", + opacity: 0, + transform: "translateY(8px)", + animation: `${fadeInKeyframes} 0.5s forwards`, + "&:nth-of-type(1)": { animationDelay: "0.05s" }, + "&:nth-of-type(2)": { animationDelay: "0.1s" }, + "&:nth-of-type(3)": { animationDelay: "0.15s" }, + "&:nth-of-type(4)": { animationDelay: "0.2s" }, + "&:nth-of-type(5)": { animationDelay: "0.25s" }, + "&:nth-of-type(n+6)": { animationDelay: "0.3s" }, + "&:hover": { transform: "translateY(-2px)", boxShadow: cardHoverShadow }, + "@media (prefers-reduced-motion: reduce)": { + animation: "none", + opacity: 1, + transform: "none", + }, + }, + cardRow: { + display: "grid", + gridTemplateColumns: "1fr auto", + alignItems: "center", + gap: "20px", + p: "18px 24px", + }, + cardLeft: { minWidth: 0 }, + monitorName: { + fontWeight: 600, + fontSize: 15, + letterSpacing: "-0.005em", + color: tokens.text, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + monitorMeta: { + display: "flex", + gap: "10px", + alignItems: "center", + mt: "6px", + flexWrap: "wrap", + }, + pill: { + ...pillBase, + color: tokens.textMuted, + background: tokens.bg, + }, + pillHardware: { + ...pillBase, + color: tokens.up, + background: tokens.upSoft, + }, + monitorUrl: { + fontSize: 12, + color: tokens.textMuted, + fontFamily: MONO_STACK, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: 280, + }, + + badge: (tone) => ({ + fontSize: 11, + fontWeight: 700, + padding: "5px 12px", + borderRadius: "999px", + display: "inline-flex", + alignItems: "center", + gap: "6px", + whiteSpace: "nowrap", + background: toneSoft(tone, tokens), + color: toneColor(tone, tokens), + "&::before": { + content: '""', + width: 6, + height: 6, + borderRadius: "50%", + background: "currentColor", + }, + }), + + heatmap: { + padding: "0 24px 20px", + display: "grid", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, + gap: "3px", + height: 46, + }, + heatmapCell: (kind) => ({ + borderRadius: "3px", + background: heatCellBg[kind], + opacity: kind === "empty" ? 0.4 : 1, + transition: "transform 0.15s", + "&:hover": { transform: "scaleY(1.2)" }, + }), + + histogram: { + padding: "0 24px", + display: "grid", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, + gap: "3px", + alignItems: "flex-end", + height: 46, + }, + bar: (kind, heightPct) => ({ + background: barBg[kind], + borderRadius: "3px", + minHeight: 4, + opacity: kind === "empty" ? 0.4 : 1, + height: `${heightPct}%`, + transition: "transform 0.15s", + "&:hover": { transform: "scaleY(1.08)" }, + }), + chartStats: { + padding: "0 24px 20px", + fontSize: 11, + color: tokens.textMuted, + fontVariantNumeric: "tabular-nums", + fontWeight: 600, + }, + + infra: { + padding: "16px 24px 22px", + display: "grid", + gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, + gap: "14px", + }, + infraEmpty: { + padding: "16px 24px 22px", + color: tokens.textMuted, + fontSize: 13, + }, + gauge: { + border: `1px solid ${tokens.border}`, + borderRadius: "14px", + p: "14px 16px", + background: `linear-gradient(135deg, ${tokens.bg}, ${tokens.surface})`, + }, + gaugeLabel: { + fontSize: 10, + color: tokens.textMuted, + textTransform: "uppercase", + letterSpacing: "0.1em", + fontWeight: 700, + }, + gaugeValue: { + fontSize: 26, + fontWeight: 700, + letterSpacing: "-0.02em", + mt: "4px", + fontVariantNumeric: "tabular-nums", + color: tokens.text, + }, + gaugeBar: { + height: 6, + background: tokens.border, + borderRadius: "3px", + overflow: "hidden", + mt: "10px", + }, + gaugeFill: (level, widthPct) => ({ + display: "block", + height: "100%", + background: gaugeFillBg[level], + borderRadius: "3px", + transition: "width 0.8s cubic-bezier(0.2, 0.8, 0.2, 1)", + width: `${Math.max(0, Math.min(100, widthPct))}%`, + }), + gaugeSub: { + fontSize: 11, + color: tokens.textMuted, + mt: "8px", + fontVariantNumeric: "tabular-nums", + }, + + footer: { + textAlign: "center", + color: tokens.textMuted, + fontSize: 12, + mt: "48px", + "& a": { + color: tokens.up, + textDecoration: "underline", + textUnderlineOffset: "3px", + fontWeight: 700, + "&:hover": { color: tokens.upStrong }, + }, + }, + }; +}; 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/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts new file mode 100644 index 000000000..a6a52c021 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts @@ -0,0 +1,368 @@ +import type { SxProps, Theme } from "@mui/material/styles"; +import type { StatusPageThemeTokens } from "../tokens"; +import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; +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; + top: SxProps; + brand: SxProps; + logoMono: SxProps; + logoImg: SxProps; + company: SxProps; + hero: SxProps; + statusDot: (tone: OverallTone) => SxProps; + statusCopy: SxProps; + heroTitle: SxProps; + heroSub: SxProps; + heroIcon: (tone: OverallTone) => 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: RefinedHeatCell) => SxProps; + histogram: SxProps; + bar: (kind: RefinedBarKind, heightPct: number) => SxProps; + chartStats: SxProps; + infra: SxProps; + infraEmpty: SxProps; + gauge: SxProps; + gaugeLabel: SxProps; + gaugeValue: SxProps; + gaugeBar: SxProps; + gaugeFill: (level: RefinedGaugeFill, widthPct: number) => SxProps; + gaugeSub: SxProps; + footer: SxProps; +} + +const cardShadow = "0 1px 2px rgba(10, 16, 32, 0.06), 0 6px 18px rgba(10, 16, 32, 0.08)"; +const cardShadowHover = + "0 2px 4px rgba(16, 24, 40, 0.06), 0 10px 24px rgba(16, 24, 40, 0.06)"; + +const pillBase = { + fontSize: 10, + textTransform: "uppercase" as const, + letterSpacing: "0.08em", + padding: "2px 8px", + borderRadius: "999px", + fontWeight: 600, +}; + +export const refinedStyles = ( + tokens: StatusPageThemeTokens, + _isDark: boolean +): RefinedStyles => { + const heatCellBg: Record = { + fast: tokens.up, + med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`, + slow: tokens.warn, + down: tokens.down, + empty: tokens.border, + }; + + const barBg: Record = { + up: tokens.up, + down: tokens.down, + empty: tokens.border, + }; + + const gaugeFillBg: Record = { + ok: tokens.up, + warm: tokens.warn, + hot: tokens.down, + }; + + return { + page: { + flex: "1 0 auto", + maxWidth: 960, + width: "100%", + mx: "auto", + p: "48px 20px 80px", + fontFamily: SANS_STACK, + fontSize: 14, + lineHeight: 1.5, + color: tokens.text, + WebkitFontSmoothing: "antialiased", + }, + + top: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + mb: "28px", + }, + brand: { + display: "flex", + alignItems: "center", + gap: "10px", + fontWeight: 600, + letterSpacing: "-0.01em", + color: tokens.text, + }, + logoMono: { + width: 28, + height: 28, + borderRadius: "8px", + background: tokens.up, + display: "grid", + placeItems: "center", + color: "#fff", + fontWeight: 700, + fontSize: 13, + }, + logoImg: { maxHeight: 32, maxWidth: 120, objectFit: "contain" }, + company: { fontSize: 14 }, + + hero: { + background: tokens.surface, + border: `1px solid ${tokens.border}`, + borderRadius: tokens.radius, + padding: "22px 24px", + display: "flex", + alignItems: "center", + gap: "16px", + boxShadow: cardShadow, + mb: "20px", + }, + statusDot: (tone) => ({ + width: 10, + height: 10, + borderRadius: "50%", + background: toneColor(tone, tokens), + boxShadow: `0 0 0 4px ${toneSoft(tone, tokens)}`, + flexShrink: 0, + }), + statusCopy: { flex: 1, minWidth: 0 }, + heroTitle: { + m: 0, + mb: "2px", + fontSize: 17, + fontWeight: 600, + letterSpacing: "-0.01em", + color: tokens.text, + }, + heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 }, + heroIcon: (tone) => ({ + color: toneColor(tone, tokens), + display: "flex", + alignItems: "center", + }), + + chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "12px" }, + chartSwitch: { + display: "inline-flex", + border: `1px solid ${tokens.border}`, + borderRadius: "8px", + background: tokens.surface, + p: "3px", + gap: "2px", + }, + chartSwitchButton: (active) => ({ + border: 0, + background: active ? tokens.upSoft : "transparent", + fontFamily: "inherit", + fontSize: 11, + padding: "5px 14px", + cursor: "pointer", + color: active ? tokens.up : tokens.textMuted, + borderRadius: "5px", + transition: "background 0.15s ease, color 0.15s ease", + fontWeight: active ? 600 : 500, + "&:hover": { color: active ? tokens.up : tokens.text }, + }), + + monitorList: { + listStyle: "none", + m: 0, + p: 0, + display: "flex", + flexDirection: "column", + gap: "12px", + }, + card: { + background: tokens.surface, + border: `1px solid ${tokens.border}`, + borderRadius: tokens.radius, + boxShadow: cardShadow, + overflow: "hidden", + position: "relative", + transition: "transform 0.15s, box-shadow 0.15s", + "&:hover": { transform: "translateY(-1px)", boxShadow: cardShadowHover }, + }, + cardRow: { + display: "grid", + gridTemplateColumns: "1fr auto", + alignItems: "center", + gap: "16px", + p: "16px 20px", + }, + cardLeft: { minWidth: 0 }, + monitorName: { + fontWeight: 600, + fontSize: 14, + letterSpacing: "-0.005em", + color: tokens.text, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + monitorMeta: { + display: "flex", + gap: "10px", + alignItems: "center", + mt: "4px", + flexWrap: "wrap", + }, + pill: { + ...pillBase, + color: tokens.textMuted, + border: `1px solid ${tokens.border}`, + }, + pillHardware: { + ...pillBase, + color: tokens.up, + border: `1px solid ${tokens.border}`, + background: tokens.upSoft, + }, + monitorUrl: { + fontSize: 12, + color: tokens.textMuted, + fontFamily: MONO_STACK, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: 280, + }, + + badge: (tone) => ({ + fontSize: 11, + fontWeight: 600, + padding: "4px 10px", + borderRadius: "999px", + whiteSpace: "nowrap", + background: toneSoft(tone, tokens), + color: toneColor(tone, tokens), + }), + + heatmap: { + padding: "0 20px 16px", + display: "grid", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, + gap: "3px", + height: 42, + }, + heatmapCell: (kind) => ({ + borderRadius: "2px", + background: heatCellBg[kind], + opacity: kind === "empty" ? 0.4 : 1, + transition: "transform 0.15s", + "&:hover": { transform: "scaleY(1.15)" }, + }), + + histogram: { + padding: "0 20px", + display: "grid", + gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, + gap: "3px", + alignItems: "flex-end", + height: 42, + }, + bar: (kind, heightPct) => ({ + background: barBg[kind], + borderRadius: "2px", + minHeight: 3, + opacity: kind === "empty" ? 0.4 : 1, + height: `${heightPct}%`, + }), + chartStats: { + padding: "0 20px 16px", + fontSize: 11, + color: tokens.textMuted, + fontVariantNumeric: "tabular-nums", + }, + + infra: { + padding: "14px 20px 18px", + display: "grid", + gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, + gap: "12px", + }, + infraEmpty: { + padding: "14px 20px 18px", + color: tokens.textMuted, + fontSize: 13, + }, + gauge: { + border: `1px solid ${tokens.border}`, + borderRadius: "10px", + p: "12px 14px", + background: tokens.bg, + }, + gaugeLabel: { + fontSize: 11, + color: tokens.textMuted, + textTransform: "uppercase", + letterSpacing: "0.08em", + fontWeight: 600, + }, + gaugeValue: { + fontSize: 20, + fontWeight: 600, + letterSpacing: "-0.01em", + mt: "2px", + fontVariantNumeric: "tabular-nums", + color: tokens.text, + }, + gaugeBar: { + height: 4, + background: tokens.border, + borderRadius: "2px", + overflow: "hidden", + mt: "8px", + }, + gaugeFill: (level, widthPct) => ({ + display: "block", + height: "100%", + background: gaugeFillBg[level], + borderRadius: "2px", + transition: "width 0.6s", + width: `${Math.max(0, Math.min(100, widthPct))}%`, + }), + gaugeSub: { + fontSize: 11, + color: tokens.textMuted, + mt: "6px", + fontVariantNumeric: "tabular-nums", + }, + + footer: { + textAlign: "center", + color: tokens.textMuted, + fontSize: 12, + mt: "40px", + "& a": { + color: tokens.up, + textDecoration: "underline", + textUnderlineOffset: "3px", + fontWeight: 600, + "&:hover": { color: tokens.upStrong || tokens.up }, + }, + }, + }; +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx new file mode 100644 index 000000000..71aa85a89 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -0,0 +1,250 @@ +import { useMemo, useState } from "react"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTranslation } from "react-i18next"; +import type { SxProps, Theme } from "@mui/material/styles"; +import type { Monitor } from "@/Types/Monitor"; +import type { StatusPage } from "@/Types/StatusPage"; +import { getMonitorTypeLabel } from "@/Types/StatusPage"; +import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens"; +import { + ThemedHeatmap, + 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, + resolveOverallStatus, + statusBadgeKey, +} 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 BaseStatusPage = ({ statusPage, monitors, config }: Props) => { + const { t } = useTranslation(); + const { tokens, mode } = useStatusPageTheme(); + const styles = useMemo( + () => config.createStyles(tokens, mode === "dark"), + [config, tokens, mode] + ); + const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); + + 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 ( + + + + + + + + {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/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx new file mode 100644 index 000000000..fe56155c3 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -0,0 +1,90 @@ +import Tooltip from "@mui/material/Tooltip"; +import Box from "@mui/material/Box"; +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 type { CheckSnapshot } from "@/Types/Check"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; +import { useStatusPageTheme } from "../StatusPageThemeProvider"; + +const CELLS = MAX_RECENT_CHECKS; + +export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty"; + +interface Props { + checks: CheckSnapshot[]; + containerSx: SxProps; + cellSx: (kind: HeatCellKind) => SxProps; +} + +const classify = (check: CheckSnapshot): Exclude => { + if (!check.status) return "down"; + const rt = check.responseTime ?? 0; + if (rt > 500) return "slow"; + if (rt > 250) return "med"; + return "fast"; +}; + +export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { + const { t } = useTranslation(); + const { timezone } = useStatusPageTheme(); + + const source = checks.slice(-CELLS); + const padded: (CheckSnapshot | null)[] = [ + ...source, + ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), + ]; + + return ( + + {padded.map((check, i) => { + if (!check) { + return ( + + ); + } + const kind = classify(check); + const tooltipContent = ( + + + {check.status + ? `${check.responseTime} ms` + : t("pages.statusPages.monitorsList.chart.downTooltip")} + + + {formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)} + + + ); + return ( + + + + ); + })} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx new file mode 100644 index 000000000..9d06a99ac --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -0,0 +1,115 @@ +import { useMemo } from "react"; +import Tooltip from "@mui/material/Tooltip"; +import Box from "@mui/material/Box"; +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 type { CheckSnapshot } from "@/Types/Check"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; +import { useStatusPageTheme } from "../StatusPageThemeProvider"; +const CELLS = MAX_RECENT_CHECKS; +const MIN_HEIGHT_PCT = 6; + +export type BarKind = "up" | "down" | "empty"; + +interface Props { + checks: CheckSnapshot[]; + containerSx: SxProps; + barSx: (kind: BarKind, heightPct: number) => SxProps; + statsSx: SxProps; + statsGap?: number; +} + +const tone = (check: CheckSnapshot): Exclude => + check.status ? "up" : "down"; + +export const ThemedHistogram = ({ + checks, + containerSx, + barSx, + statsSx, + statsGap = 1, +}: Props) => { + const { t } = useTranslation(); + const { timezone } = useStatusPageTheme(); + + const { padded, max, avg, peak } = useMemo(() => { + const source = checks.slice(-CELLS); + const out: (CheckSnapshot | null)[] = [ + ...source, + ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), + ]; + const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0); + const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1; + const avgRt = valid.length + ? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length) + : 0; + return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 }; + }, [checks]); + + return ( + + + {padded.map((check, i) => { + if (!check) { + return ( + + ); + } + const height = Math.max( + MIN_HEIGHT_PCT, + Math.round((check.responseTime / max) * 100) + ); + const tooltipContent = ( + + + {check.status + ? `${check.responseTime} ms` + : t("pages.statusPages.monitorsList.chart.downTooltip")} + + + {formatDateWithTz( + check.createdAt, + "ddd, MMMM D, YYYY, HH:mm A", + timezone + )} + + + ); + return ( + + + + ); + })} + + + {t("pages.statusPages.monitorsList.chart.avg", { value: avg })} + {t("pages.statusPages.monitorsList.chart.max", { value: peak })} + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx new file mode 100644 index 000000000..01804a322 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx @@ -0,0 +1,105 @@ +import Box from "@mui/material/Box"; +import prettyBytes from "pretty-bytes"; +import type { SxProps, Theme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import type { Monitor } from "@/Types/Monitor"; + +export type GaugeFillLevel = "ok" | "warm" | "hot"; + +export interface InfraSx { + containerSx: SxProps; + emptySx: SxProps; + gaugeSx: SxProps; + gaugeLabelSx: SxProps; + gaugeValueSx: SxProps; + gaugeBarSx: SxProps; + gaugeFillSx: (level: GaugeFillLevel, widthPct: number) => SxProps; + gaugeSubSx: SxProps; +} + +interface Props { + monitor: Monitor & { checks?: Monitor["recentChecks"] }; + sxApi: InfraSx; +} + +interface Gauge { + key: string; + label: string; + value: number; + sub: string; +} + +const PCT = 100; + +const heatLevel = (value: number): GaugeFillLevel => + value > 85 ? "hot" : value > 70 ? "warm" : "ok"; + +export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => { + const { t } = useTranslation(); + const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0]; + + const renderEmpty = () => ( + {t("pages.statusPages.monitorsList.noData")} + ); + + if (!latest) return renderEmpty(); + + const gauges: Gauge[] = []; + + if (typeof latest.cpu?.usage_percent === "number") { + const pct = latest.cpu.usage_percent * PCT; + gauges.push({ + key: "cpu", + label: t("pages.statusPages.monitorsList.infrastructure.cpu"), + value: pct, + sub: `${pct.toFixed(0)}%`, + }); + } + + if ( + typeof latest.memory?.usage_percent === "number" && + typeof latest.memory?.used_bytes === "number" && + typeof latest.memory?.total_bytes === "number" + ) { + gauges.push({ + key: "memory", + label: t("pages.statusPages.monitorsList.infrastructure.memory"), + value: latest.memory.usage_percent * PCT, + sub: `${prettyBytes(latest.memory.used_bytes)} / ${prettyBytes(latest.memory.total_bytes)}`, + }); + } + + if (latest.disk && latest.disk.length > 0) { + const disks = latest.disk; + const avg = + (disks.reduce((acc, d) => acc + (d?.usage_percent ?? 0), 0) / disks.length) * PCT; + const used = disks.reduce((acc, d) => acc + (d?.used_bytes ?? 0), 0); + const total = disks.reduce((acc, d) => acc + (d?.total_bytes ?? 0), 0); + gauges.push({ + key: "disk", + label: t("pages.statusPages.monitorsList.infrastructure.disk"), + value: avg, + sub: `${prettyBytes(used)} / ${prettyBytes(total)}`, + }); + } + + if (gauges.length === 0) return renderEmpty(); + + return ( + + {gauges.map((g) => ( + + {g.label} + {g.value.toFixed(0)}% + + + + {g.sub} + + ))} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts b/client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts new file mode 100644 index 000000000..2ad63fd07 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts @@ -0,0 +1,11 @@ +export const SANS_STACK = + '-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif'; + +export const BOLD_SANS_STACK = `ui-sans-serif, ${SANS_STACK}`; + +export const SERIF_STACK = 'Georgia, "Iowan Old Style", "Palatino Linotype", serif'; + +export const EDITORIAL_SECONDARY_SANS_STACK = + '-apple-system, "Helvetica Neue", Arial, sans-serif'; + +export const MONO_STACK = "ui-monospace, Menlo, monospace"; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx b/client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx new file mode 100644 index 000000000..51681bd79 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx @@ -0,0 +1,167 @@ +import type { ReactNode } from "react"; +import { + AlertTriangle, + CircleCheck, + CircleX, + Loader, + PauseCircle, + ShieldAlert, + Wrench, +} from "lucide-react"; +import type { Monitor, MonitorStatus } from "@/Types/Monitor"; +import type { StatusPageThemeTokens } from "../tokens"; + +export type OverallTone = "up" | "warn" | "down"; + +export const toneColor = (tone: OverallTone, t: StatusPageThemeTokens): string => + tone === "down" ? t.down : tone === "warn" ? t.warn : t.up; + +export const toneSoft = (tone: OverallTone, t: StatusPageThemeTokens): string => + tone === "down" ? t.downSoft : tone === "warn" ? t.warnSoft : t.upSoft; + +export interface OverallStatus { + tone: OverallTone; + message: string; + icon: ReactNode; +} + +type StatusPageMonitor = Pick; + +interface Options { + iconSize?: number; + // Optional override for the `allUp` message key (editorial uses a more + // formal sentence). + allUpKey?: string; +} + +export const resolveOverallStatus = ( + monitors: StatusPageMonitor[], + t: (key: string) => string, + options: Options = {} +): OverallStatus => { + const size = options.iconSize ?? 18; + const allUpMessage = t(options.allUpKey ?? "pages.statusPages.statusBar.allUp"); + + if (monitors.length === 0) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.noMonitors"), + icon: , + }; + } + + const allOf = (...statuses: MonitorStatus[]) => + monitors.every((m) => statuses.includes(m.status)); + const someOf = (...statuses: MonitorStatus[]) => + monitors.some((m) => statuses.includes(m.status)); + const noneOf = (...statuses: MonitorStatus[]) => + monitors.every((m) => !statuses.includes(m.status)); + + if (allOf("up")) { + return { tone: "up", message: allUpMessage, icon: }; + } + if (allOf("breached")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.allBreached"), + icon: , + }; + } + if (allOf("maintenance")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.allMaintenance"), + icon: , + }; + } + if (allOf("down")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.allDown"), + icon: , + }; + } + if (allOf("paused")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.allPaused"), + icon: , + }; + } + if (allOf("initializing")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.allInitializing"), + icon: , + }; + } + if (someOf("breached") && someOf("down")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.breachedAndDown"), + icon: , + }; + } + if (someOf("breached")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.breached"), + icon: , + }; + } + if (someOf("maintenance") && someOf("down")) { + return { + tone: "down", + message: t("pages.statusPages.statusBar.maintenanceAndDown"), + icon: , + }; + } + if (someOf("maintenance") && noneOf("down")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.maintenance"), + icon: , + }; + } + if (someOf("down")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.degraded"), + icon: , + }; + } + if (someOf("paused")) { + return { + tone: "warn", + message: t("pages.statusPages.statusBar.partiallyPaused"), + icon: , + }; + } + if (someOf("initializing")) { + return { + tone: "up", + message: t("pages.statusPages.statusBar.initializing"), + icon: , + }; + } + return { + tone: "warn", + message: t("pages.statusPages.statusBar.unknown"), + icon: , + }; +}; + +export const statusBadgeKey: Record = { + up: "pages.statusPages.monitorsList.status.up", + down: "pages.statusPages.monitorsList.status.down", + breached: "pages.statusPages.monitorsList.status.breached", + maintenance: "pages.statusPages.monitorsList.status.maintenance", + paused: "pages.statusPages.monitorsList.status.paused", + initializing: "pages.statusPages.monitorsList.status.initializing", +}; + +export const monoFirstChar = (s?: string): string => + (s?.trim().charAt(0) || "?").toUpperCase(); + +export const monitorBadgeTone = (status: MonitorStatus): OverallTone => + status === "up" ? "up" : status === "down" || status === "breached" ? "down" : "warn"; diff --git a/client/src/Pages/StatusPage/Status/themes/tokens.ts b/client/src/Pages/StatusPage/Status/themes/tokens.ts new file mode 100644 index 000000000..f84b6f100 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/tokens.ts @@ -0,0 +1,199 @@ +import type { StatusPageTheme } from "@/Types/StatusPage"; + +export interface StatusPageThemeTokens { + bg: string; + surface: string; + border: string; + text: string; + textMuted: string; + up: string; + upStrong: string; + upSoft: string; + degraded: string; + degradedSoft: string; + down: string; + downSoft: string; + warn: string; + warnSoft: string; + radius: string; + fontFamily?: string; + headingFontFamily?: string; + headingWeight?: number; + headingLetterSpacing?: string; + chartBarRadius?: string; + pulseStatusDot?: boolean; + staggeredCardFadeIn?: boolean; + conicLogo?: boolean; + heroSize?: "default" | "large"; + cardStyle?: "card" | "hairline"; +} + +type ThemeVariants = { light: StatusPageThemeTokens; dark: StatusPageThemeTokens }; + +const refined: ThemeVariants = { + light: { + bg: "#f4f6fa", + surface: "#ffffff", + border: "#dce1ea", + text: "#0a1020", + textMuted: "#4b5768", + up: "#0f8a6d", + upStrong: "#0c7359", + upSoft: "#d6f1e6", + degraded: "#c2630a", + degradedSoft: "#fde7bf", + down: "#d11f2f", + downSoft: "#fdd9dc", + warn: "#e08a0b", + warnSoft: "#fdebc5", + radius: "12px", + }, + dark: { + bg: "#070b11", + surface: "#131a24", + border: "#253142", + text: "#f1f5fb", + textMuted: "#9aa7bd", + up: "#2fd7a2", + upStrong: "#1fb487", + upSoft: "rgba(47,215,162,0.15)", + degraded: "#e69138", + degradedSoft: "rgba(230,145,56,0.18)", + down: "#ff5b6b", + downSoft: "rgba(255,91,107,0.18)", + warn: "#f0a837", + warnSoft: "rgba(240,168,55,0.18)", + radius: "12px", + }, +}; + +const modern: ThemeVariants = { + light: { + bg: "#fafbfc", + surface: "#ffffff", + border: "#eceef2", + text: "#0b1220", + textMuted: "#6b7280", + up: "#10a37f", + upStrong: "#0b8a6a", + upSoft: "#d7f3e9", + degraded: "#d97706", + degradedSoft: "#fef3c7", + down: "#dc2626", + downSoft: "#fee2e2", + warn: "#f59e0b", + warnSoft: "#fef3c7", + radius: "16px", + pulseStatusDot: true, + staggeredCardFadeIn: true, + }, + dark: { + bg: "#0a0d12", + surface: "#10151c", + border: "#1b2430", + text: "#e8edf5", + textMuted: "#8a95a8", + up: "#10a37f", + upStrong: "#0b8a6a", + upSoft: "rgba(16,163,127,0.18)", + degraded: "#d97706", + degradedSoft: "rgba(217,119,6,0.22)", + down: "#dc2626", + downSoft: "rgba(220,38,38,0.22)", + warn: "#f59e0b", + warnSoft: "rgba(245,158,11,0.2)", + radius: "16px", + pulseStatusDot: true, + staggeredCardFadeIn: true, + }, +}; + +const bold: ThemeVariants = { + light: { + bg: "#f5f6f9", + surface: "#ffffff", + border: "#e6e8ee", + text: "#0b1220", + textMuted: "#6b7280", + up: "#10a37f", + upStrong: "#0b8a6a", + upSoft: "rgba(16,163,127,0.12)", + degraded: "#f59e0b", + degradedSoft: "rgba(245,158,11,0.14)", + down: "#f43f5e", + downSoft: "rgba(244,63,94,0.12)", + warn: "#f59e0b", + warnSoft: "rgba(245,158,11,0.14)", + radius: "18px", + heroSize: "large", + conicLogo: true, + }, + dark: { + bg: "#05070a", + surface: "#0c1117", + border: "#1a2330", + text: "#eef2f7", + textMuted: "#7a8699", + up: "#22d3a6", + upStrong: "#10a37f", + upSoft: "rgba(34,211,166,0.14)", + degraded: "#f59e0b", + degradedSoft: "rgba(245,158,11,0.14)", + down: "#f43f5e", + downSoft: "rgba(244,63,94,0.16)", + warn: "#f59e0b", + warnSoft: "rgba(245,158,11,0.14)", + radius: "18px", + heroSize: "large", + conicLogo: true, + }, +}; + +const editorial: ThemeVariants = { + light: { + bg: "#fbfaf7", + surface: "#ffffff", + // Darkened from mockup's #e9e4d8 (~1.3:1 on #fbfaf7) to pass WCAG AA 3:1 for UI borders. + border: "#d4cdb8", + text: "#1a1a1a", + textMuted: "#6b675e", + up: "#2c6a4f", + upStrong: "#205239", + upSoft: "#dfeee4", + degraded: "#a46200", + degradedSoft: "#f7ecd4", + down: "#9a2a2a", + downSoft: "#f5dede", + warn: "#a46200", + warnSoft: "#f7ecd4", + radius: "6px", + headingFontFamily: 'Georgia, "Iowan Old Style", "Palatino Linotype", serif', + cardStyle: "hairline", + }, + dark: { + bg: "#141310", + surface: "#1c1a15", + border: "#3a352b", + text: "#f0ece3", + textMuted: "#9e988a", + up: "#6fbf96", + upStrong: "#4fa37a", + upSoft: "rgba(44,106,79,0.22)", + degraded: "#d79a3b", + degradedSoft: "rgba(164,98,0,0.22)", + down: "#d87a7a", + downSoft: "rgba(154,42,42,0.22)", + warn: "#d79a3b", + warnSoft: "rgba(164,98,0,0.22)", + radius: "6px", + headingFontFamily: 'Georgia, "Iowan Old Style", "Palatino Linotype", serif', + cardStyle: "hairline", + }, +}; + +export const themeTokens: Record = { + refined, + modern, + bold, + editorial, +}; diff --git a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx index 8f72931d6..fccf6105b 100644 --- a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx +++ b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx @@ -2,14 +2,16 @@ import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { Table, type Header, ValueLabel } from "@/Components/design-elements"; +import { Pagination } from "@/Components/design-elements/Table"; import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; +import { useClientPagination } from "@/Hooks/useClientPagination"; import { ExternalLink } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; import { useNavigate } from "react-router-dom"; -import type { StatusPage } from "@/Types/StatusPage"; +import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage"; interface StatusPagesTableProps { data: StatusPage[]; @@ -23,6 +25,7 @@ export const StatusPagesTable = ({ const { t } = useTranslation(); const theme = useTheme(); const navigate = useNavigate(); + const { pagedRows, paginationProps } = useClientPagination(data); const getActions = (row: StatusPage): ActionMenuItem[] => { return [ @@ -50,7 +53,7 @@ export const StatusPagesTable = ({ const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => { if (row.isPublished) { e.stopPropagation(); - const url = `/status/public/${row.url}`; + const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`; window.open(url, "_blank", "noopener,noreferrer"); } }; @@ -133,10 +136,11 @@ export const StatusPagesTable = ({
+ {data.length > 0 && } ); }; diff --git a/client/src/Pages/StatusPage/StatusPages/index.tsx b/client/src/Pages/StatusPage/StatusPages/index.tsx index 9468b7e12..d5ae42766 100644 --- a/client/src/Pages/StatusPage/StatusPages/index.tsx +++ b/client/src/Pages/StatusPage/StatusPages/index.tsx @@ -37,11 +37,10 @@ const StatusPages = () => { return ( { +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 d8951b6c5..048998776 100644 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -20,6 +20,24 @@ import { useSelector } from "react-redux"; import type { Monitor } from "@/Types/Monitor"; import type { ActionMenuItem } from "@/Components/actions-menu"; import type { RootState } from "@/Types/state"; +import { Checkbox } from "@/Components/inputs"; + +interface MonitorTableProps { + monitors: Monitor[]; + refetch: () => void; + setSelectedMonitor: (monitor: Monitor | null) => void; + sortField: string; + setSortField: (field: string) => void; + sortOrder: "asc" | "desc"; + setSortOrder: (order: "asc" | "desc") => void; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; + selectedRows?: string[]; + onSelectionChange?: (selected: string[]) => void; +} export const MonitorTable = ({ monitors, @@ -34,31 +52,34 @@ 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 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) { @@ -71,16 +92,24 @@ export const MonitorTable = ({ refetch(); }; + const isBrowserOpenable = (monitor: Monitor): boolean => { + if (monitor.type !== "http" && monitor.type !== "pagespeed") return false; + return /^https?:\/\//i.test(monitor.url ?? ""); + }; + const getActions = (monitor: Monitor): ActionMenuItem[] => { - return [ - { + const actions: ActionMenuItem[] = []; + if (isBrowserOpenable(monitor)) { + actions.push({ id: 1, label: t("pages.common.monitors.actions.openSite"), action: () => { window.open(monitor.url, "_blank", "noreferrer"); }, closeMenu: true, - }, + }); + } + actions.push( { id: 2, label: t("pages.common.monitors.actions.details"), @@ -102,13 +131,6 @@ export const MonitorTable = ({ navigate(`/uptime/configure/${monitor.id}`); }, }, - // { - // id: 5, - // label: "Clone", - // action: () => { - - // }, - // }, { id: 6, label: @@ -130,8 +152,9 @@ export const MonitorTable = ({ ), action: () => setSelectedMonitor(monitor), closeMenu: true, - }, - ]; + } + ); + return actions; }; const getHeaders = (chartType: string) => { @@ -151,8 +174,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: ( { navigate(`/uptime/${row.id}`); }} + getRowSx={(row) => ({ + backgroundColor: isRowSelected(row.id) + ? theme.palette.action.selected + : "inherit", + })} /> { const { t } = useTranslation(); @@ -37,6 +43,7 @@ const UptimeMonitorsPage = () => { 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,7 +77,7 @@ 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(() => { @@ -102,6 +109,15 @@ const UptimeMonitorsPage = () => { count, } = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 }; + // Bulk actions + const { + selectedRows, + setSelectedRows, + handleBulkPause, + handleBulkResume, + handleCancelSelection, + } = useBulkMonitorActions(refetch, page); + // Delete hook const { deleteFn, loading: isDeleting } = useDelete(); @@ -140,6 +156,7 @@ const UptimeMonitorsPage = () => { return ( { }} /> + + {!isLoading && ( + + + + + )} + { ); setPage(0); }} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} /> { @@ -81,6 +87,8 @@ export interface Monitor { 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 4e576a2ca..9d5b914c0 100644 --- a/client/src/Types/Notification.ts +++ b/client/src/Types/Notification.ts @@ -7,6 +7,8 @@ export const NotificationChannels = [ "matrix", "teams", "telegram", + "pushover", + "twilio", ] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; @@ -21,6 +23,8 @@ export interface Notification { homeserverUrl?: string; roomId?: string; accessToken?: string; + accountSid?: string; + twilioPhoneNumber?: string; createdAt: string; updatedAt: string; } diff --git a/client/src/Types/Queue.ts b/client/src/Types/Queue.ts index 77a770aeb..bbb3ca9a4 100644 --- a/client/src/Types/Queue.ts +++ b/client/src/Types/Queue.ts @@ -23,6 +23,8 @@ export interface QueueJobSummary { monitorUrl: string | null; monitorType: MonitorType | null; monitorInterval: number | null; + monitorGeoInterval: number | null; + monitorActive: boolean | null; active: boolean; lockedAt: number | null; runCount: number; @@ -32,6 +34,7 @@ export interface QueueJobSummary { lastFinishedAt: number | null; lastRunTook: number | null; lastFailedAt: number | null; + repeat: number | null; } export interface QueueData { 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/StatusPage.ts b/client/src/Types/StatusPage.ts index 6fe641210..fe47821c5 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -21,6 +21,25 @@ export const getMonitorTypeLabel = ( return key ? t(key) : type; }; +export const STATUS_PAGE_THEMES = ["refined", "modern", "bold", "editorial"] as const; +export type StatusPageTheme = (typeof STATUS_PAGE_THEMES)[number]; +export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined"; + +export const STATUS_PAGE_THEME_MODES = ["auto", "light", "dark"] as const; +export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number]; +export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto"; + +export const resolveStatusPageTheme = ( + value: string | null | undefined +): StatusPageTheme => + STATUS_PAGE_THEMES.find((t) => t === value) ?? DEFAULT_STATUS_PAGE_THEME; + +export const resolveStatusPageThemeMode = ( + value: string | null | undefined +): StatusPageThemeMode => + STATUS_PAGE_THEME_MODES.find((m) => m === value) ?? DEFAULT_STATUS_PAGE_THEME_MODE; +export const PUBLIC_STATUS_PAGE_PREFIX = "/status/public"; + export interface StatusPage { id: string; userId: string; @@ -43,6 +62,8 @@ export interface StatusPage { showAdminLoginLink: boolean; showInfrastructure: boolean; customCSS: string; + theme: StatusPageTheme; + themeMode: StatusPageThemeMode; createdAt: string; updatedAt: string; } diff --git a/client/src/Utils/DataUtils.ts b/client/src/Utils/DataUtils.ts index c0bbb2d2a..40bbd1b8b 100644 --- a/client/src/Utils/DataUtils.ts +++ b/client/src/Utils/DataUtils.ts @@ -56,10 +56,12 @@ export const getResponseColor = ( // 0–300ms: interpolate start (good) -> mid (warning) // 300–600ms: interpolate mid (warning) -> end (error) // >600ms: solid end (error) + // MUI default success/warning/error.main values, used only when the caller + // fails to pass theme colors (defensive — keeps the gradient meaningful). const safe = { ...colors }; - if (!safe.start) safe.start = "#4caf50"; // fallback green - if (!safe.mid) safe.mid = "#ff9800"; // fallback orange - if (!safe.end) safe.end = "#f44336"; // fallback red + if (!safe.start) safe.start = "#4caf50"; + if (!safe.mid) safe.mid = "#ff9800"; + if (!safe.end) safe.end = "#f44336"; const toHex = (c: number) => c.toString(16).padStart(2, "0"); const clamp = (n: number) => Math.min(255, Math.max(0, Math.round(n))); 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 9c19f0cbf..55dc1e26e 100644 --- a/client/src/Utils/Theme/Palette.ts +++ b/client/src/Utils/Theme/Palette.ts @@ -1,12 +1,15 @@ +import { alpha } from "@mui/material/styles"; + const typographyBase = 13; export const typographyLevels = { - base: typographyBase, - xs: `${(typographyBase - 4) / 16}rem`, - s: `${(typographyBase - 2) / 16}rem`, - m: `${typographyBase / 16}rem`, - l: `${(typographyBase + 2) / 16}rem`, - xl: `${(typographyBase + 10) / 16}rem`, + base: typographyBase, // 13px + xs: `${(typographyBase - 4) / 16}rem`, // 9px + s: `${(typographyBase - 2) / 16}rem`, // 11px + m: `${typographyBase / 16}rem`, // 13px + l: `${(typographyBase + 2) / 16}rem`, // 15px + xl: `${(typographyBase + 5) / 16}rem`, // 18px + xxl: `${(typographyBase + 10) / 16}rem`, // 23px }; export const colors = { @@ -14,21 +17,32 @@ export const colors = { gray700: "#313131", gray900: "#151518", gray850: "#1c1c21", - blueBlueWave: "#1570EF", + brandGreen: "#13715B", + brandGreenLight: "#4DAF94", + green200: "#ECF7F2", + yellow200: "#FFF4E5", + orange500: "#FFA500", }; export const lightPalette = { primary: { - main: colors.blueBlueWave, + main: colors.brandGreen, }, secondary: { main: colors.gray200, }, + sidebar: { + accent: colors.brandGreen, + }, + rowStatus: { + running: colors.green200, + paused: colors.yellow200, + }, }; export const darkPalette = { primary: { - main: colors.blueBlueWave, + main: colors.brandGreen, }, secondary: { main: colors.gray700, @@ -37,4 +51,11 @@ export const darkPalette = { default: colors.gray900, paper: colors.gray850, }, + 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 49c1636ec..04f4c94eb 100644 --- a/client/src/Utils/Theme/Theme.ts +++ b/client/src/Utils/Theme/Theme.ts @@ -2,12 +2,40 @@ import { createTheme } from "@mui/material"; import { lightPalette, darkPalette, typographyLevels } from "@/Utils/Theme/Palette"; import type { Theme } from "@mui/material/styles"; +import type {} from "@mui/material/styles"; + +declare module "@mui/material/styles" { + interface TypographyVariants { + eyebrow: React.CSSProperties; + fontFamilyMonospace: string; + } + interface TypographyVariantsOptions { + eyebrow?: React.CSSProperties; + fontFamilyMonospace?: string; + } + interface Palette { + sidebar: { accent: string }; + rowStatus: { running: string; paused: string }; + } + interface PaletteOptions { + sidebar?: { accent: string }; + rowStatus?: { running: string; paused: string }; + } +} + +declare module "@mui/material/Typography" { + interface TypographyPropsVariantOverrides { + eyebrow: true; + } +} export type PaletteKey = { [K in keyof Theme["palette"]]: Theme["palette"][K] extends { main: any } ? K : never; }[keyof Theme["palette"]]; const fontFamilyPrimary = "system-ui, sans-serif"; +const fontFamilyMonospace = + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace'; const shadow = "0px 4px 24px -4px rgba(16, 24, 40, 0.08), 0px 3px 3px -3px rgba(16, 24, 40, 0.03)"; @@ -29,9 +57,10 @@ export const theme = (mode: string, palette: any) => }, typography: { fontFamily: fontFamilyPrimary, + fontFamilyMonospace, fontSize: typographyLevels.base, h1: { - fontSize: typographyLevels.xl, + fontSize: typographyLevels.xxl, fontWeight: 500, }, h2: { @@ -46,6 +75,13 @@ export const theme = (mode: string, palette: any) => fontSize: typographyLevels.s, fontWeight: 400, }, + eyebrow: { + fontSize: 13, + fontWeight: 500, + textTransform: "uppercase", + letterSpacing: "0.08em", + lineHeight: 1.4, + }, }, components: { @@ -120,11 +156,17 @@ export const theme = (mode: string, palette: any) => MuiOutlinedInput: { styleOverrides: { - root: { + root: ({ theme }) => ({ + "&:hover .MuiOutlinedInput-notchedOutline": { + borderColor: + theme.palette.mode === "dark" + ? "rgba(255, 255, 255, 0.23)" + : theme.palette.text.primary, + }, "&.Mui-focused .MuiOutlinedInput-notchedOutline": { borderColor: palette.primary.main, }, - }, + }), }, }, @@ -145,6 +187,113 @@ export const theme = (mode: string, palette: any) => }, }, }, + + MuiSwitch: { + defaultProps: { disableRipple: true }, + styleOverrides: { + root: { + width: 36, + height: 20, + padding: "0 6px 0 0", + boxSizing: "content-box", + overflow: "visible", + }, + switchBase: ({ theme }) => ({ + padding: 2, + "&.Mui-checked": { + transform: "translateX(16px)", + color: "#fff", + "& + .MuiSwitch-track": { + backgroundColor: theme.palette.primary.main, + opacity: 1, + border: 0, + }, + }, + }), + thumb: { + width: 16, + height: 16, + boxShadow: "0 1px 2px rgba(16, 24, 40, 0.1)", + }, + track: ({ theme }) => ({ + borderRadius: 999, + backgroundColor: + theme.palette.mode === "dark" + ? "rgba(255,255,255,0.16)" + : theme.palette.action.disabledBackground, + opacity: 1, + }), + }, + }, + + MuiRadio: { + defaultProps: { disableRipple: true }, + styleOverrides: { + root: ({ theme }) => ({ + padding: theme.spacing(2), + color: theme.palette.divider, + "& .MuiSvgIcon-root": { + fontSize: 20, + transition: "none", + }, + "&.Mui-checked": { + color: theme.palette.primary.main, + }, + "&.Mui-checked .MuiSvgIcon-root:last-of-type": { + transform: "scale(0.6)", + }, + }), + }, + }, + + MuiSlider: { + styleOverrides: { + root: ({ theme }) => ({ + height: 4, + padding: "13px 0", + color: theme.palette.primary.main, + }), + rail: ({ theme }) => ({ + height: 4, + borderRadius: 999, + opacity: 1, + backgroundColor: + theme.palette.mode === "dark" + ? "rgba(255,255,255,0.16)" + : theme.palette.action.disabledBackground, + }), + track: { + height: 4, + borderRadius: 999, + border: "none", + }, + thumb: ({ theme }) => ({ + width: 18, + height: 18, + backgroundColor: theme.palette.background.paper, + border: `2px solid ${theme.palette.primary.main}`, + boxShadow: "0 1px 2px rgba(16, 24, 40, 0.1)", + "&:hover, &.Mui-focusVisible": { + boxShadow: `0 0 0 6px ${theme.palette.primary.main}1f`, + }, + "&.Mui-active": { + boxShadow: `0 0 0 8px ${theme.palette.primary.main}29`, + }, + "&::before": { display: "none" }, + }), + mark: ({ theme }) => ({ + width: 2, + height: 2, + borderRadius: 999, + backgroundColor: theme.palette.text.disabled, + opacity: 0.5, + }), + markActive: ({ theme }) => ({ + backgroundColor: theme.palette.primary.main, + opacity: 0.5, + }), + }, + }, }, shape: { borderRadius: 2, diff --git a/client/src/Utils/Theme/constants.ts b/client/src/Utils/Theme/constants.ts index 8b045f9f6..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, @@ -8,6 +9,7 @@ export const SPACING = { } as const; export const LAYOUT = { + XXS: 2, XS: 4, SM: 6, MD: 8, @@ -15,3 +17,7 @@ export const LAYOUT = { XL: 12, XXL: 16, } as const; + +export const HOVER = { + DARKEN: 0.06, // This is a coefficient for darkening function +} as const; 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/monitor.ts b/client/src/Validation/monitor.ts index 88df71101..adbe64e4a 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { GeoContinents } from "@/Types/GeoCheck"; -import { PageSpeedStrategies } from "@/Types/Monitor"; +import { DnsRecordTypes, PageSpeedStrategies } from "@/Types/Monitor"; // URL schema with custom error message const urlSchema = z.url({ message: "Please enter a valid URL" }); @@ -124,6 +124,29 @@ const websocketSchema = baseSchema.extend({ ignoreTlsErrors: z.boolean(), }); +// Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, +// optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp). +// No scheme, port, path, or whitespace. Total length ≤ 253. +const hostnameRegex = + /^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; + +// DNS monitor schema +const dnsSchema = baseSchema.extend({ + type: z.literal("dns"), + url: z + .string() + .min(1, "Domain is required") + .regex(hostnameRegex, "Enter a valid domain (e.g. www.example.com)"), + dnsServer: z + .string() + .min(1, "DNS server is required") + .refine( + (v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, + "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)" + ), + dnsRecordType: z.enum(DnsRecordTypes), +}); + // Discriminated union of all monitor types export const monitorSchema = z.discriminatedUnion("type", [ httpSchema, @@ -135,6 +158,7 @@ export const monitorSchema = z.discriminatedUnion("type", [ pagespeedSchema, hardwareSchema, websocketSchema, + dnsSchema, ]); export type MonitorFormData = z.infer; diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts index ae3490c04..9fa2b011a 100644 --- a/client/src/Validation/notifications.ts +++ b/client/src/Validation/notifications.ts @@ -56,6 +56,20 @@ const telegramSchema = baseSchema.extend({ accessToken: z.string().min(1, "Bot token is required"), }); +const pushoverSchema = baseSchema.extend({ + type: z.literal("pushover"), + address: z.string().min(1, "User key is required"), + accessToken: z.string().min(1, "App token is required"), +}); + +const twilioSchema = baseSchema.extend({ + type: z.literal("twilio"), + accountSid: z.string().min(1, "Account SID is required"), + accessToken: z.string().min(1, "Auth token is required"), + phone: z.string().min(1, "Recipient phone number is required"), + twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), +}); + export const notificationSchema = z.discriminatedUnion("type", [ emailSchema, slackSchema, @@ -65,6 +79,8 @@ export const notificationSchema = z.discriminatedUnion("type", [ matrixSchema, teamsSchema, telegramSchema, + pushoverSchema, + twilioSchema, ]); 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 43d7198f0..8b41b7780 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage"; export const statusPageSchema = z.object({ companyName: z @@ -25,6 +26,8 @@ export const statusPageSchema = z.object({ showAdminLoginLink: z.boolean(), showInfrastructure: z.boolean(), customCSS: z.string().optional(), + theme: z.enum(STATUS_PAGE_THEMES).optional(), + themeMode: z.enum(STATUS_PAGE_THEME_MODES).optional(), logo: z .object({ data: z.string(), diff --git a/client/src/assets/icons/checkmate-icon.svg b/client/src/assets/icons/checkmate-icon.svg index e0e6bf6f4..e1701573a 100644 --- a/client/src/assets/icons/checkmate-icon.svg +++ b/client/src/assets/icons/checkmate-icon.svg @@ -1,49 +1,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + Checkmate + King silhouette adapted from Cburnett's chess set (Wikimedia Commons, CC BY-SA 3.0). + + + + + + diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 6cec253d8..880667e86 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -1,1108 +1,1447 @@ { - "submit": "", - "title": "", - "distributedStatusHeaderText": "", - "distributedStatusSubHeaderText": "", - "settingsDisabled": "", - "settingsSuccessSaved": "", - "settingsFailedToSave": "", - "settingsStatsCleared": "", - "settingsFailedToClearStats": "", - "settingsMonitorsDeleted": "", - "settingsFailedToDeleteMonitors": "", - "starPromptTitle": "", - "starPromptDescription": "", - "https": "", - "http": "", - "monitor": "", - "aboutus": "", - "now": "", - "delete": "", - "configure": "", - "responseTime": "", - "ms": "", - "bar": "", - "area": "", - "country": "", - "city": "", - "response": "", - "monitorStatusUp": "", - "monitorStatusDown": "", - "webhookSendSuccess": "", - "webhookSendError": "", - "webhookUnsupportedPlatform": "", - "distributedRightCategoryTitle": "", - "distributedStatusServerMonitors": "", - "distributedStatusServerMonitorsDescription": "", - "distributedUptimeCreateSelectURL": "", - "distributedUptimeCreateChecks": "", - "distributedUptimeCreateChecksDescription": "", - "distributedUptimeCreateIncidentNotification": "", - "distributedUptimeCreateIncidentDescription": "", - "distributedUptimeCreateAdvancedSettings": "", - "distributedUptimeDetailsNoMonitorHistory": "", - "distributedUptimeDetailsStatusHeaderUptime": "", - "distributedUptimeDetailsStatusHeaderLastUpdate": "", - "notifications": { - "enableNotifications": "", - "testNotification": "", - "addOrEditNotifications": "", - "slack": { - "label": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "discord": { - "label": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "telegram": { - "label": "", - "description": "", - "tokenLabel": "", - "tokenPlaceholder": "", - "chatIdLabel": "", - "chatIdPlaceholder": "", - "fieldsRequired": "" - }, - "webhook": { - "label": "", - "description": "", - "urlLabel": "", - "urlPlaceholder": "", - "urlRequired": "" - }, - "testNotificationDevelop": "", - "integrationButton": "", - "testSuccess": "", - "testFailed": "", - "unsupportedType": "", - "networkError": "", - "fallback": { - "title": "", - "checks": [""], - "actionButton": "" - }, - "createButton": "", - "createTitle": "", - "create": { - "success": "", - "failed": "" - }, - "fetch": { - "success": "", - "failed": "" - }, - "delete": { - "success": "", - "failed": "" - }, - "edit": { - "success": "", - "failed": "" - }, - "test": { - "success": "", - "failed": "" - } - }, - "testLocale": "", - "add": "", - "monitors": "", - "distributedUptimeStatusCreateStatusPage": "", - "distributedUptimeStatusCreateStatusPageAccess": "", - "distributedUptimeStatusCreateStatusPageReady": "", - "distributedUptimeStatusBasicInfoHeader": "", - "distributedUptimeStatusBasicInfoDescription": "", - "distributedUptimeStatusLogoHeader": "", - "distributedUptimeStatusLogoDescription": "", - "distributedUptimeStatusLogoUploadButton": "", - "distributedUptimeStatusStandardMonitorsHeader": "", - "distributedUptimeStatusStandardMonitorsDescription": "", - "distributedUptimeStatusCreateYour": "", - "distributedUptimeStatusEditYour": "", - "distributedUptimeStatusPublishedLabel": "", - "distributedUptimeStatusCompanyNameLabel": "", - "distributedUptimeStatusPageAddressLabel": "", - "distributedUptimeStatus30Days": "", - "distributedUptimeStatus60Days": "", - "distributedUptimeStatus90Days": "", - "distributedUptimeStatusPageNotSetUp": "", - "distributedUptimeStatusContactAdmin": "", - "distributedUptimeStatusPageNotPublic": "", - "distributedUptimeStatusPageDeleteDialog": "", - "distributedUptimeStatusPageDeleteConfirm": "", - "distributedUptimeStatusPageDeleteDescription": "", - "distributedUptimeStatusDevices": "", - "distributedUptimeStatusUpt": "", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "", - "incidentsTableNoIncidents": "", - "incidentsTablePaginationLabel": "", - "incidentsTableMonitorName": "", - "incidentsTableStatus": "", - "incidentsTableDateTime": "", - "incidentsTableStatusCode": "", - "incidentsTableMessage": "", - "incidentsOptionsHeader": "", - "incidentsOptionsHeaderFilterBy": "", - "incidentsOptionsHeaderFilterAll": "", - "incidentsOptionsHeaderFilterDown": "", - "incidentsOptionsHeaderFilterCannotResolve": "", - "incidentsOptionsHeaderShow": "", - "incidentsOptionsHeaderLastHour": "", - "incidentsOptionsHeaderLastDay": "", - "incidentsOptionsHeaderLastWeek": "", - "incidentsOptionsPlaceholderAllServers": "", - "infrastructureCreateYour": "", - "infrastructureCreateGeneralSettingsDescription": "", - "infrastructureServerRequirement": "", - "infrastructureCustomizeAlerts": "", - "infrastructureAlertNotificationDescription": "", - "infrastructureCreateMonitor": "", - "infrastructureProtocol": "", - "infrastructureServerUrlLabel": "", - "infrastructureDisplayNameLabel": "", - "infrastructureAuthorizationSecretLabel": "", - "gb": "", - "mb": "", - "mem": "", - "memoryUsage": "", - "cpu": "", - "cpuUsage": "", - "cpuTemperature": "", - "diskUsage": "", - "used": "", - "total": "", - "cores": "", - "frequency": "", - "status": "", - "cpuPhysical": "", - "cpuLogical": "", - "cpuFrequency": "", - "avgCpuTemperature": "", - "memory": "", - "disk": "", - "uptime": "", - "os": "", - "host": "", - "actions": "", - "integrations": "", - "integrationsPrism": "", - "integrationsSlack": "", - "integrationsSlackInfo": "", - "integrationsDiscord": "", - "integrationsDiscordInfo": "", - "integrationsZapier": "", - "integrationsZapierInfo": "", - "commonSave": "", - "createYour": "", - "createMonitor": "", - "pause": "", - "resume": "", - "editing": "", - "url": "", - "access": "", - "timezone": "", - "features": "", - "administrator": "", - "loginHere": "", - "displayName": "", - "urlMonitor": "", - "portToMonitor": "", - "websiteMonitoring": "", - "websiteMonitoringDescription": "", - "pingMonitoring": "", - "pingMonitoringDescription": "", - "dockerContainerMonitoring": "", - "dockerContainerMonitoringDescription": "", - "portMonitoring": "", - "portMonitoringDescription": "", - "createMaintenanceWindow": "", - "createMaintenance": "", - "editMaintenance": "", - "maintenanceWindowName": "", - "friendlyNameInput": "", - "friendlyNamePlaceholder": "", - "maintenanceRepeat": "", - "maintenance": "", - "duration": "", - "addMonitors": "", - "window": "", - "cancel": "", - "message": "", - "low": "", - "high": "", - "statusCode": "", - "date&Time": "", - "type": "", - "statusPageName": "", - "publicURL": "", - "repeat": "", - "edit": "", - "createA": "", - "remove": "", - "maintenanceWindowDescription": "", - "startTime": "", - "timeZoneInfo": "", - "monitorsToApply": "", - "nextWindow": "", - "notFoundButton": "", - "pageSpeedConfigureSettingsDescription": "", - "monitorDisplayName": "", - "whenNewIncident": "", - "notifySMS": "", - "notifyEmails": "", - "seperateEmails": "", - "checkFrequency": "", - "matchMethod": "", - "expectedValue": "", - "deleteDialogTitle": "", - "deleteDialogDescription": "", - "pageSpeedMonitor": "", - "shown": "", - "ago": "", - "companyName": "", - "pageSpeedDetailsPerformanceReport": "", - "pageSpeedDetailsPerformanceReportCalculator": "", - "checkingEvery": "", - "statusPageCreateSettings": "", - "basicInformation": "", - "statusPageCreateBasicInfoDescription": "", - "statusPageCreateSelectTimeZoneDescription": "", - "statusPageCreateAppearanceDescription": "", - "statusPageCreateSettingsCheckboxLabel": "", - "statusPageCreateBasicInfoStatusPageAddress": "", - "statusPageCreateTabsContent": "", - "statusPageCreateTabsContentDescription": "", - "statusPageCreateTabsContentFeaturesDescription": "", - "showCharts": "", - "showUptimePercentage": "", - "removeLogo": "", - "statusPageStatus": "", - "statusPageStatusContactAdmin": "", - "statusPageStatusNotPublic": "", - "statusPageStatusNoPage": "", - "statusPageStatusServiceStatus": "", - "deleteStatusPage": "", - "deleteStatusPageConfirm": "", - "deleteStatusPageDescription": "", - "uptimeCreate": "", - "uptimeCreateJsonPath": "", - "uptimeCreateJsonPathQuery": "", - "maintenanceTableActionMenuDialogTitle": "", - "infrastructureEditYour": "", - "infrastructureEditMonitor": "", - "infrastructureMonitorCreated": "", - "infrastructureMonitorUpdated": "", - "errorInvalidTypeId": "", - "errorInvalidFieldId": "", - "inviteNoTokenFound": "", - "pageSpeedWarning": "", - "pageSpeedLearnMoreLink": "", - "pageSpeedAddApiKey": "", - "update": "", - "invalidFileFormat": "", - "invalidFileSize": "", - "ClickUpload": "", - "DragandDrop": "", - "MaxSize": "", - "SupportedFormats": "", - "FirstName": "", - "LastName": "", - "EmailDescriptionText": "", - "YourPhoto": "", - "PhotoDescriptionText": "", - "save": "", - "DeleteDescriptionText": "", - "DeleteAccountWarning": "", - "DeleteWarningTitle": "", - "bulkImport": { - "title": "", - "selectFileTips": "", - "selectFileDescription": "", - "selectFile": "", - "parsingFailed": "", - "uploadSuccess": "", - "validationFailed": "", - "noFileSelected": "", - "fallbackPage": "", - "invalidFileType": "", - "uploadFailed": "" - }, - "DeleteAccountTitle": "", - "DeleteAccountButton": "", - "publicLink": "", - "maskedPageSpeedKeyPlaceholder": "", - "reset": "", - "ignoreTLSError": "", - "tlsErrorIgnored": "", - "ignoreTLSErrorDescription": "", - "createNew": "", - "greeting": { - "prepend": "", - "append": "", - "overview": "" - }, - "roles": { - "superAdmin": "", - "admin": "", - "teamMember": "", - "demoUser": "" - }, - "teamPanel": { - "teamMembers": "", - "filter": { - "all": "", - "member": "" - }, - "inviteTeamMember": "", - "inviteNewTeamMember": "", - "inviteDescription": "", - "email": "", - "selectRole": "", - "inviteLink": "", - "cancel": "", - "noMembers": "", - "getToken": "", - "emailToken": "", - "table": { - "name": "", - "email": "", - "role": "", - "created": "" - }, - "addTeamMember": { - "addMemberMenu": "", - "title": "", - "description": "", - "addButton": "" - }, - "register": "", - "registerToast": { - "success": "", - "dbUserExists": "", - "unknownError": "" - }, - "registerTeamMember": { - "title": "", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "lastName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "email": { - "errors": { - "empty": "", - "invalid": "" - } - }, - "role": { - "errors": { - "empty": "" - } - } - } - } - } - }, - "role": "", - "changeTeamPassword": { - "changePasswordMenu": "", - "title": "", - "description": "", - "success": "" - } - }, - "monitorState": { - "paused": "", - "resumed": "", - "active": "" - }, - "menu": { - "uptime": "", - "pagespeed": "", - "infrastructure": "", - "incidents": "", - "statusPages": "", - "maintenance": "", - "integrations": "", - "settings": "", - "support": "", - "discussions": "", - "docs": "", - "changelog": "", - "profile": "", - "password": "", - "team": "", - "logOut": "", - "notifications": "", - "logs": "" - }, - "settingsEmailUser": "", - "state": "", - "statusBreadCrumbsStatusPages": "", - "statusBreadCrumbsDetails": "", - "commonSaving": "", - "navControls": "", - "incidentsPageTitle": "", - "passwordPanel": { - "passwordChangedSuccess": "", - "passwordInputIncorrect": "", - "currentPassword": "", - "enterCurrentPassword": "", - "newPassword": "", - "enterNewPassword": "", - "confirmNewPassword": "", - "passwordRequirements": "", - "saving": "" - }, - "emailSent": "", - "failedToSendEmail": "", - "settingsTestEmailSuccess": "", - "settingsTestEmailFailed": "", - "settingsTestEmailFailedWithReason": "", - "settingsTestEmailUnknownError": "", - "statusMsg": { - "paused": "", - "up": "", - "down": "", - "pending": "" - }, - "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "", - "game": "", - "https": "" - }, "common": { - "appName": "", - "monitoringAgentName": "", - "buttons": { - "toggleTheme": "" + "auth": { + "roles": { + "admin": "مسؤول", + "demo": "عرض توضيحي", + "superadmin": "مسؤول أعلى", + "user": "مستخدم" + } }, - "toasts": { - "networkError": "", - "checkConnection": "", - "unknownError": "" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "", - "back": "" + "alerts": { + "pageSpeedApiKey": { + "content": "تحذير: لم تقم بإضافة مفتاح Google PageSpeed API بعد. قم بزيارة الإعدادات لإضافته. بدونه، لن تعمل مراقبة PageSpeed." + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "التفاصيل", + "home": "الرئيسية" + }, + "buttons": { + "addMember": "إضافة عضو", + "cancel": "إلغاء", + "close": "إغلاق", + "configure": "تهيئة", + "confirm": "تأكيد", + "create": "إنشاء", + "delete": "حذف", + "generateToken": "إنشاء رمز", + "incidents": "الحوادث", + "inviteMember": "دعوة عضو", + "pause": "إيقاف مؤقت", + "resume": "استئناف", + "save": "حفظ", + "sendInvite": "إرسال دعوة", + "test": "اختبار", + "testNotifications": "اختبار الإشعارات", + "toggleTheme": "تبديل الوضع الفاتح والداكن", + "flushQueue": "تفريغ قائمة الانتظار", + "notFound": "الانتقال إلى لوحة التحكم الرئيسية", + "resetPassword": "إعادة تعيين كلمة المرور", + "reset": "إعادة تعيين", + "clear": "مسح", + "addDemo": "إضافة مراقبات تجريبية", + "removeMonitors": "إزالة المراقبات", + "sendTestEmail": "إرسال بريد تجريبي", + "exportToJSON": "تصدير إلى JSON", + "importFromJSON": "استيراد من JSON", + "clearFilters": "مسح المرشحات", + "removeUser": "إزالة المستخدم" + }, + "charts": { + "labels": { + "averageResponseTime": "متوسط وقت الاستجابة", + "downtime": "وقت التوقف", + "high": "مرتفع", + "low": "منخفض", + "uptime": "وقت التشغيل" }, - "inputs": { - "email": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "invalid": "" - } - }, - "password": { - "label": "", - "rules": { - "length": { - "beginning": "", - "highlighted": "" - }, - "special": { - "beginning": "", - "highlighted": "" - }, - "number": { - "beginning": "", - "highlighted": "" - }, - "uppercase": { - "beginning": "", - "highlighted": "" - }, - "lowercase": { - "beginning": "", - "highlighted": "" - }, - "match": { - "beginning": "", - "highlighted": "" - } - }, - "errors": { - "empty": "", - "length": "", - "uppercase": "", - "lowercase": "", - "number": "", - "special": "", - "incorrect": "" - } - }, - "passwordConfirm": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "different": "" - } - }, - "firstName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - }, - "lastName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" + "histogram": { + "avg": "المتوسط: {{value}} مللي ثانية", + "max": "الحد الأقصى: {{value}} مللي ثانية" + } + }, + "dialogs": { + "delete": { + "description": "لا يمكن التراجع عن هذا الإجراء.", + "title": "هل أنت متأكد أنك تريد حذف هذا؟" + } + }, + "labels": { + "active": "نشط", + "paused": "متوقف مؤقتاً", + "na": "غير متوفر", + "resolved": "تم الحل", + "responseTime": "وقت الاستجابة" + }, + "form": { + "role": { + "option": { + "role": { + "label": "الأدوار" } } }, - "errors": { - "validation": "" - }, - "fields": { - "password": { - "errors": { - "incorrect": "" + "name": { + "option": { + "firstName": { + "label": "الاسم الأول", + "placeholder": "أدخل اسمك الأول" + }, + "lastName": { + "label": "اسم العائلة", + "placeholder": "أدخل اسم عائلتك" } - }, - "role": { - "errors": { - "min": "" + } + }, + "email": { + "option": { + "email": { + "label": "البريد الإلكتروني", + "placeholder": "you@example.com" } } } }, - "login": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "" - }, - "links": { - "forgotPassword": "", - "register": "", - "forgotPasswordLink": "", - "registerLink": "" - }, - "toasts": { - "success": "", - "incorrectPassword": "" - }, + "table": { + "empty": "لا يوجد شيء هنا", + "headers": { + "actions": "إجراءات", + "dateTime": "التاريخ والوقت", + "message": "الرسالة", + "monitor": "المراقب", + "monitorId": "معرّف المراقب", + "name": "الاسم", + "status": "الحالة", + "type": "النوع", + "url": "الرابط", + "interval": "الفاصل الزمني", + "active": "نشط", + "responseTime": "وقت الاستجابة" + } + } + }, + "components": { + "imageUpload": { + "clickToUpload": "انقر للرفع", + "dragAndDrop": "أو اسحب وأسقط", + "supportedFormats": "الصيغ المدعومة", + "maxSize": "الحجم الأقصى", + "orDragAndDrop": "أو اسحب وأفلت", "errors": { - "password": { - "incorrect": "" - } - }, - "welcome": "" + "invalidFileSize": "حجم الملف كبير جدًا!", + "invalidFileFormat": "تنسيق الملف غير مدعوم!" + } }, - "registration": { - "heading": { - "superAdmin": "", - "user": "" - }, - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "" + "headerStatusPageControls": { + "publicLink": "رابط عام" + }, + "headerTimeRange": { + "labels": { + "day": "يوم", + "month": "شهر", + "recent": "الأخيرة", + "week": "أسبوع" }, "description": { - "superAdmin": "", - "user": "" + "recent": "عرض الإحصائيات لآخر ساعتين.", + "day": "عرض الإحصائيات لآخر 24 ساعة.", + "week": "عرض الإحصائيات لآخر 7 أيام.", + "month": "عرض الإحصائيات لآخر 30 يوماً." + } + }, + "offlineBanner": { + "serverUnreachable": "تعذر الوصول إلى الخادم", + "retry": "إعادة المحاولة", + "retrying": "جارٍ إعادة المحاولة...", + "reconnected": "تمت استعادة الاتصال" + }, + "sidebar": { + "menu": { + "uptime": "وقت التشغيل", + "pagespeed": "Pagespeed", + "infrastructure": "البنية التحتية", + "notifications": "الإشعارات", + "checks": "الفحوصات", + "incidents": "الحوادث", + "statusPages": "صفحات الحالة", + "maintenance": "الصيانة", + "logs": "السجلات", + "settings": "الإعدادات" }, - "gettingStartedButton": { - "superAdmin": "", - "user": "" + "bottomMenu": { + "support": "الدعم", + "discussions": "المناقشات", + "docs": "الوثائق", + "changelog": "سجل التغييرات" }, - "termsAndPolicies": "", + "accountMenu": { + "profile": "الملف الشخصي", + "password": "كلمة المرور", + "team": "الفريق" + }, + "starPrompt": { + "title": "أضف نجمة لـ Checkmate", + "description": "اطلع على أحدث الإصدارات وساعد في تنمية المجتمع على GitHub" + }, + "authFooter": { + "navControls": "عناصر التحكم", + "logOut": "تسجيل الخروج", + "roles": { + "superAdmin": "مسؤول أعلى", + "admin": "مسؤول", + "user": "مستخدم", + "demoUser": "مستخدم تجريبي" + } + } + }, + "starPrompt": { + "title": "أضف نجمة لـ Checkmate", + "description": "اطلع على أحدث الإصدارات وساعد في تنمية المجتمع على GitHub" + }, + "pageHeader": { "links": { - "login": "" - }, - "toasts": { - "success": "" - }, - "welcome": "" - }, - "forgotPassword": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "", - "stepFour": "" - }, - "buttons": { - "openEmail": "", - "resetPassword": "" - }, - "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" - }, - "links": { - "login": "", - "resend": "" - }, - "toasts": { - "sent": "", - "emailNotFound": "", - "redirect": "", - "success": "", - "error": "" + "support": "الدعم", + "discussions": "المناقشات", + "docs": "التوثيق", + "changelog": "سجل التغييرات" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "", - "stillUnreachable": "" + "pages": { + "notFound": { + "title": "يا إلهي! لقد أسقطت السوشي!", + "subtitle": "إما أن الرابط غير موجود، أو ليس لديك صلاحية الوصول إليه." + }, + "account": { + "tabs": { + "profile": "الملف الشخصي", + "password": "كلمة المرور", + "team": "الفريق" }, - "alertBox": "", - "description": "", - "retryButton": { - "default": "", - "processing": "" + "form": { + "name": { + "title": "الاسم", + "description": "تحديث معلوماتك الشخصية" + }, + "photo": { + "title": "صورة الملف الشخصي", + "description": "رفع صورة للملف الشخصي" + }, + "currentPassword": { + "title": "كلمة المرور الحالية", + "description": "أدخل كلمة المرور الحالية للتحقق من هويتك", + "option": { + "label": "كلمة المرور الحالية", + "placeholder": "أدخل كلمة المرور الحالية" + } + }, + "newPassword": { + "title": "كلمة مرور جديدة", + "description": "اختر كلمة مرور قوية مكونة من 8 أحرف على الأقل", + "option": { + "newPassword": { + "label": "كلمة مرور جديدة", + "placeholder": "أدخل كلمة المرور الجديدة" + }, + "confirm": { + "label": "تأكيد كلمة المرور", + "placeholder": "تأكيد كلمة المرور الجديدة" + } + } + }, + "deleteAccount": { + "title": "حذف الحساب", + "description": "هذا الإجراء دائم ولا يمكن التراجع عنه" + } + }, + "team": { + "filter": { + "placeholder": "تصفية حسب الدور", + "all": "كل الأدوار", + "admin": "مسؤول", + "member": "عضو" + }, + "table": { + "headers": { + "email": "البريد الإلكتروني", + "role": "الدور", + "created": "تم الإنشاء في" + } + }, + "addMember": { + "title": "تسجيل عضو فريق جديد", + "description": "إنشاء حساب مستخدم جديد. شارك بيانات الاعتماد بشكل آمن بعد الإنشاء." + }, + "invite": { + "title": "دعوة عضو فريق", + "description": "إرسال دعوة للانضمام إلى فريقك", + "email": { + "label": "عنوان البريد الإلكتروني", + "placeholder": "أدخل عنوان البريد الإلكتروني" + }, + "role": { + "label": "الدور", + "placeholder": "اختر دوراً" + }, + "linkLabel": "رابط الدعوة" + }, + "fallback": { + "title": "لا يوجد بعد أعضاء فريق", + "description": "ادعُ زملاءك عبر البريد الإلكتروني أو أضفهم مباشرة. بمجرد انضمامهم، سيظهرون هنا مع دورهم." + } + }, + "header": { + "title": "الحساب", + "description": "إدارة ملفك الشخصي وكلمة المرور والفريق وتفضيلات مساحة العمل." + } + }, + "auth": { + "brandPanel": { + "eyebrow": "المراقبة المفتوحة المصدر للبنية التحتية", + "tagline": "راقب كل خطوة تقوم بها خوادمك.", + "description": "تتبع حالة التشغيل وأوقات الاستجابة والحوادث في الوقت الفعلي - ذاتية الاستضافة وتبقى لك." + }, + "common": { + "passwordRules": { + "length": { + "beginning": "", + "highlighted": "" + }, + "lowercase": { + "beginning": "", + "highlighted": "" + }, + "match": { + "beginning": "", + "highlighted": "" + }, + "number": { + "beginning": "", + "highlighted": "" + }, + "special": { + "beginning": "", + "highlighted": "" + }, + "uppercase": { + "beginning": "", + "highlighted": "" + } + }, + "form": { + "option": { + "email": { + "label": "البريد الإلكتروني", + "placeholder": "me@example.com" + }, + "password": { + "label": "كلمة المرور", + "placeholder": "********" + }, + "confirmPassword": { + "label": "تأكيد كلمة المرور" + } + } + } + }, + "login": { + "title": "مرحباً بعودتك إلى Checkmate!", + "subtitle": "سجّل الدخول للمتابعة", + "submit": "تسجيل الدخول", + "links": { + "forgotPassword": { + "text": "نسيت كلمة المرور؟", + "linkText": "إعادة تعيين كلمة المرور" + }, + "register": { + "text": "ليس لديك حساب؟", + "linkText": "سجّل هنا" + } + } + }, + "register": { + "title": "مرحباً بعودتك إلى Checkmate!", + "subtitle": "سجّل للبدء", + "submit": "تسجيل", + "setupNotice": "هذه الصفحة متاحة فقط أثناء الإعداد الأولي. بمجرد تسجيل المسؤول الأعلى، يمكن دعوة مستخدمين إضافيين من إعدادات الفريق." + }, + "forgotPassword": { + "title": "نسيت كلمة المرور؟", + "subtitle": "لا تقلق، سنرسل لك تعليمات إعادة التعيين.", + "submit": "طلب استرداد", + "links": { + "login": { + "text": "العودة إلى", + "linkText": "تسجيل الدخول" + } + } + }, + "setNewPassword": { + "title": "إعادة تعيين كلمة المرور", + "subtitle": "يجب أن تكون كلمة المرور الجديدة مختلفة عن كلمات المرور المستخدمة سابقاً." + } + }, + "checks": { + "selects": { + "monitor": { + "all": "جميع المراقبات" + }, + "status": { + "all": "الكل", + "down": "متوقف", + "up": "يعمل" + } + }, + "table": { + "empty": "لا توجد فحوصات متوقفة في هذا النطاق الزمني", + "headers": { + "statusCode": "رمز الحالة", + "location": "الموقع" + } + }, + "fallback": { + "title": "لا توجد فحوصات بعد", + "description": "بمجرد إنشاء جهاز مراقبة، ستظهر هنا كل عملية فحص حالة ووقت استجابة كفحص." + }, + "header": { + "title": "الفحوصات", + "description": "كل فحص يجريه Checkmate على مراقبيك يظهر هنا برمز الحالة ووقت الاستجابة." + } + }, + "common": { + "monitors": { + "actions": { + "configure": "تهيئة", + "delete": "حذف", + "generateToken": "إنشاء رمز", + "details": "التفاصيل", + "incidents": "الحوادث", + "inviteMember": "دعوة عضو", + "openSite": "فتح الموقع", + "pause": "إيقاف مؤقت", + "resume": "استئناف", + "selectAll": "تحديد جميع الشاشات", + "selectMonitor": "تحديد {{name}}", + "bulkSelected_one": "{{count}} شاشة محددة", + "bulkSelected_other": "{{count}} شاشات محددة" + }, + "statBoxes": { + "activeFor": "نشط منذ", + "certificateExpiry": "انتهاء الشهادة", + "lastCheck": "آخر فحص", + "lastResponseTime": "آخر وقت استجابة", + "serviceIsDown": "الخدمة معطلة" + }, + "status": { + "down": "متوقف", + "breached": "تم تجاوز الحد", + "initializing": "جارٍ التهيئة", + "maintenance": "صيانة", + "paused": "متوقف مؤقتاً", + "total": "الإجمالي", + "up": "يعمل" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "ألعاب", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "منفذ", + "optionPagespeed": "PageSpeed", + "optionHardware": "البنية التحتية", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "تم إيقاف {{count}} شاشة مؤقتاً", + "paused_other": "تم إيقاف {{count}} شاشات مؤقتاً", + "resumed_one": "تم استئناف {{count}} شاشة", + "resumed_other": "تم استئناف {{count}} شاشات", + "alreadyPaused_one": "الشاشة المحددة متوقفة مؤقتاً بالفعل", + "alreadyPaused_other": "جميع الشاشات المحددة متوقفة مؤقتاً بالفعل", + "alreadyRunning_one": "الشاشة المحددة قيد التشغيل بالفعل", + "alreadyRunning_other": "جميع الشاشات المحددة قيد التشغيل بالفعل" + } + } + }, + "createMonitor": { + "form": { + "advanced": { + "description": "إعدادات اختيارية لحالات الاستخدام المتقدمة", + "option": { + "advancedMatching": { + "label": "استخدام المطابقة المتقدمة" + }, + "expectedValue": { + "label": "القيمة المتوقعة" + }, + "jsonPath": { + "description": "سيتم تقييم هذا التعبير مقابل بيانات JSON للاستجابة وسيتم استخدام النتيجة للمطابقة مع القيمة المتوقعة. راجع jmespath.org لتوثيق لغة الاستعلام.", + "label": "تعبير JSONPath" + }, + "matchMethod": { + "label": "طريقة المطابقة", + "equal": "", + "include": "", + "regex": "" + } + }, + "title": "إعدادات متقدمة" + }, + "frequency": { + "description": "كم مرة تريد التحقق من حالة هذا المراقب؟", + "option": { + "frequency": { + "label": "تكرار الفحص", + "value": { + "fifteenMinutes": "15 دقيقة", + "fifteenSeconds": "", + "fiveMinutes": "5 دقائق", + "fourMinutes": "", + "oneMinute": "", + "tenMinutes": "10 دقائق", + "thirtyMinutes": "30 دقيقة", + "thirtySeconds": "", + "threeMinutes": "", + "twoMinutes": "" + } + } + }, + "title": "تكرار الفحص" + }, + "general": { + "option": { + "container": { + "label": "اسم/معرّف الحاوية", + "placeholder": "my-app أو abcd1234" + }, + "host": { + "label": "المضيف", + "placeholder": "192.168.1.100 أو example.com" + }, + "name": { + "label": "اسم العرض", + "placeholder": "مثال: موقعي الإلكتروني" + }, + "secret": { + "label": "مفتاح التفويض", + "placeholder": "أدخل مفتاحك السري" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "", + "placeholder": "" + }, + "port": { + "label": "المنفذ المراد مراقبته", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "اسم الخدمة", + "placeholder": "مثال: my.service.v1 (اتركه فارغاً للفحص العام)" + }, + "wsUrl": { + "label": "رابط WebSocket", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "نقطة نهاية Capture", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "الإعدادات العامة", + "description": { + "http": "أدخل الرابط أو عنوان IP للمراقبة (مثال: https://example.com/ أو 192.168.1.100) وأضف اسم عرض واضح يظهر في لوحة التحكم.", + "ping": "", + "port": "", + "docker": "", + "game": "أدخل عنوان IP أو اسم المضيف ورقم المنفذ للاختبار (مثل 192.168.1.100 أو example.com) واختر نوع اللعبة. تتطلب بعض الألعاب تفعيل استعلامات الخادم في ملف الإعدادات الخاص بها — يُرجى مراجعة وثائق خادم اللعبة. اطّلع على قائمة الألعاب المدعومة لمعرفة تفاصيل البروتوكول.", + "pagespeed": "تتبع أداء تحميل الصفحة ومؤشرات Core Web Vitals ودرجات التحسين لموقعك.", + "grpc": "أدخل اسم المضيف ومنفذ خادم gRPC، وحدد اختيارياً اسم خدمة لفحص الصحة، وأضف اسم عرض.", + "websocket": "أدخل رابط WebSocket للمراقبة (مثال: wss://example.com/socket) وأضف اسم عرض.", + "hardware": "راقب استخدام وحدة المعالجة المركزية والذاكرة والقرص ودرجة الحرارة لبنيتك التحتية. يجب أن يتضمن عنوان URL لنقطة نهاية Capture المسار الكامل ‎/api/v1/metrics‎ (مثال: ‎http://192.168.1.10:5001/api/v1/metrics‎). بالنسبة لمفتاح التفويض، الصق سلسلة الرمز فقط — لا تُضمّن كلمة 'Bearer'." + } + }, + "ignoreTls": { + "description": "تهيئة التحقق من شهادة TLS/SSL لاتصالات HTTPS.", + "option": { + "tls": { + "label": "تجاهل أخطاء TLS/SSL" + } + }, + "title": "إعدادات TLS/SSL" + }, + "incidents": { + "description": "يتم استخدام نافذة منزلقة لتحديد متى يتوقف المراقب. ستتغير حالة المراقب فقط عندما تستوفي نسبة الفحوصات في النافذة المنزلقة القيمة المحددة.", + "option": { + "checks": { + "label": "عدد الفحوصات في النافذة المنزلقة" + }, + "percentage": { + "label": "ما نسبة الفحوصات في النافذة المنزلقة التي يجب أن تفشل/تنجح قبل تغيير حالة المراقب؟" + } + }, + "title": "الحوادث" + }, + "notifications": { + "description": "اختر قنوات الإشعارات التي تريد استخدامها", + "title": "الإشعارات" + }, + "type": { + "description": "اختر نوع الفحص المراد تنفيذه", + "optionDockerDescription": "استخدم Docker لمراقبة ما إذا كانت الحاوية تعمل.", + "optionGameDescription": "مراقبة ما إذا كان خادم لعبة معين متصلاً.", + "optionGrpcDescription": "مراقبة خدمات gRPC باستخدام بروتوكول فحص الصحة القياسي.", + "optionWebSocketDescription": "مراقبة نقاط نهاية WebSocket لصحة الاتصال ووقت الاستجابة.", + "optionHttpDescription": "استخدم HTTP(S) لمراقبة موقعك الإلكتروني أو نقطة نهاية API.", + "optionPingDescription": "استخدم ICMP Ping لمراقبة ما إذا كان الخادم متصلاً.", + "optionPortDescription": "مراقبة ما إذا كان منفذ معين على الخادم مفتوحاً.", + "title": "النوع" + }, + "thresholds": { + "title": "حدود التنبيه", + "description": "حدد الحدود التي يجب عندها تشغيل التنبيهات لمراقب الأجهزة هذا.", + "option": { + "cpuThreshold": { + "label": "حد تنبيه CPU (%)" + }, + "memoryThreshold": { + "label": "حد تنبيه الذاكرة (%)" + }, + "diskThreshold": { + "label": "حد تنبيه القرص (%)" + }, + "tempThreshold": { + "label": "حد تنبيه الحرارة (°C)" + } + } + }, + "geoChecks": { + "title": "فحوصات موزعة جغرافياً", + "description": "تشغيل فحوصات من مواقع جغرافية متعددة لمراقبة التوفر والأداء العالمي.", + "option": { + "enabled": { + "label": "تفعيل الفحوصات الموزعة جغرافياً" + }, + "locations": { + "label": "المواقع", + "placeholder": "اختر المواقع", + "options": { + "EU": "أوروبا", + "NA": "أمريكا الشمالية", + "AS": "آسيا", + "SA": "أمريكا الجنوبية", + "AF": "أفريقيا", + "OC": "أوقيانوسيا" + } + }, + "interval": { + "label": "فاصل الفحص", + "value": { + "fiveMinutes": "5 دقائق", + "tenMinutes": "10 دقائق", + "fifteenMinutes": "15 دقيقة", + "thirtyMinutes": "30 دقيقة" + } + } + } + }, + "url": { + "title": "عرض IP/URL للمراقب في صفحة الحالة", + "description": "عرض عنوان IP أو رابط المراقب في صفحة الحالة العامة. إذا تم تعطيله، سيظهر فقط اسم المراقب لحماية المعلومات الحساسة.", + "option": { + "showURL": { + "label": "عرض IP/URL في صفحة الحالة", + "enabled": "مفعّل", + "disabled": "معطّل" + } + } + }, + "stats": { + "title": "سجل المراقبة", + "description": "مسح جميع سجلات وإحصائيات المراقبة لفريقك. هذا الإجراء لا يمكن التراجع عنه.", + "buttonText": "مسح جميع الإحصائيات", + "dialog": { + "title": "مسح جميع سجلات المراقبة؟", + "description": "سيتم حذف جميع سجلات المراقبة والإحصائيات وبيانات الفحص لفريقك نهائياً. لا يمكن التراجع عن هذا الإجراء.", + "confirm": "نعم، مسح جميع الإحصائيات" + } + } + } + }, + "editUser": { + "form": { + "roles": { + "title": "الأدوار", + "description": "تعيين أدوار للمستخدم. يمكن اختيار أدوار متعددة." + } + }, + "dialog": { + "removeUser": { + "title": "إزالة المستخدم", + "content": "هل أنت متأكد أنك تريد إزالة {{name}} من فريقك؟ لا يمكن التراجع عن هذا الإجراء." + } + } + }, + "incidents": { + "fallback": { + "title": "لا حوادث — جميع الأنظمة سليمة", + "description": "عندما يتعطل جهاز مراقبة أو يتعافى، سيظهر الحادث هنا مع الأوقات وتفاصيل الجهاز وسجل الحل." + }, + "dialog": { + "resolveIncident": { + "title": "حل الحادثة", + "option": { + "comment": { + "label": "تعليق (اختياري)", + "placeholder": "أضف تعليقاً حول الحل..." + } + } + }, + "details": { + "analysis": "تحليل الحادثة", + "comment": "تعليق:", + "detailsLabel": "التفاصيل", + "downtime": "وقت التوقف:", + "message": "الرسالة:", + "monitor": "المراقب:", + "overview": "نظرة عامة", + "resolutionDetails": "تفاصيل الحل", + "resolutionType": "النوع:", + "resolutionTypes": { + "automatic": "تلقائي", + "manual": "يدوي" + }, + "resolve": "حل الحادثة", + "resolvedAt": "تم الحل في:", + "resolvedBy": "تم الحل بواسطة:", + "startedAt": "بدأت في:", + "status": "الحالة:", + "statusCode": "رمز الحالة:", + "timeline": "الجدول الزمني", + "title": "تفاصيل الحادثة", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "جميع المراقبات", + "monitor": "المراقب", + "resolutionType": "نوع الحل", + "resolutionTypes": { + "manual": "يدوي", + "automatic": "تلقائي", + "all": "الكل" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "الحوادث النشطة", + "active_zero": "لا توجد حوادث نشطة", + "active_one": "{{count}} حادثة نشطة", + "active_other": "{{count}} حوادث نشطة" + }, + "incidentStats": { + "avgResolutionTime": "متوسط وقت الحل", + "mostAffectedMonitor": "المراقب الأكثر تأثراً", + "title": "إحصائيات الحوادث", + "totalIncidents": "إجمالي الحوادث" + }, + "latestIncidents": { + "title": "أحدث الحوادث" + } + }, + "table": { + "actions": { + "details": "التفاصيل", + "goToMonitor": "الانتقال إلى المراقب", + "resolveManually": "حل يدوياً" + }, + "activeIncidents": "الحوادث النشطة", + "headers": { + "endTime": "وقت الانتهاء", + "resolutionType": "نوع الحل", + "startTime": "", + "statusCode": "رمز الحالة" + }, + "resolvedIncidents": "الحوادث المحلولة", + "status": { + "active": "نشط", + "resolved": "تم الحل" + } + }, + "header": { + "title": "الحوادث", + "description": "عندما يتعطل جهاز مراقبة أو يتعافى، تظهر الحوادث هنا مع الأوقات وتفاصيل الجهاز وسجل الحل." + } + }, + "infrastructure": { + "charts": { + "labels": { + "cpu": "استخدام CPU", + "disk": "استخدام القرص", + "memory": "استخدام الذاكرة", + "netBytesRecv": "{{name}} - البايتات المستلمة", + "netBytesSent": "{{name}} - البايتات المرسلة", + "netNoTraffic": "Capture يبلغ عن عدم وجود حركة بيانات على أي واجهة. هذا أمر طبيعي عندما يعمل Capture داخل حاوية لا ترى سوى واجهات الاسترجاع — قم بتشغيل Capture مباشرة على المضيف لرؤية نشاط الشبكة الحقيقي.", + "temp": "الحرارة" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "أقصى تردد", + "title": "استخدام CPU", + "upperLabel": "التردد الحالي" + }, + "disk": { + "lowerLabel": "متاح", + "title": "استخدام القرص {{idx}}", + "upperLabel": "مستخدم" + }, + "memory": { + "lowerLabel": "متاح", + "title": "استخدام الذاكرة", + "upperLabel": "مستخدم" + } + }, + "statBoxes": { + "avgCpuTemperature": "", + "cpuFrequency": "", + "cpuLogical": "", + "cpuPhysical": "", + "disk": "القرص", + "memory": "الذاكرة", + "os": "" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "القرص", + "memory": "الذاكرة" + } + }, + "tabs": { + "labels": { + "network": "", + "overview": "نظرة عامة" + } + }, + "fallback": { + "actionButton": "أنشئ مراقباً!", + "title": "لا توجد بعد أجهزة مراقبة للبنية التحتية", + "description": "تتبّع أداء الخوادم وحدد الاختناقات واضمن الموثوقية مع مراقبة البنية التحتية في الوقت الفعلي." + }, + "header": { + "title": "مراقبات البنية التحتية", + "description": "تتبّع أداء الخوادم وحدد الاختناقات واضمن الموثوقية مع مراقبة البنية التحتية في الوقت الفعلي." + } + }, + "logs": { + "fallback": { + "title": "لا توجد سجلات بعد", + "description": "ستظهر هنا نشاط الخادم والتحذيرات والأخطاء أثناء تشغيل Checkmate." + }, + "tabs": { + "diagnostics": "التشخيص", + "logs": "السجلات", + "queue": "قائمة المهام" + }, + "logLevelSelect": { + "label": "مستوى السجل" + }, + "jobQueue": "قائمة المهام", + "jobQueueExplainer": "الصفوف المميزة هي المهام التي تتم معالجتها حالياً بواسطة عامل.", + "failedJobs": "المهام الفاشلة", + "failedJobsExplainer": "حالات الفشل الأخيرة حسب المراقب — وقت آخر فشل، وعدد مرات الفشل، والسبب.", + "noLogs": "لم يتم العثور على سجلات", + "metrics": { + "jobs": "المهام", + "activeJobs": "المهام النشطة", + "failingJobs": "المهام الفاشلة", + "totalRuns": "إجمالي عمليات التشغيل", + "totalFailures": "إجمالي حالات الفشل" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "تأخير حلقة الأحداث", + "uptime": "وقت التشغيل", + "usedHeapSize": "حجم الذاكرة المستخدمة", + "totalHeapSize": "إجمالي حجم الذاكرة", + "osMemoryLimit": "حد ذاكرة نظام التشغيل" + }, + "gauges": { + "heapAllocation": "تخصيص الذاكرة", + "heapUsage": "استخدام الذاكرة", + "heapUtilization": "معدل استخدام الذاكرة", + "instantCpuUsage": "استخدام CPU اللحظي", + "availableMemoryPercentage": "% من الذاكرة المتاحة", + "allocatedPercentage": "% مخصص", + "usedSPercentage": "% من 1 ثانية مستخدمة بواسطة CPU", + "total": "الإجمالي", + "used": "مستخدم" + }, + "sections": { + "runtimeStats": { + "title": "إحصائيات التشغيل", + "description": "لقطة لعملية Node.js والجهاز المضيف. يتم التحديث كل 5 ثوانٍ." + }, + "memoryCpu": { + "title": "الذاكرة والمعالج", + "description": "كيف تستخدم العملية الذاكرة المخصصة لها ومعالج الجهاز المضيف." + } + } + }, + "table": { + "headers": { + "timestamp": "الطابع الزمني", + "level": "المستوى", + "service": "الخدمة", + "method": "الطريقة", + "monitorId": "معرّف المراقب", + "runCount": "عدد التشغيلات", + "failCount": "عدد حالات الفشل", + "lastRunAt": "آخر تشغيل في", + "lockedAt": "مقفل في", + "lastFinishedAt": "آخر انتهاء في", + "lastRunTook": "مدة آخر تشغيل", + "lastFailedAt": "آخر فشل في", + "failReason": "سبب الفشل" + } + }, + "queueFallback": { + "title": "لا توجد مهام في قائمة الانتظار", + "description": "ستظهر هنا فحوصات أجهزة المراقبة المجدولة أو قيد التشغيل. قائمة الانتظار الفارغة تعني أن كل شيء محدّث." + }, + "header": { + "title": "السجلات وقائمة الانتظار", + "description": "افحص سجلات الخادم وقائمة انتظار مهام المراقبة وتشخيصات وقت التشغيل في مكان واحد." + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "أنشئ نافذة صيانة!", + "title": "لا توجد نوافذ صيانة مجدولة", + "description": "حدد مواعيد التوقف المخطط لها لإيقاف التنبيهات وإعلام فريقك ومستخدميك بما يتوقعونه." + }, + "table": { + "headers": { + "nextWindow": "", + "repeat": "" + } + }, + "form": { + "general": { + "title": "الإعدادات العامة", + "description": "حدد اسماً وخيار التكرار لنافذة الصيانة الخاصة بك.", + "option": { + "name": { + "label": "الاسم", + "placeholder": "مثال: صيانة أسبوعية" + }, + "repeat": { + "label": "تكرار" + } + } + }, + "startDate": { + "title": "تاريخ البدء", + "description": "اختر تاريخ البدء لنافذة الصيانة الخاصة بك.", + "option": { + "startDate": { + "label": "تاريخ البدء" + } + } + }, + "startTime": { + "title": "وقت البدء", + "description": "حدد وقت البدء والمدة لنافذة الصيانة الخاصة بك. جميع القيم بتوقيت UTC", + "option": { + "duration": { + "label": "المدة" + }, + "startTime": { + "label": "وقت البدء" + } + }, + "monitors": { + "title": "المراقبات", + "description": "المراقبات التي يجب أن تنطبق عليها نافذة الصيانة", + "option": { + "addMonitors": { + "label": "إضافة مراقبات" + } + } + } + } + }, + "header": { + "title": "نوافذ الصيانة", + "description": "حدد مواعيد التوقف المخطط لها لإيقاف التنبيهات وإعلام فريقك ومستخدميك بما يتوقعونه." + } + }, + "notifications": { + "fallback": { + "actionButton": "إنشاء قناة", + "title": "لا توجد بعد قنوات إشعار", + "description": "اربط Slack وDiscord والبريد الإلكتروني وWebhooks والمزيد ليعلم الأشخاص المناسبون بالحوادث فور حدوثها." + }, + "form": { + "accessToken": { + "optionAccessToken": "رمز الوصول", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "العنوان الذي سيتم إرسال الإشعارات إليه.", + "optionAddress": "العنوان", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "العنوان" + }, + "homeServer": { + "optionHomeServer": "الخادم الرئيسي", + "placeholder": "example.com" + }, + "matrix": { + "description": "تهيئة اتصال خادم Matrix الرئيسي للإشعارات.", + "title": "تهيئة Matrix" + }, + "notificationName": { + "description": "اسم وصفي لقناة الإشعارات", + "optionName": "اسم القناة", + "placeholder": "مثال: تنبيهات الإنتاج", + "title": "اسم القناة" + }, + "pagerDuty": { + "description": "مفتاح تكامل PagerDuty لتلقي التنبيهات.", + "optionIntegrationKey": "مفتاح التكامل", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "مفتاح التكامل" + }, + "roomId": { + "optionRoomId": "معرّف الغرفة", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "اختر نوع قناة الإشعارات المراد إنشاؤها.", + "optionType": "النوع", + "title": "نوع القناة" + }, + "telegram": { + "title": "تهيئة Telegram", + "description": "", + "optionBotToken": "رمز البوت", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "معرّف المحادثة", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "إعدادات Pushover", + "description": "أدخل رمز التطبيق ومفتاح المستخدم في Pushover.", + "optionAppToken": "رمز التطبيق", + "placeholderAppToken": "أدخل رمز التطبيق", + "optionUserKey": "مفتاح المستخدم", + "placeholderUserKey": "أدخل مفتاح المستخدم" + }, + "twilio": { + "title": "إعدادات Twilio", + "description": "أدخل بيانات اعتماد حساب Twilio لإرسال الرسائل النصية.", + "optionAccountSid": "معرف الحساب (SID)", + "placeholderAccountSid": "أدخل معرف الحساب", + "optionAuthToken": "رمز المصادقة", + "placeholderAuthToken": "أدخل رمز المصادقة", + "optionFromNumber": "من الرقم", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "إلى الرقم", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "الوجهة" + } + }, + "header": { + "title": "الإشعارات", + "description": "اربط Slack وDiscord والبريد الإلكتروني وWebhooks والمزيد ليعلم الأشخاص المناسبون بالحوادث فور حدوثها." + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "إزاحة التخطيط التراكمية (CLS)", + "fcp": "أول رسم للمحتوى (FCP)", + "lcp": "أكبر رسم للمحتوى (LCP)", + "si": "مؤشر السرعة (SI)", + "tbt": "إجمالي وقت الحظر (TBT)" + }, + "legend": { + "title": "تقرير PageSpeed", + "weight": "الوزن" + }, + "pie": { + "title": "تقرير الأداء" + } + }, + "table": { + "headers": { + "pageSpeedScore": "درجة PageSpeed" + } + }, + "fallback": { + "actionButton": "أنشئ مراقباً!", + "title": "لا توجد بعد أجهزة مراقبة PageSpeed", + "description": "تابع Core Web Vitals ودرجات Lighthouse عبر الزمن لاكتشاف التراجع قبل أن يلاحظه المستخدمون." + }, + "header": { + "title": "مراقبات PageSpeed", + "description": "تابع Core Web Vitals ودرجات Lighthouse عبر الزمن لاكتشاف التراجع قبل أن يلاحظه المستخدمون." + } + }, + "settings": { + "form": { + "timezone": { + "title": "المنطقة الزمنية للعرض", + "description": "اختر المنطقة الزمنية المستخدمة لعرض التواريخ والأوقات في جميع أنحاء التطبيق.", + "option": { + "timezone": { + "label": "المنطقة الزمنية للعرض" + } + } + }, + "ui": { + "title": "المظهر", + "description": "التبديل بين الوضع الفاتح والداكن، أو تغيير اللغة، أو تخصيص نوع عرض المخططات.", + "option": { + "theme": { + "label": "وضع السمة", + "light": "فاتح", + "dark": "داكن" + }, + "language": { + "label": "اللغة" + }, + "chartType": { + "label": "نوع المخطط", + "histogram": "مدرج تكراري", + "heatmap": "خريطة حرارية" + } + } + }, + "pagespeed": { + "title": "مفتاح Google PageSpeed API", + "description": "أدخل مفتاح Google PageSpeed API لتفعيل مراقبة Google PageSpeed. انقر على إعادة تعيين لتحديث المفتاح.", + "option": { + "apiKey": { + "label": "مفتاح PageSpeed API", + "labelSet": "تم تعيين مفتاح API. انقر على إعادة تعيين لتغييره.", + "placeholder": "أدخل مفتاح Google PageSpeed API الخاص بك" + } + } + }, + "url": { + "title": "عرض IP/URL للمراقب في صفحة الحالة", + "description": "عرض عنوان IP أو رابط المراقب في صفحة الحالة العامة. إذا تم تعطيله، سيظهر فقط اسم المراقب لحماية المعلومات الحساسة.", + "option": { + "showURL": { + "label": "عرض IP/URL في صفحة الحالة", + "enabled": "مفعّل", + "disabled": "معطّل" + } + } + }, + "stats": { + "title": "سجل المراقبة", + "description": "مسح جميع سجلات وإحصائيات المراقبة لفريقك. هذا الإجراء لا يمكن التراجع عنه.", + "option": { + "clear": { + "label": "مسح جميع الإحصائيات. هذا الإجراء لا يمكن التراجع عنه." + } + }, + "dialog": { + "title": "مسح جميع سجلات المراقبة؟", + "description": "لا يمكن التراجع عن هذا" + } + }, + "retention": { + "title": "الاحتفاظ بالفحوصات", + "description": "حدد مدة الاحتفاظ ببيانات الفحص قبل التنظيف التلقائي.", + "option": { + "days": { + "label": "فترة الاحتفاظ (أيام)", + "unlimited": "غير محدود" + } + } + }, + "thresholds": { + "title": "الحدود العامة", + "description": "تعيين حدود عامة لـ CPU والذاكرة والقرص والحرارة لمراقبة البنية التحتية. تنطبق على جميع مراقبات الأجهزة ما لم يتم تجاوزها.", + "option": { + "cpu": { + "label": "حد CPU (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "حد الذاكرة (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "حد القرص (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "حد الحرارة (°C)", + "placeholder": "1 - 150" + } + } + }, + "email": { + "title": "إعدادات البريد الإلكتروني", + "description": "تهيئة إعدادات البريد الإلكتروني لنظامك. يُستخدم لإرسال الإشعارات والتنبيهات.", + "descriptionTransport": "", + "descriptionTransportLink": "اطلع على المواصفات هنا", + "option": { + "host": { + "label": "مضيف البريد - اسم المضيف أو عنوان IP للاتصال", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "منفذ البريد - المنفذ للاتصال", + "placeholder": "587" + }, + "address": { + "label": "عنوان البريد الإلكتروني - يُستخدم للمصادقة", + "placeholder": "you@example.com" + }, + "user": { + "label": "مستخدم البريد - اسم المستخدم للمصادقة، يتجاوز عنوان البريد إذا تم تحديده", + "placeholder": "اتركه فارغاً إذا لم يكن مطلوباً" + }, + "password": { + "label": "كلمة مرور البريد - كلمة المرور للمصادقة", + "labelSet": "تم تعيين كلمة المرور. انقر على إعادة تعيين لتغييرها.", + "placeholder": "أدخل كلمة المرور" + }, + "tlsServername": { + "label": "اسم خادم TLS - اسم مضيف اختياري للتحقق من TLS عندما يكون المضيف عنوان IP", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "مضيف اتصال البريد - اسم المضيف المستخدم في تحية HELO/EHLO", + "placeholder": "localhost" + }, + "secure": { + "label": "استخدام SSL (موصى به): تشفير الاتصال باستخدام SSL/TLS" + }, + "pool": { + "label": "تفعيل تجميع الاتصالات: إعادة استخدام الاتصالات الموجودة لتحسين الأداء" + }, + "ignoreTLS": { + "label": "تعطيل STARTTLS: عدم استخدام TLS حتى لو كان الخادم يدعمه" + }, + "requireTLS": { + "label": "فرض STARTTLS: يتطلب ترقية TLS، يفشل إذا لم يكن مدعوماً" + }, + "rejectUnauthorized": { + "label": "رفض الشهادات غير الصالحة: رفض الاتصالات ذات الشهادات الموقعة ذاتياً أو غير الموثوقة" + } + } + }, + "demoMonitors": { + "title": "مراقبات تجريبية", + "description": "إضافة مراقبات نموذجية لأغراض العرض التوضيحي." + }, + "removeMonitors": { + "title": "إعادة ضبط النظام", + "description": "إزالة جميع المراقبات من نظامك.", + "dialog": { + "title": "إزالة جميع المراقبات؟", + "description": "لا يمكن التراجع عن هذا الإجراء.", + "paragraph": "سيتم أيضًا إزالة جميع سجلات الفحص والحوادث ونوافذ الصيانة المجدولة وأي صفحات حالة كانت تعتمد على هذه المراقبات. صدّر المراقبات أولًا للاحتفاظ بنسخة احتياطية من بياناتها." + } + }, + "exportMonitors": { + "title": "تصدير المراقبات" + }, + "importExportMonitors": { + "title": "استيراد / تصدير المراقبات", + "description": "استيراد أو تصدير بيانات المراقبات كملف JSON للنسخ الاحتياطي أو النقل." + }, + "about": { + "title": "حول", + "developedBy": "تم التطوير بواسطة Bluewave Labs" + }, + "validation": { + "errorMessage": "يرجى إصلاح أخطاء التحقق التالية:" + } + }, + "header": { + "title": "الإعدادات", + "description": "تكوين SMTP والتكاملات والاحتفاظ والسلوك العام للتطبيق." + } + }, + "statusPages": { + "deleteSuccess": "تم حذف صفحة الحالة بنجاح", + "fallback": { + "title": "لا توجد بعد صفحات حالة", + "actionButton": "أنشئ صفحة حالة!", + "description": "انشر صفحة عامة تُظهر للعملاء وأصحاب المصلحة وقت التشغيل الفعلي وتاريخ الحوادث." + }, + "monitorsList": { + "chartTypeHeatmap": "خريطة حرارية", + "chartTypeHistogram": "مدرج تكراري", + "noData": "لا تتوفر بيانات", + "infrastructure": { + "title": "البنية التحتية", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "الذاكرة", + "disk": "القرص", + "usage": "الاستخدام", + "used": "مستخدم", + "total": "الإجمالي" + }, + "uptime": { + "title": "وقت التشغيل", + "responseTime": "وقت الاستجابة" + } + }, + "statusBar": { + "allDown": "جميع الأنظمة متوقفة", + "allUp": "جميع الأنظمة تعمل بشكل طبيعي", + "degraded": "بعض الأنظمة تواجه مشكلات", + "unknown": "تعذر تحديد حالة النظام", + "allBreached": "تجاوزت جميع الأنظمة الحدود", + "allMaintenance": "جميع الأنظمة قيد الصيانة", + "allPaused": "جميع الأنظمة متوقفة مؤقتًا", + "allInitializing": "جميع الأنظمة قيد التهيئة", + "breachedAndDown": "تجاوزت بعض الأنظمة الحدود وهي معطلة", + "breached": "تجاوزت بعض الأنظمة الحدود", + "maintenanceAndDown": "بعض الأنظمة معطلة أثناء الصيانة", + "maintenance": "بعض الأنظمة قيد الصيانة", + "partiallyPaused": "بعض الأنظمة متوقفة مؤقتًا", + "initializing": "بعض الأنظمة قيد التهيئة", + "noMonitors": "لم يتم تكوين أي شاشة" + }, + "table": { + "headers": { + "name": "اسم صفحة الحالة", + "url": "الرابط العام" + }, + "published": "منشور", + "unpublished": "غير منشور" + }, + "title": "صفحات الحالة", + "details": { + "empty": { + "title": "لا يوجد شيء هنا بعد", + "addMonitor": "أضف مراقباً للبدء" + } + }, + "form": { + "access": { + "title": "الوصول", + "description": "إذا كانت صفحة الحالة جاهزة، يمكنك وضع علامة منشور عليها.", + "option": { + "published": { + "name": "منشورة ومرئية للعامة" + } + } + }, + "basicInfo": { + "title": "المعلومات الأساسية", + "description": "حدد اسم الشركة والنطاق الفرعي الذي تشير إليه صفحة الحالة.", + "option": { + "name": { + "label": "اسم الشركة", + "placeholder": "Acme Inc." + }, + "url": { + "label": "عنوان صفحة الحالة", + "placeholder": "my-status-page" + } + } + }, + "monitors": { + "title": "المراقبات", + "description": "اختر المراقبات لعرضها في صفحة الحالة.", + "noMonitors": "لم يتم اختيار مراقبات", + "option": { + "monitors": { + "label": "اختر المراقبات", + "placeholder": "ابحث واختر المراقبات..." + } + } + }, + "timezone": { + "title": "المنطقة الزمنية", + "description": "اختر المنطقة الزمنية التي ستُعرض بها صفحة الحالة.", + "option": { + "timezone": { + "label": "المنطقة الزمنية", + "placeholder": "اختر المنطقة الزمنية..." + } + } + }, + "appearance": { + "title": "المظهر", + "description": "حدد المظهر الافتراضي لصفحة الحالة العامة.", + "option": { + "logo": { + "label": "الشعار" + }, + "color": { + "label": "لون العلامة التجارية" + } + } + }, + "features": { + "title": "الميزات", + "description": "تهيئة المعلومات المعروضة في صفحة الحالة.", + "option": { + "showCharts": { + "label": "إظهار مخططات وقت الاستجابة" + }, + "showUptimePercentage": { + "label": "إظهار نسبة وقت التشغيل" + }, + "showAdminLoginLink": { + "label": "إظهار رابط تسجيل دخول المسؤول" + }, + "showInfrastructure": { + "label": "إظهار مقاييس البنية التحتية" + } + } + } + }, + "header": { + "title": "صفحات الحالة", + "description": "انشر صفحات عامة تُظهر للعملاء وأصحاب المصلحة وقت التشغيل الفعلي وتاريخ الحوادث." + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "البحث في المراقبات..." + } + }, + "table": { + "headers": { + "responseTime": "وقت الاستجابة" + } + }, + "fallback": { + "actionButton": "أنشئ مراقباً!", + "title": "لا توجد بعد أجهزة مراقبة وقت التشغيل", + "description": "راقب نقاط نهاية HTTP وعمليات Ping والحاويات والمنافذ — احصل على تنبيه فور حدوث عطل." + }, + "header": { + "title": "مراقبات وقت التشغيل", + "description": "راقب نقاط نهاية HTTP وعمليات Ping والحاويات والمنافذ — احصل على تنبيه فور حدوث عطل." } } - }, - "createNotifications": { - "title": "", - "nameSettings": { - "title": "", - "description": "", - "nameLabel": "", - "namePlaceholder": "" - }, - "typeSettings": { - "title": "", - "description": "", - "typeLabel": "" - }, - "emailSettings": { - "title": "", - "description": "", - "emailLabel": "", - "emailPlaceholder": "" - }, - "slackSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "pagerdutySettings": { - "title": "", - "description": "", - "integrationKeyLabel": "", - "integrationKeyPlaceholder": "" - }, - "discordSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "webhookSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "testNotification": "", - "dialogDeleteTitle": "", - "dialogDeleteConfirm": "" - }, - "notificationConfig": { - "title": "", - "description": "" - }, - "monitorStatus": { - "checkingEvery": "", - "withCaptureAgent": "", - "up": "", - "down": "", - "paused": "" - }, - "advancedMatching": "", - "sendTestNotifications": "", - "selectAll": "", - "showAdminLoginLink": "", - "logsPage": { - "title": "", - "description": "", - "tabs": { - "queue": "", - "logs": "", - "diagnostics": "" - }, - "toast": { - "fetchLogsSuccess": "" - }, - "logLevelSelect": { - "title": "", - "values": { - "all": "", - "info": "", - "warn": "", - "error": "", - "debug": "" - } - } - }, - "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", - "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "", - "activeHeader": "", - "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "", - "lastRunTookHeader": "", - "intervalHeader": "" - }, - "metricsTable": { - "title": "", - "metricHeader": "", - "valueHeader": "" - }, - "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", - "failedAtHeader": "", - "failReasonHeader": "" - } - }, - "export": { - "title": "", - "success": "", - "failed": "" - }, - "monitorActions": { - "title": "", - "import": "", - "export": "", - "deleteSuccess": "", - "deleteFailed": "", - "details": "" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "" - }, - "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" - }, - "emailSettings": { - "buttonSendTestEmail": "", - "description": "", - "descriptionTransport": "", - "labelAddress": "", - "labelConnectionHost": "", - "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", - "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", - "labelRequireTLS": "", - "labelSecure": "", - "labelTLSServername": "", - "labelUser": "", - "linkTransport": "", - "placeholderUser": "", - "title": "", - "toastEmailRequiredFieldsError": "" - }, - "pageSpeedSettings": { - "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "" - }, - "saveButtonLabel": "", - "statsSettings": { - "clearAllStatsButton": "", - "clearAllStatsDescription": "", - "clearAllStatsDialogConfirm": "", - "clearAllStatsDialogDescription": "", - "clearAllStatsDialogTitle": "", - "description": "", - "labelTTL": "", - "labelTTLOptional": "", - "title": "" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", - "dialogDescription": "", - "dialogTitle": "", - "title": "" - }, - "timezoneSettings": { - "description": "", - "label": "", - "title": "" - }, - "title": "", - "uiSettings": { - "description": "", - "labelLanguage": "", - "labelTheme": "", - "title": "" - }, - "urlSettings": { - "description": "", - "label": "", - "selectDisabled": "", - "selectEnabled": "", - "title": "" - }, - "globalThresholds": { - "title": "", - "description": "" - } - }, - "statusPageCreate": { - "buttonSave": "" - }, - "incidentsOptionsHeaderFilterResolved": "", - "settingsSave": "", - "statusPageCreateAppearanceTitle": "", - "confirmPassword": "", - "monitorHooks": { - "failureAddDemoMonitors": "", - "successAddDemoMonitors": "" - }, - "settingsAppearance": "", - "settingsDisplayTimezone": "", - "settingsGeneralSettings": "", - "incidentsOptionsHeaderTotalIncidents": "", - "statusPage": { - "deleteSuccess": "", - "deleteFailed": "", - "createSuccess": "", - "updateSuccess": "", - "generalSettings": "", - "contents": "", - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "testNotificationsDisabled": "", - "incidentsTableResolvedAt": "", - "incidentsTableActionResolve": "", - "checkHooks": { - "failureResolveOne": "", - "failureResolveAll": "", - "failureResolveMonitor": "" - }, - "checkFormError": "", - "diagnosticsPage": { - "diagnosticDescription": "", - "statsDescription": "", - "gauges": { - "heapAllocationTitle": "", - "heapAllocationSubtitle": "", - "heapUsageTitle": "", - "heapUsageSubtitle": "", - "heapUtilizationTitle": "", - "heapUtilizationSubtitle": "", - "instantCpuUsageTitle": "", - "instantCpuUsageSubtitle": "" - }, - "stats": { - "eventLoopDelayTitle": "", - "uptimeTitle": "", - "usedHeapSizeTitle": "", - "totalHeapSizeTitle": "", - "osMemoryLimitTitle": "" - } - }, - "pageSpeedLighthouseAPI": "", - "time": { - "threeMinutes": "", - "fiveMinutes": "", - "tenMinutes": "", - "twentyMinutes": "", - "oneHour": "", - "oneDay": "", - "oneWeek": "", - "fourMinutes": "", - "oneMinute": "", - "twoMinutes": "", - "fifteenSeconds": "", - "thirtySeconds": "" - }, - "general": { - "noOptionsFound": "" - }, - "infrastructureMonitor": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "pageSpeed": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "editUserPage": { - "form": { - "email": "", - "firstName": "", - "lastName": "", - "role": "", - "save": "" - }, - "table": { - "actionHeader": "", - "roleHeader": "" - }, - "title": "", - "toast": { - "successUserUpdate": "", - "validationErrors": "" - } - }, - "incidentsPageActionResolveMonitor": "", - "incidentsPageActionResolveAll": "", - "matchMethodOptions": { - "equal": "", - "equalPlaceholder": "", - "include": "", - "includePlaceholder": "", - "regex": "", - "regexPlaceholder": "", - "text": "" - }, - "monitorType": { - "docker": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "http": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "ping": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "port": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "game": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "" - }, - "bytesPerSecond": "", - "bytesReceived": "", - "bytesSent": "", - "chooseGame": "", - "createMonitorPage": { - "incidentConfigDescription": "", - "incidentConfigStatusWindowLabel": "", - "incidentConfigStatusWindowThresholdLabel": "", - "incidentConfigTitle": "", - "incidentConfigDescriptionV2": "", - "incidentConfigStatusCheckNumber": "", - "intervalTitle": "", - "intervalDescription": "" - }, - "dataRate": "", - "dataReceived": "", - "dataSent": "", - "details": "", - "drops": "", - "errors": "", - "errorsIn": "", - "errorsOut": "", - "gameServerMonitoring": "", - "gameServerMonitoringDescription": "", - "network": "", - "networkDrops": "", - "networkErrors": "", - "networkInterface": "", - "noNetworkStatsAvailable": "", - "packetsPerSecond": "", - "packetsReceived": "", - "packetsReceivedRate": "", - "packetsSent": "", - "rate": "", - "selectInterface": "" + } } diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json new file mode 100644 index 000000000..fe80cf9c4 --- /dev/null +++ b/client/src/locales/ca.json @@ -0,0 +1,1489 @@ +{ + "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" + }, + "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" + } + } + }, + "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" + }, + "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" + } + } + }, + "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" + } + }, + "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'." + } + }, + "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" + }, + "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" + } + }, + "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." + } + }, + "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 837b49803..19d506399 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -1,1108 +1,1447 @@ { - "submit": "Odeslat", - "title": "", - "distributedStatusHeaderText": "", - "distributedStatusSubHeaderText": "", - "settingsDisabled": "Vypnuto", - "settingsSuccessSaved": "Nastavení bylo úspěšně uloženo", - "settingsFailedToSave": "Nepodařilo se uložit nastavení", - "settingsStatsCleared": "", - "settingsFailedToClearStats": "", - "settingsMonitorsDeleted": "", - "settingsFailedToDeleteMonitors": "", - "starPromptTitle": "", - "starPromptDescription": "", - "https": "", - "http": "", - "monitor": "", - "aboutus": "", - "now": "", - "delete": "", - "configure": "", - "responseTime": "", - "ms": "", - "bar": "", - "area": "", - "country": "", - "city": "", - "response": "", - "monitorStatusUp": "", - "monitorStatusDown": "", - "webhookSendSuccess": "", - "webhookSendError": "", - "webhookUnsupportedPlatform": "", - "distributedRightCategoryTitle": "", - "distributedStatusServerMonitors": "", - "distributedStatusServerMonitorsDescription": "", - "distributedUptimeCreateSelectURL": "", - "distributedUptimeCreateChecks": "", - "distributedUptimeCreateChecksDescription": "", - "distributedUptimeCreateIncidentNotification": "", - "distributedUptimeCreateIncidentDescription": "", - "distributedUptimeCreateAdvancedSettings": "", - "distributedUptimeDetailsNoMonitorHistory": "", - "distributedUptimeDetailsStatusHeaderUptime": "", - "distributedUptimeDetailsStatusHeaderLastUpdate": "", - "notifications": { - "enableNotifications": "", - "testNotification": "", - "addOrEditNotifications": "", - "slack": { - "label": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "discord": { - "label": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "telegram": { - "label": "", - "description": "", - "tokenLabel": "", - "tokenPlaceholder": "", - "chatIdLabel": "", - "chatIdPlaceholder": "", - "fieldsRequired": "" - }, - "webhook": { - "label": "", - "description": "", - "urlLabel": "", - "urlPlaceholder": "", - "urlRequired": "" - }, - "testNotificationDevelop": "", - "integrationButton": "", - "testSuccess": "", - "testFailed": "", - "unsupportedType": "", - "networkError": "", - "fallback": { - "title": "", - "checks": [""], - "actionButton": "" - }, - "createButton": "", - "createTitle": "", - "create": { - "success": "", - "failed": "" - }, - "fetch": { - "success": "", - "failed": "" - }, - "delete": { - "success": "", - "failed": "" - }, - "edit": { - "success": "", - "failed": "" - }, - "test": { - "success": "", - "failed": "" - } - }, - "testLocale": "", - "add": "", - "monitors": "", - "distributedUptimeStatusCreateStatusPage": "", - "distributedUptimeStatusCreateStatusPageAccess": "", - "distributedUptimeStatusCreateStatusPageReady": "", - "distributedUptimeStatusBasicInfoHeader": "", - "distributedUptimeStatusBasicInfoDescription": "", - "distributedUptimeStatusLogoHeader": "", - "distributedUptimeStatusLogoDescription": "", - "distributedUptimeStatusLogoUploadButton": "", - "distributedUptimeStatusStandardMonitorsHeader": "", - "distributedUptimeStatusStandardMonitorsDescription": "", - "distributedUptimeStatusCreateYour": "", - "distributedUptimeStatusEditYour": "", - "distributedUptimeStatusPublishedLabel": "", - "distributedUptimeStatusCompanyNameLabel": "", - "distributedUptimeStatusPageAddressLabel": "", - "distributedUptimeStatus30Days": "", - "distributedUptimeStatus60Days": "", - "distributedUptimeStatus90Days": "", - "distributedUptimeStatusPageNotSetUp": "", - "distributedUptimeStatusContactAdmin": "", - "distributedUptimeStatusPageNotPublic": "", - "distributedUptimeStatusPageDeleteDialog": "", - "distributedUptimeStatusPageDeleteConfirm": "", - "distributedUptimeStatusPageDeleteDescription": "", - "distributedUptimeStatusDevices": "", - "distributedUptimeStatusUpt": "", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "", - "incidentsTableNoIncidents": "", - "incidentsTablePaginationLabel": "", - "incidentsTableMonitorName": "", - "incidentsTableStatus": "", - "incidentsTableDateTime": "", - "incidentsTableStatusCode": "", - "incidentsTableMessage": "", - "incidentsOptionsHeader": "", - "incidentsOptionsHeaderFilterBy": "", - "incidentsOptionsHeaderFilterAll": "", - "incidentsOptionsHeaderFilterDown": "", - "incidentsOptionsHeaderFilterCannotResolve": "", - "incidentsOptionsHeaderShow": "", - "incidentsOptionsHeaderLastHour": "", - "incidentsOptionsHeaderLastDay": "", - "incidentsOptionsHeaderLastWeek": "", - "incidentsOptionsPlaceholderAllServers": "", - "infrastructureCreateYour": "", - "infrastructureCreateGeneralSettingsDescription": "", - "infrastructureServerRequirement": "", - "infrastructureCustomizeAlerts": "", - "infrastructureAlertNotificationDescription": "", - "infrastructureCreateMonitor": "", - "infrastructureProtocol": "", - "infrastructureServerUrlLabel": "", - "infrastructureDisplayNameLabel": "", - "infrastructureAuthorizationSecretLabel": "", - "gb": "", - "mb": "", - "mem": "", - "memoryUsage": "", - "cpu": "", - "cpuUsage": "", - "cpuTemperature": "", - "diskUsage": "", - "used": "", - "total": "", - "cores": "", - "frequency": "", - "status": "", - "cpuPhysical": "", - "cpuLogical": "", - "cpuFrequency": "", - "avgCpuTemperature": "", - "memory": "", - "disk": "", - "uptime": "", - "os": "", - "host": "", - "actions": "", - "integrations": "", - "integrationsPrism": "", - "integrationsSlack": "", - "integrationsSlackInfo": "", - "integrationsDiscord": "", - "integrationsDiscordInfo": "", - "integrationsZapier": "", - "integrationsZapierInfo": "", - "commonSave": "", - "createYour": "", - "createMonitor": "", - "pause": "", - "resume": "", - "editing": "", - "url": "", - "access": "", - "timezone": "", - "features": "", - "administrator": "", - "loginHere": "", - "displayName": "", - "urlMonitor": "", - "portToMonitor": "", - "websiteMonitoring": "", - "websiteMonitoringDescription": "", - "pingMonitoring": "", - "pingMonitoringDescription": "", - "dockerContainerMonitoring": "", - "dockerContainerMonitoringDescription": "", - "portMonitoring": "", - "portMonitoringDescription": "", - "createMaintenanceWindow": "", - "createMaintenance": "", - "editMaintenance": "", - "maintenanceWindowName": "", - "friendlyNameInput": "", - "friendlyNamePlaceholder": "", - "maintenanceRepeat": "", - "maintenance": "", - "duration": "", - "addMonitors": "", - "window": "", - "cancel": "", - "message": "", - "low": "", - "high": "", - "statusCode": "", - "date&Time": "", - "type": "", - "statusPageName": "", - "publicURL": "", - "repeat": "", - "edit": "", - "createA": "", - "remove": "", - "maintenanceWindowDescription": "", - "startTime": "", - "timeZoneInfo": "", - "monitorsToApply": "", - "nextWindow": "", - "notFoundButton": "", - "pageSpeedConfigureSettingsDescription": "", - "monitorDisplayName": "", - "whenNewIncident": "", - "notifySMS": "", - "notifyEmails": "", - "seperateEmails": "", - "checkFrequency": "", - "matchMethod": "", - "expectedValue": "", - "deleteDialogTitle": "", - "deleteDialogDescription": "", - "pageSpeedMonitor": "", - "shown": "", - "ago": "", - "companyName": "", - "pageSpeedDetailsPerformanceReport": "", - "pageSpeedDetailsPerformanceReportCalculator": "", - "checkingEvery": "", - "statusPageCreateSettings": "", - "basicInformation": "", - "statusPageCreateBasicInfoDescription": "", - "statusPageCreateSelectTimeZoneDescription": "", - "statusPageCreateAppearanceDescription": "", - "statusPageCreateSettingsCheckboxLabel": "", - "statusPageCreateBasicInfoStatusPageAddress": "", - "statusPageCreateTabsContent": "", - "statusPageCreateTabsContentDescription": "", - "statusPageCreateTabsContentFeaturesDescription": "", - "showCharts": "", - "showUptimePercentage": "", - "removeLogo": "", - "statusPageStatus": "", - "statusPageStatusContactAdmin": "", - "statusPageStatusNotPublic": "", - "statusPageStatusNoPage": "", - "statusPageStatusServiceStatus": "", - "deleteStatusPage": "", - "deleteStatusPageConfirm": "", - "deleteStatusPageDescription": "", - "uptimeCreate": "", - "uptimeCreateJsonPath": "", - "uptimeCreateJsonPathQuery": "", - "maintenanceTableActionMenuDialogTitle": "", - "infrastructureEditYour": "", - "infrastructureEditMonitor": "", - "infrastructureMonitorCreated": "", - "infrastructureMonitorUpdated": "", - "errorInvalidTypeId": "", - "errorInvalidFieldId": "", - "inviteNoTokenFound": "", - "pageSpeedWarning": "", - "pageSpeedLearnMoreLink": "", - "pageSpeedAddApiKey": "", - "update": "", - "invalidFileFormat": "", - "invalidFileSize": "", - "ClickUpload": "", - "DragandDrop": "", - "MaxSize": "", - "SupportedFormats": "", - "FirstName": "", - "LastName": "", - "EmailDescriptionText": "", - "YourPhoto": "", - "PhotoDescriptionText": "", - "save": "", - "DeleteDescriptionText": "", - "DeleteAccountWarning": "", - "DeleteWarningTitle": "", - "bulkImport": { - "title": "", - "selectFileTips": "", - "selectFileDescription": "", - "selectFile": "", - "parsingFailed": "", - "uploadSuccess": "", - "validationFailed": "", - "noFileSelected": "", - "fallbackPage": "", - "invalidFileType": "", - "uploadFailed": "" - }, - "DeleteAccountTitle": "", - "DeleteAccountButton": "", - "publicLink": "", - "maskedPageSpeedKeyPlaceholder": "", - "reset": "", - "ignoreTLSError": "", - "tlsErrorIgnored": "", - "ignoreTLSErrorDescription": "", - "createNew": "", - "greeting": { - "prepend": "", - "append": "", - "overview": "" - }, - "roles": { - "superAdmin": "", - "admin": "", - "teamMember": "", - "demoUser": "" - }, - "teamPanel": { - "teamMembers": "", - "filter": { - "all": "", - "member": "" - }, - "inviteTeamMember": "", - "inviteNewTeamMember": "", - "inviteDescription": "", - "email": "", - "selectRole": "", - "inviteLink": "", - "cancel": "", - "noMembers": "", - "getToken": "", - "emailToken": "", - "table": { - "name": "", - "email": "", - "role": "", - "created": "" - }, - "addTeamMember": { - "addMemberMenu": "", - "title": "", - "description": "", - "addButton": "" - }, - "register": "", - "registerToast": { - "success": "", - "dbUserExists": "", - "unknownError": "" - }, - "registerTeamMember": { - "title": "", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "lastName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "email": { - "errors": { - "empty": "", - "invalid": "" - } - }, - "role": { - "errors": { - "empty": "" - } - } - } - } - } - }, - "role": "", - "changeTeamPassword": { - "changePasswordMenu": "", - "title": "", - "description": "", - "success": "" - } - }, - "monitorState": { - "paused": "", - "resumed": "", - "active": "" - }, - "menu": { - "uptime": "", - "pagespeed": "", - "infrastructure": "", - "incidents": "", - "statusPages": "", - "maintenance": "", - "integrations": "", - "settings": "", - "support": "", - "discussions": "", - "docs": "", - "changelog": "", - "profile": "", - "password": "", - "team": "", - "logOut": "", - "notifications": "", - "logs": "" - }, - "settingsEmailUser": "", - "state": "", - "statusBreadCrumbsStatusPages": "", - "statusBreadCrumbsDetails": "", - "commonSaving": "", - "navControls": "", - "incidentsPageTitle": "", - "passwordPanel": { - "passwordChangedSuccess": "", - "passwordInputIncorrect": "", - "currentPassword": "", - "enterCurrentPassword": "", - "newPassword": "", - "enterNewPassword": "", - "confirmNewPassword": "", - "passwordRequirements": "", - "saving": "" - }, - "emailSent": "", - "failedToSendEmail": "", - "settingsTestEmailSuccess": "", - "settingsTestEmailFailed": "", - "settingsTestEmailFailedWithReason": "", - "settingsTestEmailUnknownError": "", - "statusMsg": { - "paused": "", - "up": "", - "down": "", - "pending": "" - }, - "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "", - "game": "", - "https": "" - }, "common": { - "appName": "Checkmate", - "monitoringAgentName": "Capture", - "buttons": { - "toggleTheme": "Přepnout mezi světlým a tmavým motivem" + "auth": { + "roles": { + "admin": "Administrátor", + "demo": "Demo", + "superadmin": "Superadministrátor", + "user": "Uživatel" + } }, - "toasts": { - "networkError": "Chyba připojení k síti", - "checkConnection": "Zkontrolujte prosím své připojení k síti", - "unknownError": "Nastala neznámá chyba" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "Pokračovat", - "back": "Zpět" + "alerts": { + "pageSpeedApiKey": { + "content": "Upozornění: Zatím jste nepřidali klíč Google PageSpeed API. Přejděte do Nastavení a přidejte ho. Bez něj nebude monitor PageSpeed fungovat." + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "Podrobnosti", + "home": "Domů" + }, + "buttons": { + "addMember": "Přidat člena", + "cancel": "Zrušit", + "close": "Zavřít", + "configure": "Konfigurovat", + "confirm": "Potvrdit", + "create": "Vytvořit", + "delete": "Smazat", + "generateToken": "Vygenerovat token", + "incidents": "Incidenty", + "inviteMember": "Pozvat člena", + "pause": "Pozastavit", + "resume": "Obnovit", + "save": "Uložit", + "sendInvite": "Odeslat pozvánku", + "test": "Test", + "testNotifications": "Testovat oznámení", + "toggleTheme": "Přepnout mezi světlým a tmavým motivem", + "flushQueue": "Vyprázdnit frontu", + "notFound": "Přejít na hlavní přehled", + "resetPassword": "Obnovit heslo", + "reset": "Resetovat", + "clear": "Vymazat", + "addDemo": "Přidat ukázkové monitory", + "removeMonitors": "Odebrat monitory", + "sendTestEmail": "Odeslat testovací email", + "exportToJSON": "Exportovat do JSON", + "importFromJSON": "Importovat z JSON", + "clearFilters": "Vymazat filtry", + "removeUser": "Odebrat uživatele" + }, + "charts": { + "labels": { + "averageResponseTime": "Průměrná doba odezvy", + "downtime": "Výpadek", + "high": "Vysoké", + "low": "Nízké", + "uptime": "Dostupnost" }, - "inputs": { - "email": { - "label": "E-mail", - "placeholder": "jan.novak@domena.cz", - "errors": { - "empty": "Zadejte prosím e-mailovou adresu", - "invalid": "Překontrolujte si prosím správnost zadané e-mailové adresy" - } - }, - "password": { - "label": "Heslo", - "rules": { - "length": { - "beginning": "Heslo musí být dlouhé alespoň", - "highlighted": "8 znaků" - }, - "special": { - "beginning": "Heslo musí obsahovat alespoň", - "highlighted": "1 speciální znak" - }, - "number": { - "beginning": "Heslo musí obsahovat alespoň", - "highlighted": "1 číslo" - }, - "uppercase": { - "beginning": "Heslo musí obsahovat alespoň", - "highlighted": "1 velké písmeno" - }, - "lowercase": { - "beginning": "Heslo musí obsahovat alespoň", - "highlighted": "1 malé písmeno" - }, - "match": { - "beginning": "Obě hesla se", - "highlighted": "musí shodovat" - } - }, - "errors": { - "empty": "Zadejte prosím heslo", - "length": "Heslo musí být dlouhé alespoň 8 znaků", - "uppercase": "Heslo musí obsahovat alespoň 1 velké písmeno", - "lowercase": "Heslo musí obsahovat alespoň 1 malé písmeno", - "number": "Heslo musí obsahovat alespoň 1 číslo", - "special": "Heslo musí obsahovat alespoň 1 speciální znak", - "incorrect": "Zadané heslo neodpovídá tomu, co bylo nastaveno" - } - }, - "passwordConfirm": { - "label": "Potvrzení hesla", - "placeholder": "Pro ověření správnosti zadejte heslo ještě jednou", - "errors": { - "empty": "Zadejte prosím své heslo ještě jednou, aby mohla být potvrzena jeho správnost (odhalí překlepy)", - "different": "Zadaná hesla se liší, takže je nejspíše jedno z nich špatně zapsané" - } - }, - "firstName": { - "label": "Jméno", - "placeholder": "Jan", - "errors": { - "empty": "Zadejte prosím své křestní jméno", - "length": "Jméno se musí vejít do 50 znaků", - "pattern": "Součástí jména mohou být pouze písmena, mezery apostrofy nebo spojovníky" - } - }, - "lastName": { - "label": "Příjmení", - "placeholder": "Novák", - "errors": { - "empty": "Zadejte prosím své příjmení", - "length": "Příjmení se musí vejít do 50 znaků", - "pattern": "Součástí příjmení mohou být pouze písmena, mezery apostrofy nebo spojovníky" + "histogram": { + "avg": "Prům.: {{value}} ms", + "max": "Max: {{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "Tuto akci nelze vrátit zpět.", + "title": "Opravdu chcete toto smazat?" + } + }, + "labels": { + "active": "Aktivní", + "paused": "pozastaveno", + "na": "N/A", + "resolved": "Vyřešeno", + "responseTime": "Doba odezvy" + }, + "form": { + "role": { + "option": { + "role": { + "label": "Role" } } }, - "errors": { - "validation": "Nastala chyba při ověřování dat." - }, - "fields": { - "password": { - "errors": { - "incorrect": "" + "name": { + "option": { + "firstName": { + "label": "Jméno", + "placeholder": "Jan" + }, + "lastName": { + "label": "Příjmení", + "placeholder": "Novák" } - }, - "role": { - "errors": { - "min": "" + } + }, + "email": { + "option": { + "email": { + "label": "Email", + "placeholder": "you@example.com" } } } }, - "login": { - "heading": "Přihlášení", - "subheadings": { - "stepOne": "Zadejte svůj e-mail", - "stepTwo": "Zadejte své heslo" - }, - "links": { - "forgotPassword": "Zapomněli jste heslo? Obnovte si ho", - "register": "Ještě nemáte účet? Zaregistrujte se", - "forgotPasswordLink": "", - "registerLink": "" - }, - "toasts": { - "success": "Vítejte zpět", - "incorrectPassword": "Nesprávné heslo" - }, + "table": { + "empty": "Zde nic není", + "headers": { + "actions": "Akce", + "dateTime": "Datum a čas", + "message": "Zpráva", + "monitor": "Monitor", + "monitorId": "ID monitoru", + "name": "Název", + "status": "Stav", + "type": "Typ", + "url": "URL", + "interval": "Interval", + "active": "Aktivní", + "responseTime": "Doba odezvy" + } + } + }, + "components": { + "imageUpload": { + "clickToUpload": "Klikněte pro nahrání", + "dragAndDrop": "nebo přetáhněte", + "supportedFormats": "Podporované formáty", + "maxSize": "Max. velikost", + "orDragAndDrop": "nebo přetáhněte soubor", "errors": { - "password": { - "incorrect": "" - } - }, - "welcome": "" + "invalidFileSize": "Soubor je příliš velký!", + "invalidFileFormat": "Nepodporovaný formát souboru!" + } }, - "registration": { - "heading": { - "superAdmin": "Vytvoření superadministrátora", - "user": "Registrace" - }, - "subheadings": { - "stepOne": "Zadejte své osobní údaje", - "stepTwo": "Zadejte svůj e-mail", - "stepThree": "Nastavte si heslo" + "headerStatusPageControls": { + "publicLink": "Veřejný odkaz" + }, + "headerTimeRange": { + "labels": { + "day": "Den", + "month": "Měsíc", + "recent": "Nedávné", + "week": "Týden" }, "description": { - "superAdmin": "Pro začátek vytvořte účet superadministrátora", - "user": "Zaregistrujte se jako uživatel a požádejte svého superadministrátora, aby vám přidělil oprávnění k hlídačům" + "recent": "Zobrazení statistik za poslední 2 hodiny.", + "day": "Zobrazení statistik za posledních 24 hodin.", + "week": "Zobrazení statistik za posledních 7 dní.", + "month": "Zobrazení statistik za posledních 30 dní." + } + }, + "offlineBanner": { + "serverUnreachable": "Nelze se spojit se serverem", + "retry": "Zkusit znovu", + "retrying": "Opakuji pokus...", + "reconnected": "Podařilo se úspěšně obnovit spojení se serverem" + }, + "sidebar": { + "menu": { + "uptime": "Dostupnost", + "pagespeed": "Pagespeed", + "infrastructure": "Infrastruktura", + "notifications": "Oznámení", + "checks": "Kontroly", + "incidents": "Incidenty", + "statusPages": "Stavové stránky", + "maintenance": "Údržba", + "logs": "Záznamy", + "settings": "Nastavení" }, - "gettingStartedButton": { - "superAdmin": "Vytvořit účet superadministrátora", - "user": "Zaregistrovat se jako běžný uživatel" + "bottomMenu": { + "support": "Podpora", + "discussions": "Diskuze", + "docs": "Dokumentace", + "changelog": "Záznam změn" }, - "termsAndPolicies": "Vytvořením účtu souhlasíte s našimi Podmínkami použití (anglicky) a Podmínkami zpracování osobních údajů (anglicky).", + "accountMenu": { + "profile": "Profil", + "password": "Heslo", + "team": "Tým" + }, + "starPrompt": { + "title": "Ohodnoťte Checkmate", + "description": "Sledujte nejnovější vydání a pomozte rozšířit komunitu na GitHubu" + }, + "authFooter": { + "navControls": "Ovládací prvky", + "logOut": "Odhlásit se", + "roles": { + "superAdmin": "Superadministrátor", + "admin": "Administrátor", + "user": "Uživatel", + "demoUser": "Demo uživatel" + } + } + }, + "starPrompt": { + "title": "Ohodnoťte Checkmate", + "description": "Sledujte nejnovější vydání a pomozte rozšířit komunitu na GitHubu" + }, + "pageHeader": { "links": { - "login": "Máte již vytvořený účet? Přihlašte se" - }, - "toasts": { - "success": "Vítejte! Váš účet byl úspěšně vytvořen." - }, - "welcome": "" - }, - "forgotPassword": { - "heading": "Zapomenuté heslo", - "subheadings": { - "stepOne": "Nebojte se, zašleme vám pokyny pro obnovení hesla.", - "stepTwo": "Odkaz pro obnovení hesla byl odeslán na ", - "stepThree": "Nové heslo se musí lišit od těch, které jste již použili.", - "stepFour": "Heslo bylo úspěšně obnoveno. Klepněte na tlačítko a dojde k čarovnému přihlášení." - }, - "buttons": { - "openEmail": "Otevřít e-mailovou aplikaci", - "resetPassword": "Obnovit heslo" - }, - "imageAlts": { - "passwordKey": "Ikonka přístupového klíče", - "email": "Ikonka e-mailu", - "lock": "Ikonka zámku", - "passwordConfirm": "Ikonka potvrzeného hesla" - }, - "links": { - "login": "Vrátit se k Přihlášení", - "resend": "Nedorazil vám e-mail? Klepněte pro opětovné zaslání" - }, - "toasts": { - "sent": "Instrukce vám dorazí na .", - "emailNotFound": "Nepodařilo se nalézt e-mailovou adresu.", - "redirect": "Přesměrování proběhne za ...", - "success": "Heslo bylo úspěšně obnoveno.", - "error": "Obnova hesla se nepodařila. Zkuste to prosím znovu nebo kontaktujte podporu." + "support": "Podpora", + "discussions": "Diskuze", + "docs": "Dokumentace", + "changelog": "Seznam změn" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "Podařilo se úspěšně obnovit spojení se serverem", - "stillUnreachable": "Server je stále nedosažitelný. Zkuste to prosím později." + "pages": { + "notFound": { + "title": "Ale ne! Upustili jste své sushi!", + "subtitle": "Buď tato URL neexistuje, nebo k ní nemáte přístup." + }, + "account": { + "tabs": { + "profile": "Profil", + "password": "Heslo", + "team": "Tým" }, - "alertBox": "Chyba připojení k serveru", - "description": "Nepodařilo se připojit k serveru. Zkontrolujte si prosím internetové připojení a pokud problém přetrvává, ověřte konfiguraci aplikace Checkmate.", - "retryButton": { - "default": "Zkusit se znovu připojit", - "processing": "Probíhá připojování…" + "form": { + "name": { + "title": "Jméno", + "description": "Aktualizujte své osobní údaje" + }, + "photo": { + "title": "Profilová fotografie", + "description": "Nahrajte profilový obrázek" + }, + "currentPassword": { + "title": "Aktuální heslo", + "description": "Zadejte aktuální heslo pro ověření totožnosti", + "option": { + "label": "Aktuální heslo", + "placeholder": "Zadejte aktuální heslo" + } + }, + "newPassword": { + "title": "Nové heslo", + "description": "Zvolte silné heslo s alespoň 8 znaky", + "option": { + "newPassword": { + "label": "Nové heslo", + "placeholder": "Zadejte nové heslo" + }, + "confirm": { + "label": "Potvrzení hesla", + "placeholder": "Potvrďte nové heslo" + } + } + }, + "deleteAccount": { + "title": "Smazat účet", + "description": "Tato akce je trvalá a nelze ji vrátit zpět" + } + }, + "team": { + "filter": { + "placeholder": "Filtrovat podle role", + "all": "Všechny role", + "admin": "Administrátor", + "member": "Člen" + }, + "table": { + "headers": { + "email": "Email", + "role": "Role", + "created": "Vytvořeno" + } + }, + "addMember": { + "title": "Registrace nového člena týmu", + "description": "Vytvořte nový uživatelský účet. Po vytvoření bezpečně sdílejte přihlašovací údaje." + }, + "invite": { + "title": "Pozvat člena týmu", + "description": "Odešlete pozvánku k připojení do vašeho týmu", + "email": { + "label": "Emailová adresa", + "placeholder": "Zadejte emailovou adresu" + }, + "role": { + "label": "Role", + "placeholder": "Vyberte roli" + }, + "linkLabel": "Odkaz pro pozvánku" + }, + "fallback": { + "title": "Zatím žádní členové týmu", + "description": "Pozvěte kolegy e-mailem nebo je přidejte přímo. Jakmile se připojí, objeví se zde se svou rolí." + } + }, + "header": { + "title": "Účet", + "description": "Spravujte profil, heslo, tým a předvolby pracovního prostoru." + } + }, + "auth": { + "brandPanel": { + "eyebrow": "Open-source monitoring infrastruktury", + "tagline": "Sledujte každý krok vašich serverů.", + "description": "Monitorování dostupnosti, doby odezvy a incidentů v reálném čase - self-hosted a vaše." + }, + "common": { + "passwordRules": { + "length": { + "beginning": "Heslo musí být dlouhé alespoň", + "highlighted": "8 znaků" + }, + "lowercase": { + "beginning": "Heslo musí obsahovat alespoň", + "highlighted": "1 malé písmeno" + }, + "match": { + "beginning": "Obě hesla se", + "highlighted": "musí shodovat" + }, + "number": { + "beginning": "Heslo musí obsahovat alespoň", + "highlighted": "1 číslo" + }, + "special": { + "beginning": "Heslo musí obsahovat alespoň", + "highlighted": "1 speciální znak" + }, + "uppercase": { + "beginning": "Heslo musí obsahovat alespoň", + "highlighted": "1 velké písmeno" + } + }, + "form": { + "option": { + "email": { + "label": "Email", + "placeholder": "me@example.com" + }, + "password": { + "label": "Heslo", + "placeholder": "********" + }, + "confirmPassword": { + "label": "Potvrzení hesla" + } + } + } + }, + "login": { + "title": "Vítejte zpět v Checkmate!", + "subtitle": "Přihlaste se pro pokračování", + "submit": "Přihlásit se", + "links": { + "forgotPassword": { + "text": "Zapomněli jste heslo?", + "linkText": "Obnovit heslo" + }, + "register": { + "text": "Nemáte účet?", + "linkText": "Zaregistrujte se zde" + } + } + }, + "register": { + "title": "Vítejte v Checkmate!", + "subtitle": "Zaregistrujte se a začněte", + "submit": "Registrovat", + "setupNotice": "Tato stránka je k dispozici pouze během počátečního nastavení. Po zaregistrování super administrátora lze pozvat další uživatele z nastavení týmu." + }, + "forgotPassword": { + "title": "Zapomněli jste heslo?", + "subtitle": "Žádný problém, zašleme vám pokyny k obnovení.", + "submit": "Požádat o obnovení", + "links": { + "login": { + "text": "Zpět na", + "linkText": "přihlášení" + } + } + }, + "setNewPassword": { + "title": "Obnovte své heslo", + "subtitle": "Vaše nové heslo musí být odlišné od dříve použitých hesel." + } + }, + "checks": { + "selects": { + "monitor": { + "all": "Všechny monitory" + }, + "status": { + "all": "Vše", + "down": "Nedostupné", + "up": "Dostupné" + } + }, + "table": { + "empty": "Žádné neúspěšné kontroly v tomto období", + "headers": { + "statusCode": "Stavový kód", + "location": "Umístění" + } + }, + "fallback": { + "title": "Zatím žádné kontroly", + "description": "Jakmile vytvoříte monitor, každá kontrola stavu a doba odezvy se zde zobrazí jako kontrola." + }, + "header": { + "title": "Kontroly", + "description": "Každá sonda, kterou Checkmate spustí proti vašim monitorům, se zde objeví se stavovým kódem a dobou odezvy." + } + }, + "common": { + "monitors": { + "actions": { + "configure": "Konfigurovat", + "delete": "Smazat", + "generateToken": "Vygenerovat token", + "details": "Podrobnosti", + "incidents": "Incidenty", + "inviteMember": "Pozvat člena", + "openSite": "Otevřít stránku", + "pause": "Pozastavit", + "resume": "Obnovit", + "selectAll": "Vybrat všechny monitory", + "selectMonitor": "Vybrat {{name}}", + "bulkSelected_one": "Vybrán {{count}} monitor", + "bulkSelected_other": "Vybráno {{count}} monitorů" + }, + "statBoxes": { + "activeFor": "Aktivní po dobu", + "certificateExpiry": "Expirace certifikátu", + "lastCheck": "Poslední kontrola", + "lastResponseTime": "Poslední doba odezvy", + "serviceIsDown": "Služba je nedostupná" + }, + "status": { + "down": "nedostupný", + "breached": "překročen práh", + "initializing": "inicializace", + "maintenance": "údržba", + "paused": "pozastaveno", + "total": "celkem", + "up": "dostupný" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "Hra", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "Port", + "optionPagespeed": "PageSpeed", + "optionHardware": "Infrastruktura", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} monitor pozastaven", + "paused_other": "{{count}} monitorů pozastaveno", + "resumed_one": "{{count}} monitor obnoven", + "resumed_other": "{{count}} monitorů obnoveno", + "alreadyPaused_one": "Vybraný monitor je již pozastaven", + "alreadyPaused_other": "Všechny vybrané monitory jsou již pozastaveny", + "alreadyRunning_one": "Vybraný monitor již běží", + "alreadyRunning_other": "Všechny vybrané monitory již běží" + } + } + }, + "createMonitor": { + "form": { + "advanced": { + "description": "Volitelná nastavení pro pokročilé použití", + "option": { + "advancedMatching": { + "label": "Použít pokročilé porovnávání" + }, + "expectedValue": { + "label": "Očekávaná hodnota" + }, + "jsonPath": { + "description": "Tento výraz bude vyhodnocen proti odpovědi ve formátu JSON a výsledek bude použit k porovnání s očekávanou hodnotou. Dokumentaci dotazovacího jazyka naleznete na jmespath.org.", + "label": "Výraz JSONPath" + }, + "matchMethod": { + "label": "Metoda porovnání", + "equal": "", + "include": "", + "regex": "" + } + }, + "title": "Pokročilá nastavení" + }, + "frequency": { + "description": "Jak často chcete kontrolovat stav tohoto monitoru?", + "option": { + "frequency": { + "label": "Frekvence kontroly", + "value": { + "fifteenMinutes": "15 minut", + "fifteenSeconds": "", + "fiveMinutes": "5 minut", + "fourMinutes": "", + "oneMinute": "", + "tenMinutes": "10 minut", + "thirtyMinutes": "30 minut", + "thirtySeconds": "", + "threeMinutes": "", + "twoMinutes": "" + } + } + }, + "title": "Frekvence kontroly" + }, + "general": { + "option": { + "container": { + "label": "Název/ID kontejneru", + "placeholder": "my-app nebo abcd1234" + }, + "host": { + "label": "Hostitel", + "placeholder": "192.168.1.100 nebo example.com" + }, + "name": { + "label": "Zobrazovaný název", + "placeholder": "např. Moje stránka" + }, + "secret": { + "label": "Autorizační tajný klíč", + "placeholder": "Zadejte svůj tajný klíč" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "", + "placeholder": "" + }, + "port": { + "label": "Port ke sledování", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "Název služby", + "placeholder": "např. my.service.v1 (nechte prázdné pro celkový stav)" + }, + "wsUrl": { + "label": "WebSocket URL", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Endpoint Capture", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "Obecná nastavení", + "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": "", + "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. URL endpointu Capture musí obsahovat celou cestu /api/v1/metrics (např. http://192.168.1.10:5001/api/v1/metrics). Do autorizačního klíče vložte pouze řetězec tokenu — nezahrnujte 'Bearer'." + } + }, + "ignoreTls": { + "description": "Nastavte ověřování TLS/SSL certifikátů pro HTTPS připojení.", + "option": { + "tls": { + "label": "Ignorovat chyby TLS/SSL" + } + }, + "title": "Nastavení TLS/SSL" + }, + "incidents": { + "description": "Klouzavé okno se používá k určení, kdy je monitor nedostupný. Stav monitoru se změní pouze tehdy, když procento kontrol v klouzavém okně dosáhne zadané hodnoty.", + "option": { + "checks": { + "label": "Počet kontrol v klouzavém okně" + }, + "percentage": { + "label": "Jaké procento kontrol v klouzavém okně musí selhat/uspět, než se změní stav monitoru?" + } + }, + "title": "Incidenty" + }, + "notifications": { + "description": "Vyberte kanály pro oznámení, které chcete použít", + "title": "Oznámení" + }, + "type": { + "description": "Vyberte typ kontroly, která se má provádět", + "optionDockerDescription": "Použijte Docker ke sledování, zda je kontejner spuštěn.", + "optionGameDescription": "Sledujte, zda je konkrétní herní server online.", + "optionGrpcDescription": "Sledujte gRPC služby pomocí standardního protokolu pro kontrolu stavu.", + "optionWebSocketDescription": "Sledujte WebSocket koncové body pro stav připojení a dobu odezvy.", + "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" + }, + "thresholds": { + "title": "Prahové hodnoty upozornění", + "description": "Definujte prahové hodnoty, při kterých se mají spouštět upozornění pro tento monitor infrastruktury.", + "option": { + "cpuThreshold": { + "label": "Prahová hodnota upozornění CPU (%)" + }, + "memoryThreshold": { + "label": "Prahová hodnota upozornění paměti (%)" + }, + "diskThreshold": { + "label": "Prahová hodnota upozornění disku (%)" + }, + "tempThreshold": { + "label": "Prahová hodnota upozornění teploty (°C)" + } + } + }, + "geoChecks": { + "title": "Geograficky distribuované kontroly", + "description": "Spouštějte kontroly z více geografických lokací pro sledování globální dostupnosti a výkonu.", + "option": { + "enabled": { + "label": "Povolit geograficky distribuované kontroly" + }, + "locations": { + "label": "Lokace", + "placeholder": "Vyberte lokace", + "options": { + "EU": "Evropa", + "NA": "Severní Amerika", + "AS": "Asie", + "SA": "Jižní Amerika", + "AF": "Afrika", + "OC": "Oceánie" + } + }, + "interval": { + "label": "Interval kontroly", + "value": { + "fiveMinutes": "5 minut", + "tenMinutes": "10 minut", + "fifteenMinutes": "15 minut", + "thirtyMinutes": "30 minut" + } + } + } + }, + "url": { + "title": "IP/URL monitoru na stavové stránce", + "description": "Zobrazí IP adresu nebo URL monitoru na veřejné stavové stránce. Pokud je tato možnost zakázána, zobrazí se pouze název monitoru pro ochranu citlivých informací.", + "option": { + "showURL": { + "label": "Zobrazit IP/URL na stavové stránce", + "enabled": "Povoleno", + "disabled": "Zakázáno" + } + } + }, + "stats": { + "title": "Historie monitoru", + "description": "Vymazat veškerou historii sledování a statistiky vašeho týmu. Tato akce je nevratná.", + "buttonText": "Vymazat všechny statistiky", + "dialog": { + "title": "Vymazat veškerou historii sledování?", + "description": "Toto trvale smaže veškerou historii sledování, statistiky a data kontrol vašeho týmu. Tuto akci nelze vrátit zpět.", + "confirm": "Ano, vymazat všechny statistiky" + } + } + } + }, + "editUser": { + "form": { + "roles": { + "title": "Role", + "description": "Přiřaďte role uživateli. Lze vybrat více rolí." + } + }, + "dialog": { + "removeUser": { + "title": "Odebrat uživatele", + "content": "Opravdu chcete odebrat uživatele {{name}} z vašeho týmu? Tuto akci nelze vrátit zpět." + } + } + }, + "incidents": { + "fallback": { + "title": "Žádné incidenty — všechny systémy v pořádku", + "description": "Když monitor vypadne nebo se zotaví, incident se zobrazí zde spolu s časem, detaily monitoru a historií řešení." + }, + "dialog": { + "resolveIncident": { + "title": "Vyřešit incident", + "option": { + "comment": { + "label": "Komentář (volitelné)", + "placeholder": "Přidejte komentář k vyřešení..." + } + } + }, + "details": { + "analysis": "Analýza incidentu", + "comment": "Komentář:", + "detailsLabel": "Podrobnosti", + "downtime": "Výpadek:", + "message": "Zpráva:", + "monitor": "Monitor:", + "overview": "Přehled", + "resolutionDetails": "Podrobnosti řešení", + "resolutionType": "Typ:", + "resolutionTypes": { + "automatic": "Automatický", + "manual": "Manuální" + }, + "resolve": "Vyřešit incident", + "resolvedAt": "Vyřešeno v:", + "resolvedBy": "Vyřešil:", + "startedAt": "Začátek:", + "status": "Stav:", + "statusCode": "Stavový kód:", + "timeline": "Časová osa", + "title": "Podrobnosti incidentu", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "Všechny monitory", + "monitor": "Monitor", + "resolutionType": "Typ řešení", + "resolutionTypes": { + "manual": "Manuální", + "automatic": "Automatický", + "all": "Vše" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "Aktivní incidenty", + "active_zero": "Žádné aktivní incidenty", + "active_one": "{{count}} aktivní incident", + "active_other": "{{count}} aktivních incidentů" + }, + "incidentStats": { + "avgResolutionTime": "Průměrná doba řešení", + "mostAffectedMonitor": "Nejvíce postižený monitor", + "title": "Statistiky incidentů", + "totalIncidents": "Celkem incidentů" + }, + "latestIncidents": { + "title": "Nejnovější incidenty" + } + }, + "table": { + "actions": { + "details": "Podrobnosti", + "goToMonitor": "Přejít na monitor", + "resolveManually": "Vyřešit ručně" + }, + "activeIncidents": "Aktivní incidenty", + "headers": { + "endTime": "Čas ukončení", + "resolutionType": "Typ řešení", + "startTime": "", + "statusCode": "Stavový kód" + }, + "resolvedIncidents": "Vyřešené incidenty", + "status": { + "active": "Aktivní", + "resolved": "Vyřešeno" + } + }, + "header": { + "title": "Incidenty", + "description": "Když monitor vypadne nebo se zotaví, incidenty se zobrazí zde spolu s časem, detaily monitoru a historií řešení." + } + }, + "infrastructure": { + "charts": { + "labels": { + "cpu": "Využití CPU", + "disk": "Využití disku", + "memory": "Využití paměti", + "netBytesRecv": "{{name}} - přijaté bajty", + "netBytesSent": "{{name}} - odeslané bajty", + "netNoTraffic": "Capture hlásí nulový provoz na všech rozhraních. To je normální, když Capture běží uvnitř kontejneru, který vidí pouze loopback rozhraní — spusťte Capture přímo na hostiteli, abyste viděli skutečnou síťovou aktivitu.", + "temp": "Teplota" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "Maximální frekvence", + "title": "Využití CPU", + "upperLabel": "Aktuální frekvence" + }, + "disk": { + "lowerLabel": "Volné", + "title": "Využití disku {{idx}}", + "upperLabel": "Použité" + }, + "memory": { + "lowerLabel": "Volné", + "title": "Využití paměti", + "upperLabel": "Použité" + } + }, + "statBoxes": { + "avgCpuTemperature": "", + "cpuFrequency": "", + "cpuLogical": "", + "cpuPhysical": "", + "disk": "Disk", + "memory": "Paměť", + "os": "" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "Disk", + "memory": "Paměť" + } + }, + "tabs": { + "labels": { + "network": "", + "overview": "Přehled" + } + }, + "fallback": { + "actionButton": "Vytvořte monitor!", + "title": "Zatím žádné monitory infrastruktury", + "description": "Sledujte výkon serverů, identifikujte úzká místa a zajistěte spolehlivost pomocí monitoringu infrastruktury v reálném čase." + }, + "header": { + "title": "Monitory infrastruktury", + "description": "Sledujte výkon serverů, identifikujte úzká místa a zajistěte spolehlivost pomocí monitoringu infrastruktury v reálném čase." + } + }, + "logs": { + "fallback": { + "title": "Zatím žádné protokoly", + "description": "Aktivita serveru, varování a chyby se zobrazí zde, jak Checkmate běží." + }, + "tabs": { + "diagnostics": "Diagnostika", + "logs": "Záznamy", + "queue": "Fronta úloh" + }, + "logLevelSelect": { + "label": "Úroveň logování" + }, + "jobQueue": "Fronta úloh", + "jobQueueExplainer": "Zvýrazněné řádky jsou úlohy, které právě zpracovává worker.", + "failedJobs": "Neúspěšné úlohy", + "failedJobsExplainer": "Nedávná selhání podle monitoru — čas posledního selhání, počet selhání a důvod.", + "noLogs": "Žádné logy nebyly nalezeny", + "metrics": { + "jobs": "Úlohy", + "activeJobs": "Aktivní úlohy", + "failingJobs": "Selhávající úlohy", + "totalRuns": "Celkem spuštění", + "totalFailures": "Celkem selhání" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "Zpoždění smyčky událostí", + "uptime": "Doba provozu", + "usedHeapSize": "Použitá velikost haldy", + "totalHeapSize": "Celková velikost haldy", + "osMemoryLimit": "Limit paměti OS" + }, + "gauges": { + "heapAllocation": "Alokace haldy", + "heapUsage": "Využití haldy", + "heapUtilization": "Utilizace haldy", + "instantCpuUsage": "Okamžité využití CPU", + "availableMemoryPercentage": "% dostupné paměti", + "allocatedPercentage": "% alokováno", + "usedSPercentage": "% z 1s využito CPU", + "total": "Celkem", + "used": "Použité" + }, + "sections": { + "runtimeStats": { + "title": "Statistiky běhu", + "description": "Snímek procesu Node.js a hostitelského počítače. Aktualizuje se každých 5 sekund." + }, + "memoryCpu": { + "title": "Paměť a CPU", + "description": "Jak proces využívá přidělenou paměť a CPU hostitele." + } + } + }, + "table": { + "headers": { + "timestamp": "Časové razítko", + "level": "Úroveň", + "service": "Služba", + "method": "Metoda", + "monitorId": "ID monitoru", + "runCount": "Počet spuštění", + "failCount": "Počet selhání", + "lastRunAt": "Poslední spuštění", + "lockedAt": "Uzamčeno v", + "lastFinishedAt": "Naposledy dokončeno", + "lastRunTook": "Poslední běh trval", + "lastFailedAt": "Poslední selhání", + "failReason": "Důvod selhání" + } + }, + "queueFallback": { + "title": "Žádné úlohy ve frontě", + "description": "Naplánované nebo běžící kontroly monitorů se objeví zde. Prázdná fronta znamená, že je vše aktuální." + }, + "header": { + "title": "Protokoly a fronta", + "description": "Zkontrolujte serverové protokoly, frontu úloh monitoru a diagnostiku v jednom místě." + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "Vytvořte okno údržby!", + "title": "Žádná naplánovaná okna údržby", + "description": "Naplánujte plánované odstávky, aby byly výstrahy potlačeny a váš tým i uživatelé věděli, co očekávat." + }, + "table": { + "headers": { + "nextWindow": "", + "repeat": "" + } + }, + "form": { + "general": { + "title": "Obecná nastavení", + "description": "Nastavte název a možnost opakování pro okno údržby.", + "option": { + "name": { + "label": "Název", + "placeholder": "např. Týdenní údržba" + }, + "repeat": { + "label": "Opakování" + } + } + }, + "startDate": { + "title": "Datum zahájení", + "description": "Vyberte datum zahájení okna údržby.", + "option": { + "startDate": { + "label": "Datum zahájení" + } + } + }, + "startTime": { + "title": "Čas zahájení", + "description": "Nastavte čas zahájení a dobu trvání okna údržby. Všechny hodnoty jsou v UTC", + "option": { + "duration": { + "label": "Doba trvání" + }, + "startTime": { + "label": "Čas zahájení" + } + }, + "monitors": { + "title": "Monitory", + "description": "Monitory, na které se má okno údržby vztahovat", + "option": { + "addMonitors": { + "label": "Přidat monitory" + } + } + } + } + }, + "header": { + "title": "Okna údržby", + "description": "Naplánujte plánované odstávky, aby byly výstrahy potlačeny a váš tým i uživatelé věděli, co očekávat." + } + }, + "notifications": { + "fallback": { + "actionButton": "Vytvořit kanál", + "title": "Zatím žádné notifikační kanály", + "description": "Připojte Slack, Discord, e-mail, webhooky a další, aby se ti správní lidé dozvěděli o incidentech v okamžiku, kdy nastanou." + }, + "form": { + "accessToken": { + "optionAccessToken": "Přístupový token", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "Adresa, na kterou budou zasílána oznámení.", + "optionAddress": "Adresa", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "Adresa" + }, + "homeServer": { + "optionHomeServer": "Domovský server", + "placeholder": "example.com" + }, + "matrix": { + "description": "Nastavte připojení k domovskému serveru Matrix pro oznámení.", + "title": "Konfigurace Matrix" + }, + "notificationName": { + "description": "Popisný název kanálu oznámení", + "optionName": "Název kanálu", + "placeholder": "např. Produkční upozornění", + "title": "Název kanálu" + }, + "pagerDuty": { + "description": "Váš integrační klíč PagerDuty pro příjem upozornění.", + "optionIntegrationKey": "Integrační klíč", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "Integrační klíč" + }, + "roomId": { + "optionRoomId": "ID místnosti", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "Vyberte typ kanálu oznámení, který chcete vytvořit.", + "optionType": "Typ", + "title": "Typ kanálu" + }, + "telegram": { + "title": "Konfigurace Telegramu", + "description": "", + "optionBotToken": "Token bota", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "ID chatu", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Konfigurace Pushover", + "description": "Zadejte token aplikace a uživatelský klíč Pushover.", + "optionAppToken": "Token aplikace", + "placeholderAppToken": "Zadejte token aplikace", + "optionUserKey": "Uživatelský klíč", + "placeholderUserKey": "Zadejte uživatelský klíč" + }, + "twilio": { + "title": "Konfigurace Twilio", + "description": "Zadejte přihlašovací údaje účtu Twilio pro odesílání SMS.", + "optionAccountSid": "SID účtu", + "placeholderAccountSid": "Zadejte SID účtu", + "optionAuthToken": "Autentizační token", + "placeholderAuthToken": "Zadejte autentizační token", + "optionFromNumber": "Z čísla", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "Na číslo", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "Cíl" + } + }, + "header": { + "title": "Notifikace", + "description": "Připojte Slack, Discord, e-mail, webhooky a další, aby se ti správní lidé dozvěděli o incidentech v okamžiku, kdy nastanou." + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "Kumulativní posun rozvržení (CLS)", + "fcp": "První vykreslení obsahu (FCP)", + "lcp": "Největší vykreslení obsahu (LCP)", + "si": "Index rychlosti (SI)", + "tbt": "Celková doba blokování (TBT)" + }, + "legend": { + "title": "Zpráva PageSpeed", + "weight": "Váha" + }, + "pie": { + "title": "Zpráva o výkonu" + } + }, + "table": { + "headers": { + "pageSpeedScore": "Hodnocení PageSpeed" + } + }, + "fallback": { + "actionButton": "Vytvořte monitor!", + "title": "Zatím žádné PageSpeed monitory", + "description": "Sledujte Core Web Vitals a skóre Lighthouse v čase a zachyťte regrese dříve než vaši uživatelé." + }, + "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é." + } + }, + "settings": { + "form": { + "timezone": { + "title": "Zobrazované časové pásmo", + "description": "Vyberte časové pásmo používané pro zobrazení dat a časů v celé aplikaci.", + "option": { + "timezone": { + "label": "Zobrazované časové pásmo" + } + } + }, + "ui": { + "title": "Vzhled", + "description": "Přepínejte mezi světlým a tmavým režimem, měňte jazyk nebo upravte typ zobrazení grafů.", + "option": { + "theme": { + "label": "Režim motivu", + "light": "Světlý", + "dark": "Tmavý" + }, + "language": { + "label": "Jazyk" + }, + "chartType": { + "label": "Typ grafu", + "histogram": "Histogram", + "heatmap": "Heatmapa" + } + } + }, + "pagespeed": { + "title": "Klíč Google PageSpeed API", + "description": "Zadejte klíč Google PageSpeed API pro aktivaci sledování Google PageSpeed. Klikněte na Obnovit pro aktualizaci klíče.", + "option": { + "apiKey": { + "label": "Klíč PageSpeed API", + "labelSet": "Klíč API je nastaven. Klikněte na Obnovit pro jeho změnu.", + "placeholder": "Zadejte klíč Google PageSpeed API" + } + } + }, + "url": { + "title": "IP/URL monitoru na stavové stránce", + "description": "Zobrazí IP adresu nebo URL monitoru na veřejné stavové stránce. Pokud je tato možnost zakázána, zobrazí se pouze název monitoru pro ochranu citlivých informací.", + "option": { + "showURL": { + "label": "Zobrazit IP/URL na stavové stránce", + "enabled": "Povoleno", + "disabled": "Zakázáno" + } + } + }, + "stats": { + "title": "Historie monitoru", + "description": "Vymazat veškerou historii sledování a statistiky vašeho týmu. Tato akce je nevratná.", + "option": { + "clear": { + "label": "Vymazat všechny statistiky. Tato akce je nevratná." + } + }, + "dialog": { + "title": "Vymazat veškerou historii sledování?", + "description": "Tuto akci nelze vrátit zpět" + } + }, + "retention": { + "title": "Uchovávání kontrol", + "description": "Nastavte, jak dlouho se mají uchovávat data kontrol před automatickým vymazáním.", + "option": { + "days": { + "label": "Doba uchovávání (dny)", + "unlimited": "Neomezeně" + } + } + }, + "thresholds": { + "title": "Globální prahové hodnoty", + "description": "Nastavte globální prahové hodnoty CPU, paměti, disku a teploty pro sledování infrastruktury. Platí pro všechny monitory infrastruktury, pokud nejsou přepsány.", + "option": { + "cpu": { + "label": "Prahová hodnota CPU (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "Prahová hodnota paměti (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "Prahová hodnota disku (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "Prahová hodnota teploty (°C)", + "placeholder": "1 - 150" + } + } + }, + "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": "", + "descriptionTransportLink": "Zobrazit specifikace zde", + "option": { + "host": { + "label": "Emailový hostitel - název hostitele nebo IP adresa pro připojení", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "Emailový port - port pro připojení", + "placeholder": "587" + }, + "address": { + "label": "Emailová adresa - použitá pro ověření", + "placeholder": "you@example.com" + }, + "user": { + "label": "Emailový uživatel - uživatelské jméno pro ověření, přepíše emailovou adresu, pokud je zadáno", + "placeholder": "Nechte prázdné, pokud není vyžadováno" + }, + "password": { + "label": "Emailové heslo - heslo pro ověření", + "labelSet": "Heslo je nastaveno. Klikněte na Obnovit pro jeho změnu.", + "placeholder": "Zadejte heslo" + }, + "tlsServername": { + "label": "Název TLS serveru - volitelný název hostitele pro TLS validaci, když je hostitel IP", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "Hostitel emailového připojení - název hostitele pro HELO/EHLO pozdrav", + "placeholder": "localhost" + }, + "secure": { + "label": "Použít SSL (doporučeno): Šifrovat připojení pomocí SSL/TLS" + }, + "pool": { + "label": "Povolit sdílení připojení: Opakované použití stávajících připojení pro vyšší výkon" + }, + "ignoreTLS": { + "label": "Zakázat STARTTLS: Nepoužívat TLS, i když to server podporuje" + }, + "requireTLS": { + "label": "Vynutit STARTTLS: Vyžadovat upgrade TLS, selhat pokud není podporován" + }, + "rejectUnauthorized": { + "label": "Odmítnout neplatné certifikáty: Odmítnout připojení se sebou podepsanými nebo nedůvěryhodnými certifikáty" + } + } + }, + "demoMonitors": { + "title": "Ukázkové monitory", + "description": "Přidejte ukázkové monitory pro demonstrační účely." + }, + "removeMonitors": { + "title": "Reset systému", + "description": "Odstranit všechny monitory ze systému.", + "dialog": { + "title": "Odebrat všechny monitory?", + "description": "Tuto akci nelze vrátit zpět.", + "paragraph": "Smaže se také veškerá historie kontrol, incidenty, naplánovaná údržbová okna a stavové stránky, které tyto monitory používaly. Pokud si chcete data zachovat, nejprve monitory exportujte." + } + }, + "exportMonitors": { + "title": "Exportovat monitory" + }, + "importExportMonitors": { + "title": "Import / Export monitorů", + "description": "Importujte nebo exportujte data monitorů jako JSON soubor pro zálohu nebo přenos." + }, + "about": { + "title": "O aplikaci", + "developedBy": "Vyvinulo Bluewave Labs" + }, + "validation": { + "errorMessage": "Opravte prosím následující chyby validace:" + } + }, + "header": { + "title": "Nastavení", + "description": "Nakonfigurujte SMTP, integrace, uchovávání a globální chování aplikace." + } + }, + "statusPages": { + "deleteSuccess": "Stavová stránka byla úspěšně smazána", + "fallback": { + "title": "Zatím žádné stavové stránky", + "actionButton": "Vytvořte stavovou stránku!", + "description": "Publikujte veřejnou stránku, která zákazníkům a zúčastněným stranám zobrazuje dostupnost v reálném čase a historii incidentů." + }, + "monitorsList": { + "chartTypeHeatmap": "Heatmapa", + "chartTypeHistogram": "Histogram", + "noData": "Žádná data nejsou k dispozici", + "infrastructure": { + "title": "Infrastruktura", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "Paměť", + "disk": "Disk", + "usage": "Využití", + "used": "Použité", + "total": "Celkem" + }, + "uptime": { + "title": "Dostupnost", + "responseTime": "Doba odezvy" + } + }, + "statusBar": { + "allDown": "Všechny systémy jsou nedostupné", + "allUp": "Všechny systémy fungují", + "degraded": "Některé systémy mají problémy", + "unknown": "Nelze určit stav systému", + "allBreached": "Všechny systémy překročily prahové hodnoty", + "allMaintenance": "Všechny systémy jsou v údržbě", + "allPaused": "Všechny systémy jsou pozastavené", + "allInitializing": "Všechny systémy se inicializují", + "breachedAndDown": "Některé systémy překročily prahové hodnoty a jsou nedostupné", + "breached": "Některé systémy překročily prahové hodnoty", + "maintenanceAndDown": "Některé systémy jsou nedostupné během údržby", + "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" + }, + "table": { + "headers": { + "name": "Název stavové stránky", + "url": "Veřejná URL" + }, + "published": "Publikováno", + "unpublished": "Nepublikováno" + }, + "title": "Stavové stránky", + "details": { + "empty": { + "title": "Zatím zde nic není", + "addMonitor": "Přidejte monitor pro začátek" + } + }, + "form": { + "access": { + "title": "Přístup", + "description": "Pokud je vaše stavová stránka připravena, můžete ji označit jako publikovanou.", + "option": { + "published": { + "name": "Publikováno a viditelné pro veřejnost" + } + } + }, + "basicInfo": { + "title": "Základní informace", + "description": "Zadejte název společnosti a subdoménu, na kterou stavová stránka odkazuje.", + "option": { + "name": { + "label": "Název společnosti", + "placeholder": "Acme s.r.o." + }, + "url": { + "label": "Adresa vaší stavové stránky", + "placeholder": "moje-stavova-stranka" + } + } + }, + "monitors": { + "title": "Monitory", + "description": "Vyberte monitory, které se mají zobrazit na vaší stavové stránce.", + "noMonitors": "Žádné monitory nebyly vybrány", + "option": { + "monitors": { + "label": "Vybrat monitory", + "placeholder": "Hledejte a vyberte monitory..." + } + } + }, + "timezone": { + "title": "Časové pásmo", + "description": "Vyberte časové pásmo, ve kterém se bude zobrazovat stavová stránka.", + "option": { + "timezone": { + "label": "Časové pásmo", + "placeholder": "Vyberte časové pásmo..." + } + } + }, + "appearance": { + "title": "Vzhled", + "description": "Definujte výchozí vzhled vaší veřejné stavové stránky.", + "option": { + "logo": { + "label": "Logo" + }, + "color": { + "label": "Barva značky" + } + } + }, + "features": { + "title": "Funkce", + "description": "Nastavte, jaké informace se zobrazí na stavové stránce.", + "option": { + "showCharts": { + "label": "Zobrazit grafy doby odezvy" + }, + "showUptimePercentage": { + "label": "Zobrazit procento dostupnosti" + }, + "showAdminLoginLink": { + "label": "Zobrazit odkaz pro přihlášení administrátora" + }, + "showInfrastructure": { + "label": "Zobrazit metriky infrastruktury" + } + } + } + }, + "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ů." + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "Hledat monitory..." + } + }, + "table": { + "headers": { + "responseTime": "Doba odezvy" + } + }, + "fallback": { + "actionButton": "Vytvořte monitor!", + "title": "Zatím žádné monitory dostupnosti", + "description": "Sledujte HTTP koncové body, pingy, kontejnery a porty — buďte upozorněni v okamžiku, kdy něco vypadne." + }, + "header": { + "title": "Monitory dostupnosti", + "description": "Sledujte HTTP koncové body, pingy, kontejnery a porty — buďte upozorněni v okamžiku, kdy něco vypadne." } } - }, - "createNotifications": { - "title": "", - "nameSettings": { - "title": "", - "description": "", - "nameLabel": "", - "namePlaceholder": "" - }, - "typeSettings": { - "title": "", - "description": "", - "typeLabel": "" - }, - "emailSettings": { - "title": "", - "description": "", - "emailLabel": "", - "emailPlaceholder": "" - }, - "slackSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "pagerdutySettings": { - "title": "", - "description": "", - "integrationKeyLabel": "", - "integrationKeyPlaceholder": "" - }, - "discordSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "webhookSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "testNotification": "", - "dialogDeleteTitle": "", - "dialogDeleteConfirm": "" - }, - "notificationConfig": { - "title": "", - "description": "" - }, - "monitorStatus": { - "checkingEvery": "", - "withCaptureAgent": "", - "up": "", - "down": "", - "paused": "" - }, - "advancedMatching": "", - "sendTestNotifications": "", - "selectAll": "", - "showAdminLoginLink": "", - "logsPage": { - "title": "", - "description": "", - "tabs": { - "queue": "", - "logs": "", - "diagnostics": "" - }, - "toast": { - "fetchLogsSuccess": "" - }, - "logLevelSelect": { - "title": "", - "values": { - "all": "", - "info": "", - "warn": "", - "error": "", - "debug": "" - } - } - }, - "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", - "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "", - "activeHeader": "", - "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "", - "lastRunTookHeader": "", - "intervalHeader": "" - }, - "metricsTable": { - "title": "", - "metricHeader": "", - "valueHeader": "" - }, - "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", - "failedAtHeader": "", - "failReasonHeader": "" - } - }, - "export": { - "title": "", - "success": "", - "failed": "" - }, - "monitorActions": { - "title": "", - "import": "", - "export": "", - "deleteSuccess": "", - "deleteFailed": "", - "details": "" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "" - }, - "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" - }, - "emailSettings": { - "buttonSendTestEmail": "", - "description": "", - "descriptionTransport": "", - "labelAddress": "", - "labelConnectionHost": "", - "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", - "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", - "labelRequireTLS": "", - "labelSecure": "", - "labelTLSServername": "", - "labelUser": "", - "linkTransport": "", - "placeholderUser": "", - "title": "", - "toastEmailRequiredFieldsError": "" - }, - "pageSpeedSettings": { - "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "" - }, - "saveButtonLabel": "", - "statsSettings": { - "clearAllStatsButton": "", - "clearAllStatsDescription": "", - "clearAllStatsDialogConfirm": "", - "clearAllStatsDialogDescription": "", - "clearAllStatsDialogTitle": "", - "description": "", - "labelTTL": "", - "labelTTLOptional": "", - "title": "" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", - "dialogDescription": "", - "dialogTitle": "", - "title": "" - }, - "timezoneSettings": { - "description": "", - "label": "", - "title": "" - }, - "title": "", - "uiSettings": { - "description": "", - "labelLanguage": "", - "labelTheme": "", - "title": "" - }, - "urlSettings": { - "description": "", - "label": "", - "selectDisabled": "", - "selectEnabled": "", - "title": "" - }, - "globalThresholds": { - "title": "", - "description": "" - } - }, - "statusPageCreate": { - "buttonSave": "" - }, - "incidentsOptionsHeaderFilterResolved": "", - "settingsSave": "", - "statusPageCreateAppearanceTitle": "", - "confirmPassword": "", - "monitorHooks": { - "failureAddDemoMonitors": "", - "successAddDemoMonitors": "" - }, - "settingsAppearance": "", - "settingsDisplayTimezone": "", - "settingsGeneralSettings": "", - "incidentsOptionsHeaderTotalIncidents": "", - "statusPage": { - "deleteSuccess": "", - "deleteFailed": "", - "createSuccess": "", - "updateSuccess": "", - "generalSettings": "", - "contents": "", - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "testNotificationsDisabled": "", - "incidentsTableResolvedAt": "", - "incidentsTableActionResolve": "", - "checkHooks": { - "failureResolveOne": "", - "failureResolveAll": "", - "failureResolveMonitor": "" - }, - "checkFormError": "", - "diagnosticsPage": { - "diagnosticDescription": "", - "statsDescription": "", - "gauges": { - "heapAllocationTitle": "", - "heapAllocationSubtitle": "", - "heapUsageTitle": "", - "heapUsageSubtitle": "", - "heapUtilizationTitle": "", - "heapUtilizationSubtitle": "", - "instantCpuUsageTitle": "", - "instantCpuUsageSubtitle": "" - }, - "stats": { - "eventLoopDelayTitle": "", - "uptimeTitle": "", - "usedHeapSizeTitle": "", - "totalHeapSizeTitle": "", - "osMemoryLimitTitle": "" - } - }, - "pageSpeedLighthouseAPI": "", - "time": { - "threeMinutes": "", - "fiveMinutes": "", - "tenMinutes": "", - "twentyMinutes": "", - "oneHour": "", - "oneDay": "", - "oneWeek": "", - "fourMinutes": "", - "oneMinute": "", - "twoMinutes": "", - "fifteenSeconds": "", - "thirtySeconds": "" - }, - "general": { - "noOptionsFound": "" - }, - "infrastructureMonitor": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "pageSpeed": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "editUserPage": { - "form": { - "email": "", - "firstName": "", - "lastName": "", - "role": "", - "save": "" - }, - "table": { - "actionHeader": "", - "roleHeader": "" - }, - "title": "", - "toast": { - "successUserUpdate": "", - "validationErrors": "" - } - }, - "incidentsPageActionResolveMonitor": "", - "incidentsPageActionResolveAll": "", - "matchMethodOptions": { - "equal": "", - "equalPlaceholder": "", - "include": "", - "includePlaceholder": "", - "regex": "", - "regexPlaceholder": "", - "text": "" - }, - "monitorType": { - "docker": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "http": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "ping": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "port": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "game": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "" - }, - "bytesPerSecond": "", - "bytesReceived": "", - "bytesSent": "", - "chooseGame": "", - "createMonitorPage": { - "incidentConfigDescription": "", - "incidentConfigStatusWindowLabel": "", - "incidentConfigStatusWindowThresholdLabel": "", - "incidentConfigTitle": "", - "incidentConfigDescriptionV2": "", - "incidentConfigStatusCheckNumber": "", - "intervalTitle": "", - "intervalDescription": "" - }, - "dataRate": "", - "dataReceived": "", - "dataSent": "", - "details": "", - "drops": "", - "errors": "", - "errorsIn": "", - "errorsOut": "", - "gameServerMonitoring": "", - "gameServerMonitoringDescription": "", - "network": "", - "networkDrops": "", - "networkErrors": "", - "networkInterface": "", - "noNetworkStatsAvailable": "", - "packetsPerSecond": "", - "packetsReceived": "", - "packetsReceivedRate": "", - "packetsSent": "", - "rate": "", - "selectInterface": "" + } } diff --git a/client/src/locales/de.json b/client/src/locales/de.json index fd3918378..0d0808135 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -1,1140 +1,1447 @@ { - "submit": "Absenden", - "title": "Titel", - "distributedStatusHeaderText": "Echtzeit Abdeckung", - "distributedStatusSubHeaderText": "Genutzt von Millionen von Geräten weltweit, siehst Du die Systemleistung nach globaler Region, Land oder Stadt", - "settingsDisabled": "Inaktiv", - "settingsSuccessSaved": "Einstellungen erfolgreich gespeichert", - "settingsFailedToSave": "Fehler beim Speichern der Einstellungen", - "settingsStatsCleared": "Statistiken gelöscht", - "settingsFailedToClearStats": "Fehler beim löschen der Statistiken", - "settingsMonitorsDeleted": "Alle Monitore erfolgreich gelöscht.", - "settingsFailedToDeleteMonitors": "Fehler beim Löschen aller Monitore", - "starPromptTitle": "Stern vergeben", - "starPromptDescription": "Sieh dir die neuesten Veröffentlichungen an und hilf der Community auf GitHub", - "https": "HTTPS", - "http": "HTTP", - "monitor": "monitor", - "aboutus": "Über uns", - "now": "Jetzt", - "delete": "Löschen", - "configure": "Einstellungen", - "responseTime": "Antwortzeit", - "ms": "ms", - "bar": "Bar", - "area": "Bereich", - "country": "Land", - "city": "Stadt", - "response": "Antwort", - "monitorStatusUp": "Monitor {name} ({url}) ist jetzt ONLINE und antwortet", - "monitorStatusDown": "Monitor {name} ({url}) ist jetzt OFFLINE und antwortet nicht", - "webhookSendSuccess": "Webhook-Benachrichtigung erfolgreich gesendet", - "webhookSendError": "Fehler beim Senden der Webhook-Benachrichtigung an {platform}", - "webhookUnsupportedPlatform": "Nicht unterstützte Plattform: {platform}", - "distributedRightCategoryTitle": "Monitor", - "distributedStatusServerMonitors": "Server Monitore", - "distributedStatusServerMonitorsDescription": "Überwache den Status der zugehörigen Server", - "distributedUptimeCreateSelectURL": "Hier kannst Du die URL und Typ des Hosts auswählen", - "distributedUptimeCreateChecks": "Durchzuführende Prüfungen", - "distributedUptimeCreateChecksDescription": "Du kannst jederzeit Überprüfungen hinzufügen oder entfernen, nachdem Du Deine Seite hinzugefügt hast.\n", - "distributedUptimeCreateIncidentNotification": "Ereignisbenachrichtigungen", - "distributedUptimeCreateIncidentDescription": "Wenn es ein Ereignis gibt, benachrichtige die Benutzer.", - "distributedUptimeCreateAdvancedSettings": "Erweiterte Einstellungen", - "distributedUptimeDetailsNoMonitorHistory": "Es gibt noch keine Überprüfungshistorie für diesen Monitor.", - "distributedUptimeDetailsStatusHeaderUptime": "Uptime:", - "distributedUptimeDetailsStatusHeaderLastUpdate": "Zuletzt aktualisiert", - "notifications": { - "enableNotifications": "Aktiviere {{platform}} Benachrichtigungen\n", - "testNotification": "Test Benachrichtigung", - "addOrEditNotifications": "Benachrichtigungen hinzufügen oder bearbeiten", - "slack": { - "label": "Slack", - "description": "Um Slack-Benachrichtigungen zu aktivieren, erstelle eine Slack-App und aktiviere eingehende Webhooks. Danach gib einfach die Webhook-URL hier ein.", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "Slack webhook URL wird benötigt" - }, - "discord": { - "label": "Discord", - "description": "Um Daten von Checkmate an einen Discord-Kanal über Discord-Benachrichtigungen mithilfe von Webhooks zu senden, kannst Du die eingehenden Webhooks von Discord verwenden.", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "Discord webhook URL wird benötigt" - }, - "telegram": { - "label": "Telegram", - "description": "Um Telegram-Benachrichtigungen zu aktivieren, erstelle einen Telegram-Bot mit BotFather, einem offiziellen Bot zum Erstellen und Verwalten von Telegram-Bots. Dann hole dir das API-Token und die Chat-ID und notiere sie hier.", - "tokenLabel": "Dein bot token", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "Deine Chat ID", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "Telegram token und Chat-ID werden benötigt" - }, - "webhook": { - "label": "Webhooks", - "description": "Du kannst einen benutzerdefinierten Webhook einrichten, um Benachrichtigungen zu erhalten, wenn Ereignisse auftreten.", - "urlLabel": "Webhook URL", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "Webhook URL wird benötigt" - }, - "testNotificationDevelop": "Testbenachrichtigung 2", - "integrationButton": "Benachrichtigungsintegration", - "testSuccess": "Testbenachrichtigung erfolgreich gesendet!", - "testFailed": "Senden der Testbenachrichtigung fehlgeschlagen", - "unsupportedType": "Nicht unterstützter Benachrichtigungstyp", - "networkError": "Netzwerkfehler aufgetreten", - "fallback": { - "title": "Ein Benachrichtigungskanal wird verwendet, um:", - "checks": [ - "Teams über Ausfallzeiten oder Leistungsprobleme informieren", - "Informieren Sie Techniker über Vorfälle", - "Informieren Sie Administratoren über Systemänderungen" - ], - "actionButton": "Lassen Sie uns Ihren ersten Benachrichtigungskanal erstellen!" - }, - "createButton": "Benachrichtigungskanal erstellen", - "createTitle": "Benachrichtigungskanal", - "create": { - "success": "Benachrichtigung erfolgreich erstellt", - "failed": "Benachrichtigung konnte nicht erstellt werden" - }, - "fetch": { - "success": "Benachrichtigungen erfolgreich abgerufen", - "failed": "Abrufen der Benachrichtigungen fehlgeschlagen" - }, - "delete": { - "success": "Benachrichtigung erfolgreich gelöscht", - "failed": "Benachrichtigung konnte nicht gelöscht werden" - }, - "edit": { - "success": "Benachrichtigung erfolgreich aktualisiert", - "failed": "Benachrichtigung konnte nicht aktualisiert werden" - }, - "test": { - "success": "Test Benachrichtigung erfolgreich gesendet", - "failed": "Fehler beim senden der Test Benachrichtigung" - } - }, - "testLocale": "testLocale", - "add": "Hinzufügen", - "monitors": "Monitore", - "distributedUptimeStatusCreateStatusPage": "Statusseite", - "distributedUptimeStatusCreateStatusPageAccess": "Zugriff", - "distributedUptimeStatusCreateStatusPageReady": "Wenn deine Statusseite bereit ist, kannst du sie als veröffentlicht markieren.", - "distributedUptimeStatusBasicInfoHeader": "Grundlegende Informationen", - "distributedUptimeStatusBasicInfoDescription": "Definiere den Firmennamen und die Subdomain, auf die deine Statusseite verweist.", - "distributedUptimeStatusLogoHeader": "Logo", - "distributedUptimeStatusLogoDescription": "Lade ein Logo für deine Statusseite hoch", - "distributedUptimeStatusLogoUploadButton": "Logo hochladen", - "distributedUptimeStatusStandardMonitorsHeader": "Standardmonitore", - "distributedUptimeStatusStandardMonitorsDescription": "Füge Standardmonitore zu deiner Statusseite hinzu.", - "distributedUptimeStatusCreateYour": "Erstelle Dein", - "distributedUptimeStatusEditYour": "Ändere Dein", - "distributedUptimeStatusPublishedLabel": "Veröffentlicht und für die Öffentlichkeit sichtbar", - "distributedUptimeStatusCompanyNameLabel": "Firmenname", - "distributedUptimeStatusPageAddressLabel": "Die Adresse deiner Statusseite", - "distributedUptimeStatus30Days": "30 Tage", - "distributedUptimeStatus60Days": "60 Tage", - "distributedUptimeStatus90Days": "90 Tage", - "distributedUptimeStatusPageNotSetUp": "Eine Statusseite ist nicht eingerichtet.", - "distributedUptimeStatusContactAdmin": "Bitte kontaktiere deinen Administrator", - "distributedUptimeStatusPageNotPublic": "Diese Statusseite ist nicht öffentlich.", - "distributedUptimeStatusPageDeleteDialog": "Möchtest du diese Statusseite löschen?", - "distributedUptimeStatusPageDeleteConfirm": "Ja, Statusseite löschen", - "distributedUptimeStatusPageDeleteDescription": "Sobald sie gelöscht ist, kann deine Statusseite nicht wiederhergestellt werden.", - "distributedUptimeStatusDevices": "Geräte", - "distributedUptimeStatusUpt": "UPT", - "distributedUptimeStatusUptBurned": "UPT Burned", - "distributedUptimeStatusUptLogo": "Upt Logo", - "incidentsTableNoIncidents": "Keine Ereignisse aufgezeichnet", - "incidentsTablePaginationLabel": "Ereignisse", - "incidentsTableMonitorName": "Monitor Name", - "incidentsTableStatus": "Status", - "incidentsTableDateTime": "Datum & Zeit", - "incidentsTableStatusCode": "Status Code", - "incidentsTableMessage": "Nachricht", - "incidentsOptionsHeader": "Ereignisse für", - "incidentsOptionsHeaderFilterBy": "Gefiltert nach:", - "incidentsOptionsHeaderFilterAll": "Alle", - "incidentsOptionsHeaderFilterDown": "offline", - "incidentsOptionsHeaderFilterCannotResolve": "Kann nicht aufgelöst werden", - "incidentsOptionsHeaderShow": "Anzeigen:", - "incidentsOptionsHeaderLastHour": "Letzte Stunde", - "incidentsOptionsHeaderLastDay": "Letzter Tag", - "incidentsOptionsHeaderLastWeek": "Letzte Woche", - "incidentsOptionsPlaceholderAllServers": "Alle Server", - "infrastructureCreateYour": "Erstelle dein", - "infrastructureCreateGeneralSettingsDescription": "Hier kannst du die URL des Hosts auswählen, zusammen mit dem Namen und dem Autorisierungs-token, um eine Verbindung zum Server-Agenten herzustellen.", - "infrastructureServerRequirement": "Der Server, den du überwachst, muss die folgende Software ausführen:", - "infrastructureCustomizeAlerts": "Benachrichtigungen anpassen", - "infrastructureAlertNotificationDescription": "Sende eine Benachrichtigung an Benutzer, wenn Schwellenwerte einen bestimmten Prozentsatz überschreiten.", - "infrastructureCreateMonitor": "Erstelle Infrastrukturmonitor", - "infrastructureProtocol": "Protokoll", - "infrastructureServerUrlLabel": "Server URL", - "infrastructureDisplayNameLabel": "Anzeigename", - "infrastructureAuthorizationSecretLabel": "Autorisierungs-token", - "gb": "GB", - "mb": "MB", - "mem": "Speicher", - "memoryUsage": "Speicher Nutzung", - "cpu": "CPU", - "cpuUsage": "CPU Nutzung", - "cpuTemperature": "CPU Temperatur", - "diskUsage": "Disk benutzt", - "used": "benutzt", - "total": "Total", - "cores": "Kerne", - "frequency": "Frequenz", - "status": "Status", - "cpuPhysical": "CPU (Physical)", - "cpuLogical": "CPU (Logical)", - "cpuFrequency": "CPU Frequenz", - "avgCpuTemperature": "Durchschn. CPU Termperatur", - "memory": "Speicher", - "disk": "Disk", - "uptime": "Uptime", - "os": "OS", - "host": "Host", - "actions": "Aktionen", - "integrations": "Integrationen", - "integrationsPrism": "Verbinde Prism mit deinem favorisierten Gerät", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "Verbinde dich mit Slack und sieh Ereignisse in einem Kanal", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "Verbinde dich mit Discord und sieh Ereignisse direkt in einem Kanal", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "Sende alle Ereignisse an Zapier und sieh sie dann überall", - "commonSave": "Speichern", - "createYour": "Erstelle Deinen", - "createMonitor": "Erstelle Monitor", - "pause": "Pausieren", - "resume": "Weiter", - "editing": "Bearbeiten...", - "url": "URL", - "access": "Zugriff", - "timezone": "Zeitzone", - "features": "Funktionen", - "administrator": "Administrator?", - "loginHere": "Hier anmelden", - "displayName": "Anzeigename", - "urlMonitor": "zu überwachende URL", - "portToMonitor": "zu überwachender Port", - "websiteMonitoring": "Websiten-Überwachung", - "websiteMonitoringDescription": "Verwende HTTP(s), um deine Website oder API-Endpunkt zu überwachen.", - "pingMonitoring": "Ping-Überwachung", - "pingMonitoringDescription": "Überprüfe, ob dein Server verfügbar ist oder nicht.", - "dockerContainerMonitoring": "Docker-Container-Überwachung", - "dockerContainerMonitoringDescription": "Überprüfe, ob dein Docker-Container läuft oder nicht.", - "portMonitoring": "Port-Überwachung", - "portMonitoringDescription": "Überprüfe, ob dein Port offen ist oder nicht.", - "createMaintenanceWindow": "Erstelle Wartungsfenster", - "createMaintenance": "Erstelle Wartungsfenster", - "editMaintenance": "Ändere Wartungsfenster", - "maintenanceWindowName": "Name des Wartungsfensters", - "friendlyNameInput": "Freundlicher Name", - "friendlyNamePlaceholder": "Wartung um __ : __ für ___ Minuten", - "maintenanceRepeat": "Wartungsfenster wiederholen", - "maintenance": "Wartungsfenster", - "duration": "Dauer", - "addMonitors": "Monitor hinzufügen", - "window": "Fenster", - "cancel": "Abbrechen", - "message": "Nachricht", - "low": "niedrig", - "high": "hoch", - "statusCode": "Status Code", - "date&Time": "Datum & Zeit", - "type": "Art", - "statusPageName": "Name der Statusseite", - "publicURL": "Öffentliche URL", - "repeat": "Wiederholen", - "edit": "Ändern", - "createA": "Erstelle ein", - "remove": "Löschen", - "maintenanceWindowDescription": "Pings werden während dieses Zeitraums nicht gesendet", - "startTime": "Anfangszeit", - "timeZoneInfo": "Alle Daten und Zeiten sind in der Zeitzone GMT+0.", - "monitorsToApply": "Monitore, auf die das Wartungsfenster angewendet werden soll", - "nextWindow": "Nächstes Fenster", - "notFoundButton": "Gehe zum Haupt-Dashboard", - "pageSpeedConfigureSettingsDescription": "Hier kannst du die URL des Hosts auswählen, zusammen mit der Art des Monitors.", - "monitorDisplayName": "Anzeigename des Monitors", - "whenNewIncident": "Wenn es ein neues Ereignis gibt,", - "notifySMS": "Benachrichtige per SMS (kommt bald)", - "notifyEmails": "Benachrichtige auch per E-Mail an mehrere Adressen (kommt bald)", - "seperateEmails": "Du kannst mehrere E-Mails mit einem Komma trennen", - "checkFrequency": "Überprüfungsfrequenz", - "matchMethod": "Übereinstimmungsmethode", - "expectedValue": "Erwarteter Wert", - "deleteDialogTitle": "Möchtest du diesen Monitor wirklich löschen?", - "deleteDialogDescription": "Sobald dieser Monitor gelöscht ist, kann er nicht wiederhergestellt werden.", - "pageSpeedMonitor": "PageSpeed-Monitor", - "shown": "Gezeigt", - "ago": "vor", - "companyName": "Name der Firma", - "pageSpeedDetailsPerformanceReport": "Die Werte sind geschätzt und können variieren.", - "pageSpeedDetailsPerformanceReportCalculator": "Rechner öffnen", - "checkingEvery": "Überprüfen alle", - "statusPageCreateSettings": "Wenn Ihre Statusseite fertig ist, können Sie sie als veröffentlicht markieren.", - "basicInformation": "Grundlegende Informationen", - "statusPageCreateBasicInfoDescription": "Definieren Sie den Firmennamen und die Subdomäne, auf die Ihre Statusseite verweist.", - "statusPageCreateSelectTimeZoneDescription": "Wählen Sie die Zeitzone aus, in der Ihre Statusseite angezeigt wird.", - "statusPageCreateAppearanceDescription": "Definieren Sie das Standard-Erscheinungsbild Ihrer öffentlichen Statusseite.", - "statusPageCreateSettingsCheckboxLabel": "Veröffentlicht und für die Öffentlichkeit sichtbar", - "statusPageCreateBasicInfoStatusPageAddress": "Ihre Statusseitenadresse", - "statusPageCreateTabsContent": "Statusseitenserver", - "statusPageCreateTabsContentDescription": "Sie können Ihrer Statusseite beliebig viele Server hinzufügen, die Sie überwachen. Sie die Reihenfolge der Server ändern.", - "statusPageCreateTabsContentFeaturesDescription": "Mehr Details auf der Statusseite anzeigen", - "showCharts": "Diagramme anzeigen", - "showUptimePercentage": "Verfügbarkeitsprozentsatz anzeigen", - "removeLogo": "Entferne Logo", - "statusPageStatus": "Eine öffentliche Statusseite ist nicht eingerichtet.", - "statusPageStatusContactAdmin": "Bitte wenden Sie sich an Ihren Administrator", - "statusPageStatusNotPublic": "Diese Statusseite ist nicht öffentlich.", - "statusPageStatusNoPage": "Hier gibt es keine Statusseite.", - "statusPageStatusServiceStatus": "Dienststatus", - "deleteStatusPage": "Möchten Sie diese Statusseite löschen?", - "deleteStatusPageConfirm": "Ja, Statusseite löschen", - "deleteStatusPageDescription": "Nach dem Löschen kann Ihre Statusseite nicht mehr abgerufen werden.", - "uptimeCreate": "Der erwartete Wert wird zum Abgleich mit dem Antwortergebnis verwendet und die Übereinstimmung bestimmt den Status.", - "uptimeCreateJsonPath": "Dieser Ausdruck wird anhand der JSON-Antwortdaten ausgewertet und das Ergebnis wird zum Abgleich mit dem erwarteten Wert verwendet. Siehe", - "uptimeCreateJsonPathQuery": "für die Dokumentation der Abfragesprache.", - "maintenanceTableActionMenuDialogTitle": "Möchten Sie dieses Wartungsfenster wirklich entfernen?", - "infrastructureEditYour": "Bearbeiten Sie Ihre", - "infrastructureEditMonitor": "Infrastrukturmonitor speichern", - "infrastructureMonitorCreated": "Infrastrukturmonitor erfolgreich erstellt!", - "infrastructureMonitorUpdated": "Infrastrukturmonitor erfolgreich aktualisiert!", - "errorInvalidTypeId": "Ungültiger Benachrichtigungstyp angegeben", - "errorInvalidFieldId": "Ungültige Feld-ID angegeben", - "inviteNoTokenFound": "Kein Einladungstoken gefunden", - "pageSpeedWarning": "Warnung: Sie haben noch keinen Google PageSpeed API-Schlüssel hinzugefügt. Ohne diesen funktioniert der PageSpeed-Monitor nicht.", - "pageSpeedLearnMoreLink": "Hier klicken", - "pageSpeedAddApiKey": "um einen API key einzutragen", - "update": "Update", - "invalidFileFormat": "Nicht unterstützes Dateiformat", - "invalidFileSize": "Dateigröße zu groß!", - "ClickUpload": "Zum Hochladen klicken", - "DragandDrop": "drag and drop", - "MaxSize": "Maximalgröße", - "SupportedFormats": "Unterstützte Formate", - "FirstName": "Vorname", - "LastName": "Zuname", - "EmailDescriptionText": "Dies ist Ihre aktuelle E-Mail-Adresse – sie kann nicht geändert werden.", - "YourPhoto": "Profilphoto", - "PhotoDescriptionText": "Dieses Foto wird auf Ihrer Profilseite angezeigt.", - "save": "Speichern", - "DeleteDescriptionText": "Dadurch werden das Konto und alle zugehörigen Daten vom Server entfernt. Dieser Vorgang kann nicht rückgängig gemacht werden.", - "DeleteAccountWarning": "Wenn Sie Ihr Konto löschen, können Sie sich nicht mehr anmelden und alle Ihre Daten werden gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.", - "DeleteWarningTitle": "Account wirklich löschen?", - "bulkImport": { - "title": "Massenimport", - "selectFileTips": "Wählen Sie die CSV-Datei zum Hochladen aus", - "selectFileDescription": "Sie können unsere oder Beispiel herunterladen.", - "selectFile": "Datei auswählen", - "parsingFailed": "Parsen fehlgeschlagen", - "uploadSuccess": "Monitore erfolgreich erstellt!", - "validationFailed": "Prüfung fehlgeschlagen", - "noFileSelected": "Keine Datei ausgewählt", - "fallbackPage": "Importieren Sie eine Datei, um eine Liste von Servern gleichzeitig hochzuladen", - "invalidFileType": "Ungültiger Dateityp", - "uploadFailed": "Upload fehlgeschlagen" - }, - "DeleteAccountTitle": "Account löschen", - "DeleteAccountButton": "Account löschen", - "publicLink": "Öffentlicher Link", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "reset": "Reset", - "ignoreTLSError": "TLS/SSL Fehler ignorieren", - "tlsErrorIgnored": "TLS/SSL Fehler ignoriert", - "ignoreTLSErrorDescription": "TLS/SSL Fehler ignorieren und Verfügbarkeit weiter prüfen", - "createNew": "Neu anlegen", - "greeting": { - "prepend": "Hallo", - "append": "Der Nachmittag gehört Ihnen – machen wir ihn episch!", - "overview": "Hier ist eine Übersicht Ihrer {{type}}-Monitore." - }, - "roles": { - "superAdmin": "Super admin", - "admin": "Admin", - "teamMember": "Team Mitglied", - "demoUser": "Demo user" - }, - "teamPanel": { - "teamMembers": "Team Mitglied", - "filter": { - "all": "Alle", - "member": "Mitglied" - }, - "inviteTeamMember": "Team Mirglied einladen", - "inviteNewTeamMember": "Neues Team Mirglied einladen", - "inviteDescription": "Wenn Sie ein neues Teammitglied hinzufügen, erhält dieses Zugriff auf alle Monitore.", - "email": "Email", - "selectRole": "Rolle auswählen", - "inviteLink": "Einladungslink", - "cancel": "Abbrechen", - "noMembers": "Es gibt keine Team Mitglieder mit dieser Rolle", - "getToken": "Token holen", - "emailToken": "E-mail token", - "table": { - "name": "Name", - "email": "Email", - "role": "Rolle", - "created": "Angelegt" - }, - "addTeamMember": { - "addMemberMenu": "Teammitglied hinzufügen", - "title": "Neues Teammitglied registrieren", - "description": "Einen neuen Benutzer anlegen und Zugangsdaten mit ihm teilen. Diese Methode gibt dem Mitglied direkt Zugriff auf alle Überwachungen.", - "addButton": "Mitglied hinzufügen" - }, - "register": "Teammitglied registrieren", - "registerToast": { - "success": "Benutzer erstellt, Zugangsdaten auf sichere Weise weitergeben.", - "dbUserExists": "Der Benutzer existiert bereits.", - "unknownError": "Unbekannter Fehler aufgetreten." - }, - "registerTeamMember": { - "title": "Teammitglied registrieren", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "Bitte einen Namen eingeben", - "pattern": "Der Name darf nur Buchstaben, Leerzeichen, Apostrophe oder Bindestriche enthalten" - } - }, - "lastName": { - "errors": { - "empty": "Bitte einen Vornamen eingeben", - "pattern": "Der Vorname darf nur Buchstaben, Leerzeichen, Apostrophe oder Bindestriche enthalten" - } - }, - "email": { - "errors": { - "empty": "Zum Fortsetzen bitte eine E-Mail-Adresse eingeben", - "invalid": "Bitte die Gültigkeit der E-Mail-Adresse erneut prüfen" - } - }, - "role": { - "errors": { - "empty": "Eine Rolle wird benötigt" - } - } - } - } - } - }, - "role": "Rolle", - "changeTeamPassword": { - "changePasswordMenu": "Passwort zurücksetzen", - "title": "Passwort des Teammitgliedes zurücksetzen", - "description": "Neues Passwort für dieses Teammitglied erstellen. Es muss auf einem Sicheren weg zur Verfügung gestellt werden.", - "success": "Passwort erfolgreich zurückgesetzt. Bitte sicherstellen, dass die Zugangsdaten auf sichere Weise weitergegeben werden." - } - }, - "monitorState": { - "paused": "Pausiert", - "resumed": "Wiederaufgenommen", - "active": "Aktiv" - }, - "menu": { - "uptime": "Uptime", - "pagespeed": "Pagespeed", - "infrastructure": "Infrastruktur", - "incidents": "Vorfälle", - "statusPages": "Statusseite", - "maintenance": "Wartung", - "integrations": "Integrationen", - "settings": "Einstellungen", - "support": "Support", - "discussions": "Diskussionen", - "docs": "Anleitungen", - "changelog": "Änderungen", - "profile": "Profil", - "password": "Passwort", - "team": "Team", - "logOut": "Abmelden", - "notifications": "Benachrichtigungen", - "logs": "Logs" - }, - "settingsEmailUser": "E-Mail-Benutzer – Benutzername zur Authentifizierung, überschreibt die E-Mail-Adresse, falls angegeben", - "state": "Zustand", - "statusBreadCrumbsStatusPages": "Statusseite", - "statusBreadCrumbsDetails": "Details", - "commonSaving": "Speichere...", - "navControls": "Steuerungen", - "incidentsPageTitle": "Ereignisse", - "passwordPanel": { - "passwordChangedSuccess": "Dein Passwort wurde erfolgreich geändert.", - "passwordInputIncorrect": "Die Passworteingabe war nicht korrekt.", - "currentPassword": "Aktuelles Passwort", - "enterCurrentPassword": "Aktuelles Passwort eingeben", - "newPassword": "Neues Passwort", - "enterNewPassword": "Neues Passwort eingeben", - "confirmNewPassword": "Neues Passwort wiederholen", - "passwordRequirements": "Das Passwort muss mindestens 8 Zeichen, einen Großbuchstaben, eine Zahl und ein Sonderzeichen enthalten.", - "saving": "Speichere..." - }, - "emailSent": "Email erfolgreich gesendet", - "failedToSendEmail": "Email konnte nicht gesendet werden", - "settingsTestEmailSuccess": "Test-Email erfolgreich gesendet", - "settingsTestEmailFailed": "Test-Email konnte nicht gesendet werden", - "settingsTestEmailFailedWithReason": "Email konnte nicht gesendet werden: {{reason}}", - "settingsTestEmailUnknownError": "Unbekannter Fehler", - "statusMsg": { - "paused": "Monitoring pausiert", - "up": "Ihre Site ist online.", - "down": "Ihre Site ist nicht erreichbar.", - "pending": "Ausstehend..." - }, - "uptimeGeneralInstructions": { - "http": "Geben Sie die zu überwachende URL oder IP ein (z. B. https://example.com/ oder 192.168.1.100) und fügen Sie einen eindeutigen Anzeigenamen hinzu, der auf dem Dashboard angezeigt wird.", - "ping": "Geben Sie die IP-Adresse oder den Hostnamen ein, der angepingt werden soll (z. B. 192.168.1.100 oder example.com), und fügen Sie einen eindeutigen Anzeigenamen hinzu, der auf dem Dashboard angezeigt wird.", - "docker": "Geben Sie die Docker-ID Ihres Containers ein. Docker-IDs müssen die vollständige 64-Zeichen-ID sein. Sie können docker inspect ausführen, um die vollständige Container-ID abzurufen.", - "port": "Geben Sie die URL oder IP des Servers, die Portnummer und einen eindeutigen Anzeigenamen ein, der auf dem Dashboard angezeigt wird.", - "game": "IP Adresse oder Hostnamen und Port für Ping eingeben (z.B. 192.168.1.100 oder beispiel.de) und Spieletyp auswählen.", - "https": "Zu überwachende Adresse oder IP (z.B. https://beispiel.de/ oder 192.168.1.100) eingeben und einen klaren Anzeigenamen, der im Dashboard erscheint." - }, "common": { - "appName": "Checkmate", - "monitoringAgentName": "Capture", - "buttons": { - "toggleTheme": "Schaltet hell und dunkel um" + "auth": { + "roles": { + "admin": "Admin", + "demo": "Demo", + "superadmin": "Super-Admin", + "user": "Benutzer" + } }, - "toasts": { - "networkError": "Netzwerkfehler", - "checkConnection": "Bitte Verbindung prüfen", - "unknownError": "Unbekannter Fehler" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "Weiter", - "back": "Zurück" + "alerts": { + "pageSpeedApiKey": { + "content": "Warnung: Sie haben noch keinen Google PageSpeed API-Schlüssel hinzugefügt. Besuchen Sie die Einstellungen, um einen hinzuzufügen. Ohne diesen funktioniert der PageSpeed-Monitor nicht." + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "Details", + "home": "Startseite" + }, + "buttons": { + "addMember": "Mitglied hinzufügen", + "cancel": "Abbrechen", + "close": "Schließen", + "configure": "Konfigurieren", + "confirm": "Bestätigen", + "create": "Erstellen", + "delete": "Löschen", + "generateToken": "Token generieren", + "incidents": "Vorfälle", + "inviteMember": "Mitglied einladen", + "pause": "Pausieren", + "resume": "Fortsetzen", + "save": "Speichern", + "sendInvite": "Einladung senden", + "test": "Testen", + "testNotifications": "Benachrichtigungen testen", + "toggleTheme": "Schaltet hell und dunkel um", + "flushQueue": "Warteschlange leeren", + "notFound": "Zum Hauptdashboard", + "resetPassword": "Passwort zurücksetzen", + "reset": "Zurücksetzen", + "clear": "Löschen", + "addDemo": "Demo-Monitore hinzufügen", + "removeMonitors": "Monitore entfernen", + "sendTestEmail": "Test-E-Mail senden", + "exportToJSON": "Als JSON exportieren", + "importFromJSON": "Aus JSON importieren", + "clearFilters": "Filter zurücksetzen", + "removeUser": "Benutzer entfernen" + }, + "charts": { + "labels": { + "averageResponseTime": "Durchschnittliche Antwortzeit", + "downtime": "Ausfallzeit", + "high": "hoch", + "low": "niedrig", + "uptime": "Verfügbarkeit" }, - "inputs": { - "email": { - "label": "Email", - "placeholder": "jordan.ellis@domain.com", - "errors": { - "empty": "Email adresse eingeben um fortzufahren", - "invalid": "Bitte überprüfen Sie die Gültigkeit der eingegebenen E-Mail-Adresse erneut" - } - }, - "password": { - "label": "Passwort", - "rules": { - "length": { - "beginning": "Muß mindestens", - "highlighted": "8 Zeichen lang" - }, - "special": { - "beginning": "Muß mindestens", - "highlighted": "ein Sonderzeichen enthalten" - }, - "number": { - "beginning": "Muß mindestens", - "highlighted": "eine Zahl enthalten" - }, - "uppercase": { - "beginning": "Muß mindestens", - "highlighted": "einen Großbuchstaben enthalten" - }, - "lowercase": { - "beginning": "Muß mindestens", - "highlighted": "einen Kleinbuchstaben enthalten" - }, - "match": { - "beginning": "Passwort und Kennwort bestätigen", - "highlighted": "muß übereinstimmen" - } - }, - "errors": { - "empty": "Bitte Passwort eingeben", - "length": "Passwort muss mindestens 8 Zeichen lang sein", - "uppercase": "Passwort muss mindestens einen Großbuchstaben enthalten", - "lowercase": "Passwort muss mindestens einen Kleinbuchstaben enthalten", - "number": "Passwort muss mindestens eine Zahl enthalten", - "special": "Passwort muss mindestens ein Sonderzeichen enthalten", - "incorrect": "Das Passwort stimmt nicht mit dem gespeicherten überein" - } - }, - "passwordConfirm": { - "label": "Passwort bestätigen", - "placeholder": "Passwort nochmal eingeben", - "errors": { - "empty": "Passwort nochmal eingeben", - "different": "Die Passwörter stimmen nicht überein" - } - }, - "firstName": { - "label": "Name", - "placeholder": "Jordan", - "errors": { - "empty": "Bitte Name eingeben", - "length": "Name darf nicht mehr als 50 Zeichen lang sein", - "pattern": "Name darf nur aus Buchstaben, Leerstellen, Apostroph und Bindestrichen bestehen" - } - }, - "lastName": { - "label": "Zuname", - "placeholder": "Ellis", - "errors": { - "empty": "Bitte Zuname eingeben", - "length": "Zuname darf nicht länger als 50 Zeichen sein", - "pattern": "Zuname darf nur aus Buchstaben, Leerstellen, Apostroph und Bindestrichen bestehen" + "histogram": { + "avg": "Durchschn.: {{value}} ms", + "max": "Max.: {{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "Diese Aktion kann nicht rückgängig gemacht werden.", + "title": "Möchten Sie dies wirklich löschen?" + } + }, + "labels": { + "active": "Aktiv", + "paused": "pausiert", + "na": "N/A", + "resolved": "Gelöst", + "responseTime": "Antwortzeit" + }, + "form": { + "role": { + "option": { + "role": { + "label": "Rollen" } } }, - "errors": { - "validation": "Fehler bei der prüfung der Daten" - }, - "fields": { - "password": { - "errors": { - "incorrect": "Das Passwort stimmt nicht mit dem gespeicherten überein" + "name": { + "option": { + "firstName": { + "label": "Name", + "placeholder": "Jordan" + }, + "lastName": { + "label": "Zuname", + "placeholder": "Ellis" } - }, - "role": { - "errors": { - "min": "Mindestens eine Rolle ist erforderlich" + } + }, + "email": { + "option": { + "email": { + "label": "E-Mail", + "placeholder": "you@example.com" } } } }, - "login": { - "heading": "Anmelden", - "subheadings": { - "stepOne": "Email eingeben", - "stepTwo": "Passwort eingeben" - }, - "links": { - "forgotPassword": "Passwort vergessen?", - "register": "Noch keinen Account?", - "forgotPasswordLink": "Passwort zurücksetzen", - "registerLink": "Hier registrieren" - }, - "toasts": { - "success": "Willkommen zurück. Erfolgreich angemeldet.", - "incorrectPassword": "Falsches Passwort" - }, + "table": { + "empty": "Nichts vorhanden", + "headers": { + "actions": "Aktionen", + "dateTime": "Datum & Uhrzeit", + "message": "Nachricht", + "monitor": "Monitor", + "monitorId": "Monitor-ID", + "name": "Name", + "status": "Status", + "type": "Art", + "url": "URL", + "interval": "Intervall", + "active": "Aktiv", + "responseTime": "Antwortzeit" + } + } + }, + "components": { + "imageUpload": { + "clickToUpload": "Zum Hochladen klicken", + "dragAndDrop": "drag and drop", + "supportedFormats": "Unterstützte Formate", + "maxSize": "Maximalgröße", + "orDragAndDrop": "oder per Drag & Drop", "errors": { - "password": { - "incorrect": "Das Passwort stimmt nicht mit dem gespeicherten überein" - } - }, - "welcome": "Willkommen zurück bei Checkmate!" + "invalidFileSize": "Dateigröße zu groß!", + "invalidFileFormat": "Nicht unterstützes Dateiformat" + } }, - "registration": { - "heading": { - "superAdmin": "Super Admin anlegen", - "user": "Anmelden" - }, - "subheadings": { - "stepOne": "Geben Sie Ihre persönlichen Daten ein", - "stepTwo": "Email eingeben", - "stepThree": "Passwort eingeben" + "headerStatusPageControls": { + "publicLink": "Öffentlicher Link" + }, + "headerTimeRange": { + "labels": { + "day": "Tag", + "month": "Monat", + "recent": "Aktuell", + "week": "Woche" }, "description": { - "superAdmin": "Super admin account anlegen um zu beginnen", - "user": "Melden Sie sich als Benutzer an und bitten Sie den Superadministrator um Zugriff auf Ihre Monitore" + "recent": "Statistiken der letzten 2 Stunden.", + "day": "Statistiken der letzten 24 Stunden.", + "week": "Statistiken der letzten 7 Tage.", + "month": "Statistiken der letzten 30 Tage." + } + }, + "offlineBanner": { + "serverUnreachable": "Server nicht erreichbar", + "retry": "Erneut versuchen", + "retrying": "Wird erneut versucht...", + "reconnected": "Verbindung zum Server wiederhergestellt." + }, + "sidebar": { + "menu": { + "uptime": "Verfügbarkeit", + "pagespeed": "Pagespeed", + "infrastructure": "Infrastruktur", + "notifications": "Benachrichtigungen", + "checks": "Checks", + "incidents": "Vorfälle", + "statusPages": "Statusseite", + "maintenance": "Wartung", + "logs": "Protokolle", + "settings": "Einstellungen" }, - "gettingStartedButton": { - "superAdmin": "Super admin account anlegen", - "user": "Normalen Benutzer anlegen" + "bottomMenu": { + "support": "Support", + "discussions": "Diskussionen", + "docs": "Anleitungen", + "changelog": "Änderungen" }, - "termsAndPolicies": "Indem Sie ein Konto erstellen, stimmen Sie unseren Servicebedingungen und Datenschutzrichtlinien zu.", + "accountMenu": { + "profile": "Profil", + "password": "Passwort", + "team": "Team" + }, + "starPrompt": { + "title": "Checkmate bewerten", + "description": "Entdecken Sie die neuesten Versionen und helfen Sie der Community auf GitHub" + }, + "authFooter": { + "navControls": "Steuerungen", + "logOut": "Abmelden", + "roles": { + "superAdmin": "Super-Admin", + "admin": "Admin", + "user": "Benutzer", + "demoUser": "Demo-Benutzer" + } + } + }, + "starPrompt": { + "title": "Checkmate bewerten", + "description": "Entdecken Sie die neuesten Versionen und helfen Sie der Community auf GitHub" + }, + "pageHeader": { "links": { - "login": "Du hast bereits einen Benutzer? Anmelden" - }, - "toasts": { - "success": "Willkommen! Benutzer erfolgreich angelegt" - }, - "welcome": "Willkommen bei Checkmate!" - }, - "forgotPassword": { - "heading": "Passwort vergessen?", - "subheadings": { - "stepOne": "Kein Problem. Wir schicken Dir Infos zum zurücksetzen.", - "stepTwo": "Link zum Password zurücksetzen an geschickt. ", - "stepThree": "Das neue Passwort darf nicht gleich wie frühere Passwörter sein.", - "stepFour": "Das Passwort wurde zurückgesetzt. Unten klicken zum Anmelden." - }, - "buttons": { - "openEmail": "Email app öffnen", - "resetPassword": "Passwort zurücksetzen" - }, - "imageAlts": { - "passwordKey": "Password key icon", - "email": "Email icon", - "lock": "Lock icon", - "passwordConfirm": "Password confirm icon" - }, - "links": { - "login": "Zurück zum Anmelden", - "resend": "Keine Email bekommen? Klicken um nochmal zu senden" - }, - "toasts": { - "sent": "Anleitung an gesendet.", - "emailNotFound": "Email nicht gefunden.", - "redirect": "Umleitung in ...", - "success": "Passwort erfolgreich zurückgesetzt.", - "error": "Kennwort konnte nicht zurückgesetzt werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an den Support." + "support": "Support", + "discussions": "Diskussionen", + "docs": "Dokumentation", + "changelog": "Änderungsverlauf" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "Verbindung zum Server wiederhergestellt.", - "stillUnreachable": "Server immer noch nicht erreichbar. Bitte später erneut versuchen." + "pages": { + "notFound": { + "title": "Oh nein! Sie haben Ihr Sushi fallen lassen!", + "subtitle": "Entweder existiert die URL nicht oder Sie haben keinen Zugriff darauf." + }, + "account": { + "tabs": { + "profile": "Profil", + "password": "Passwort", + "team": "Team" }, - "alertBox": "Server Verbindungsfehler", - "description": "Wir können keine Verbindung zum Server herstellen. Bitte überprüfen Sie Ihre Internetverbindung oder Ihre Bereitstellungskonfiguration, falls das Problem weiterhin besteht.", - "retryButton": { - "default": "Verbindung erneut versuchen", - "processing": "Verbinde..." + "form": { + "name": { + "title": "Name", + "description": "Aktualisieren Sie Ihre persönlichen Informationen" + }, + "photo": { + "title": "Profilbild", + "description": "Laden Sie ein Profilbild hoch" + }, + "currentPassword": { + "title": "Aktuelles Passwort", + "description": "Geben Sie Ihr aktuelles Passwort ein, um Ihre Identität zu bestätigen", + "option": { + "label": "Aktuelles Passwort", + "placeholder": "Aktuelles Passwort eingeben" + } + }, + "newPassword": { + "title": "Neues Passwort", + "description": "Wählen Sie ein sicheres Passwort mit mindestens 8 Zeichen", + "option": { + "newPassword": { + "label": "Neues Passwort", + "placeholder": "Neues Passwort eingeben" + }, + "confirm": { + "label": "Passwort bestätigen", + "placeholder": "Neues Passwort bestätigen" + } + } + }, + "deleteAccount": { + "title": "Konto löschen", + "description": "Diese Aktion ist dauerhaft und kann nicht rückgängig gemacht werden" + } + }, + "team": { + "filter": { + "placeholder": "Nach Rolle filtern", + "all": "Alle", + "admin": "Admin", + "member": "Mitglied" + }, + "table": { + "headers": { + "email": "E-Mail", + "role": "Rolle", + "created": "Angelegt" + } + }, + "addMember": { + "title": "Neues Teammitglied registrieren", + "description": "Erstellen Sie ein neues Benutzerkonto. Teilen Sie die Zugangsdaten nach der Erstellung sicher mit." + }, + "invite": { + "title": "Teammitglied einladen", + "description": "Senden Sie eine Einladung, Ihrem Team beizutreten", + "email": { + "label": "E-Mail-Adresse", + "placeholder": "E-Mail-Adresse eingeben" + }, + "role": { + "label": "Rolle", + "placeholder": "Rolle auswählen" + }, + "linkLabel": "Einladungslink" + }, + "fallback": { + "title": "Noch keine Teammitglieder", + "description": "Laden Sie Teammitglieder per E-Mail ein oder fügen Sie sie direkt hinzu. Sobald sie beitreten, erscheinen sie hier mit ihrer Rolle." + } + }, + "header": { + "title": "Konto", + "description": "Verwalten Sie Profil, Passwort, Team und Workspace-Einstellungen." } - } - }, - "createNotifications": { - "title": "Benachrichtigungskanal erstellen", - "nameSettings": { - "title": "Name", - "description": "Ein beschreibender Name für Ihre Integration.", - "nameLabel": "Name", - "namePlaceholder": "z.B. Slack Benachrichtigung" }, - "typeSettings": { - "title": "Typ", - "description": "Wählen Sie den Typ des Benachrichtigungskanals aus, den Sie erstellen möchten.", - "typeLabel": "Typ" - }, - "emailSettings": { - "title": "Email", - "description": "Empfänger Email adresse", - "emailLabel": "Email addresse", - "emailPlaceholder": "z.B. john@example.com" - }, - "slackSettings": { - "title": "Slack", - "description": "Slack webhook hier konfigurieren", - "webhookLabel": "Slack webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/..." - }, - "pagerdutySettings": { - "title": "PagerDuty", - "description": "PagerDuty hier konfigurieren", - "integrationKeyLabel": "Integration key", - "integrationKeyPlaceholder": "1234567890" - }, - "discordSettings": { - "title": "Discord", - "description": "Discord webhook hier konfigurieren", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "webhookSettings": { - "title": "Webhook", - "description": "Webhook hier konfigurieren", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "testNotification": "Testbenachrichtigung", - "dialogDeleteTitle": "Möchten Sie diese Benachrichtigung wirklich löschen?", - "dialogDeleteConfirm": "Löschen" - }, - "notificationConfig": { - "title": "Benachrichtigungen", - "description": "Wählen Sie die Benachrichtigungskanäle aus, die Sie verwenden möchten" - }, - "monitorStatus": { - "checkingEvery": "Überprüfung alle {{interval}}", - "withCaptureAgent": "mit Capture-Agent {{version}}", - "up": "Online", - "down": "Offline", - "paused": "pausiert" - }, - "advancedMatching": "Erweiterter Abgleich", - "sendTestNotifications": "Test Benachrichtigung senden", - "selectAll": "Alle auswählen", - "showAdminLoginLink": "Link „Administrator? Hier anmelden“ auf der Statusseite anzeigen", - "logsPage": { - "title": "Logs", - "description": "Systemprotokolle – letzte 1000 Zeilen", - "tabs": { - "queue": "Warteschlange", - "logs": "Server logs", - "diagnostics": "Diagnose" - }, - "toast": { - "fetchLogsSuccess": "Protokolle erfolgreich abgerufen" - }, - "logLevelSelect": { - "title": "Protokollebene", - "values": { - "all": "Alle", - "info": "Info", - "warn": "Warnung", - "error": "Fehler", - "debug": "Debug" + "auth": { + "brandPanel": { + "eyebrow": "Open-Source-Infrastruktur-Monitoring", + "tagline": "Beobachten Sie jeden Zug Ihrer Server.", + "description": "Echtzeit-Überwachung von Verfügbarkeit, Antwortzeiten und Vorfällen - selbst gehostet und in Ihrer Hand." + }, + "common": { + "passwordRules": { + "length": { + "beginning": "Muß mindestens", + "highlighted": "8 Zeichen lang" + }, + "lowercase": { + "beginning": "Muß mindestens", + "highlighted": "einen Kleinbuchstaben enthalten" + }, + "match": { + "beginning": "Passwort und Kennwort bestätigen", + "highlighted": "muß übereinstimmen" + }, + "number": { + "beginning": "Muß mindestens", + "highlighted": "eine Zahl enthalten" + }, + "special": { + "beginning": "Muß mindestens", + "highlighted": "ein Sonderzeichen enthalten" + }, + "uppercase": { + "beginning": "Muß mindestens", + "highlighted": "einen Großbuchstaben enthalten" + } + }, + "form": { + "option": { + "email": { + "label": "E-Mail", + "placeholder": "me@example.com" + }, + "password": { + "label": "Passwort", + "placeholder": "********" + }, + "confirmPassword": { + "label": "Passwort bestätigen" + } + } + } + }, + "login": { + "title": "Willkommen zurück bei Checkmate!", + "subtitle": "Melden Sie sich an, um fortzufahren", + "submit": "Anmelden", + "links": { + "forgotPassword": { + "text": "Passwort vergessen?", + "linkText": "Passwort zurücksetzen" + }, + "register": { + "text": "Noch kein Konto?", + "linkText": "Hier registrieren" + } + } + }, + "register": { + "title": "Willkommen bei Checkmate!", + "subtitle": "Registrieren Sie sich, um loszulegen", + "submit": "Registrieren", + "setupNotice": "Diese Seite ist nur während der Ersteinrichtung verfügbar. Sobald ein Super-Admin registriert ist, können weitere Benutzer in den Team-Einstellungen eingeladen werden." + }, + "forgotPassword": { + "title": "Passwort vergessen?", + "subtitle": "Keine Sorge, wir senden Ihnen Anweisungen zum Zurücksetzen.", + "submit": "Wiederherstellung anfordern", + "links": { + "login": { + "text": "Zurück zum", + "linkText": "Login" + } + } + }, + "setNewPassword": { + "title": "Passwort zurücksetzen", + "subtitle": "Ihr neues Passwort muss sich von zuvor verwendeten Passwörtern unterscheiden." } - } - }, - "queuePage": { - "title": "Warteschlange", - "refreshButton": "Auffrischen", - "flushButton": "Warteschlange leeren", - "jobTable": { - "title": "Aufgaben in der Warteschlange", - "idHeader": "Monitor ID", - "urlHeader": "URL", - "typeHeader": "Typ", - "activeHeader": "Aktiv", - "lockedAtHeader": "Gesperrt bei", - "runCountHeader": "Anzahl Läufe", - "failCountHeader": "Anzahl Fehler", - "lastRunHeader": "Letzter Lauf um", - "lastFinishedAtHeader": "Zuletzt abgeschlossen um", - "lastRunTookHeader": "Letzter Lauf dauerte", - "intervalHeader": "Invervall" }, - "metricsTable": { - "title": "Warteschlangenmetriken", - "metricHeader": "Metrisch", - "valueHeader": "Wert" + "checks": { + "selects": { + "monitor": { + "all": "Alle Monitore" + }, + "status": { + "all": "Alle", + "down": "Ausgefallen", + "up": "Erreichbar" + } + }, + "table": { + "empty": "Keine ausgefallenen Checks in diesem Zeitraum", + "headers": { + "statusCode": "Statuscode", + "location": "Standort" + } + }, + "fallback": { + "title": "Noch keine Prüfungen", + "description": "Sobald Sie einen Monitor erstellen, erscheinen jede Statusprüfung und Antwortzeit hier als Prüfung." + }, + "header": { + "title": "Prüfungen", + "description": "Jede Sondierung, die Checkmate gegen Ihre Monitore ausführt, erscheint hier mit Statuscode und Antwortzeit." + } }, - "failedJobTable": { - "title": "Anzahl Fehler", - "monitorIdHeader": "Monitor ID", - "monitorUrlHeader": "Monitor URL", - "failCountHeader": "Anzahl Fehler", - "failedAtHeader": "Zuletzt fehlgeschlagen um", - "failReasonHeader": "Fehlergrund" - } - }, - "export": { - "title": "Monitore exportieren", - "success": "Monitore erfolgreich exportiert", - "failed": "Fehler beim exportieren der Monitore" - }, - "monitorActions": { - "title": "Export/Import", - "import": "Monitore importieren", - "export": "Monitore exportieren", - "deleteSuccess": "Monitor erfolgreich gelöscht", - "deleteFailed": "Monitor konnte nicht gelöscht werden", - "details": "Details" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "Entwickelt von Bluewave Labs", - "labelVersion": "Version", - "title": "Über" + "common": { + "monitors": { + "actions": { + "configure": "Konfigurieren", + "delete": "Löschen", + "generateToken": "Token generieren", + "details": "Details", + "incidents": "Vorfälle", + "inviteMember": "Mitglied einladen", + "openSite": "Seite öffnen", + "pause": "Pausieren", + "resume": "Fortsetzen", + "selectAll": "Alle Monitore auswählen", + "selectMonitor": "{{name}} auswählen", + "bulkSelected_one": "{{count}} Monitor ausgewählt", + "bulkSelected_other": "{{count}} Monitore ausgewählt" + }, + "statBoxes": { + "activeFor": "Aktiv seit", + "certificateExpiry": "Zertifikatablauf", + "lastCheck": "Letzter Check", + "lastResponseTime": "Letzte Antwortzeit", + "serviceIsDown": "Dienst ist nicht erreichbar" + }, + "status": { + "down": "ausgefallen", + "breached": "Schwellenwert überschritten", + "initializing": "wird initialisiert", + "maintenance": "Wartung", + "paused": "pausiert", + "total": "gesamt", + "up": "erreichbar" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "Spiel", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "Port", + "optionPagespeed": "PageSpeed", + "optionHardware": "Infrastruktur", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} Monitor pausiert", + "paused_other": "{{count}} Monitore pausiert", + "resumed_one": "{{count}} Monitor fortgesetzt", + "resumed_other": "{{count}} Monitore fortgesetzt", + "alreadyPaused_one": "Der ausgewählte Monitor ist bereits pausiert", + "alreadyPaused_other": "Alle ausgewählten Monitore sind bereits pausiert", + "alreadyRunning_one": "Der ausgewählte Monitor wird bereits ausgeführt", + "alreadyRunning_other": "Alle ausgewählten Monitore werden bereits ausgeführt" + } + } }, - "demoMonitorsSettings": { - "buttonAddMonitors": "Demo Monitor hinzufügen", - "description": "Beispiel Monitor zur Demonstration hinzufügen.", - "title": "Demo Monitore" + "createMonitor": { + "form": { + "advanced": { + "description": "Optionale Einstellungen für erweiterte Anwendungsfälle", + "option": { + "advancedMatching": { + "label": "Erweiterten Abgleich verwenden" + }, + "expectedValue": { + "label": "Erwarteter Wert" + }, + "jsonPath": { + "description": "Dieser Ausdruck wird gegen die JSON-Antwortdaten ausgewertet und das Ergebnis wird mit dem erwarteten Wert verglichen. Siehe jmespath.org für die Dokumentation der Abfragesprache.", + "label": "JSONPath-Ausdruck" + }, + "matchMethod": { + "label": "Abgleichmethode", + "equal": "Gleich", + "include": "Enthält", + "regex": "Regex" + } + }, + "title": "Erweiterte Einstellungen" + }, + "frequency": { + "description": "Wie oft möchten Sie den Status dieses Monitors überprüfen?", + "option": { + "frequency": { + "label": "Prüffrequenz", + "value": { + "fifteenMinutes": "15 Minuten", + "fifteenSeconds": "15 Sekunden", + "fiveMinutes": "5 Minuten", + "fourMinutes": "4 Minuten", + "oneMinute": "Eine Minute", + "tenMinutes": "10 Minuten", + "thirtyMinutes": "30 Minuten", + "thirtySeconds": "30 Sekunden", + "threeMinutes": "3 Minuten", + "twoMinutes": "Zwei Minuten" + } + } + }, + "title": "Prüffrequenz" + }, + "general": { + "option": { + "container": { + "label": "Containername/-ID", + "placeholder": "my-app oder abcd1234" + }, + "host": { + "label": "Host", + "placeholder": "192.168.1.100 oder example.com" + }, + "name": { + "label": "Anzeigename", + "placeholder": "z. B. Meine Website" + }, + "secret": { + "label": "Autorisierungsschlüssel", + "placeholder": "Geben Sie Ihren geheimen Schlüssel ein" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "Zu überwachende URL", + "placeholder": "localhost" + }, + "port": { + "label": "Zu überwachender Port", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "Dienstname", + "placeholder": "z. B. my.service.v1 (leer lassen für allgemeinen Status)" + }, + "wsUrl": { + "label": "WebSocket-URL", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture-Endpunkt", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "Allgemeine Einstellungen", + "description": { + "http": "Geben Sie die zu überwachende URL oder IP ein (z. B. https://example.com/ oder 192.168.1.100) und fügen Sie einen eindeutigen Anzeigenamen hinzu, der auf dem Dashboard erscheint.", + "ping": "Geben Sie die IP-Adresse oder den Hostnamen ein, der angepingt werden soll (z. B. 192.168.1.100 oder example.com), und fügen Sie einen eindeutigen Anzeigenamen hinzu, der auf dem Dashboard angezeigt wird.", + "port": "Geben Sie die URL oder IP des Servers, die Portnummer und einen eindeutigen Anzeigenamen ein, der auf dem Dashboard angezeigt wird.", + "docker": "Geben Sie die Docker-ID Ihres Containers ein. Docker-IDs müssen die vollständige 64-Zeichen-ID sein. Sie können docker inspect ausführen, um die vollständige Container-ID abzurufen.", + "game": "IP Adresse oder Hostnamen und Port für Ping eingeben (z.B. 192.168.1.100 oder beispiel.de) und Spieletyp auswählen. Einige Spiele erfordern das Aktivieren von Server-Abfragen in ihrer Konfigurationsdatei — sieh in der Dokumentation deines Spielservers nach. In der Liste unterstützter Spiele findest du Protokolldetails.", + "pagespeed": "Verfolgen Sie die Ladezeit, Core Web Vitals und Optimierungswerte Ihrer Website.", + "grpc": "Geben Sie den Hostnamen und Port des gRPC-Servers ein, optional einen Dienstnamen für den Health-Check, und fügen Sie einen Anzeigenamen hinzu.", + "websocket": "Geben Sie die zu überwachende WebSocket-URL ein (z. B. wss://example.com/socket) und fügen Sie einen Anzeigenamen hinzu.", + "hardware": "Überwachen Sie CPU-, Speicher-, Festplattenauslastung und Temperatur Ihrer Infrastruktur. Die URL des Capture-Endpunkts muss den vollständigen Pfad /api/v1/metrics enthalten (z. B. http://192.168.1.10:5001/api/v1/metrics). Fügen Sie für das Autorisierungsgeheimnis nur den Token-String ein — geben Sie 'Bearer' nicht mit an." + } + }, + "ignoreTls": { + "description": "Konfigurieren Sie die TLS/SSL-Zertifikatsvalidierung für HTTPS-Verbindungen.", + "option": { + "tls": { + "label": "TLS/SSL-Fehler ignorieren" + } + }, + "title": "TLS/SSL-Einstellungen" + }, + "incidents": { + "description": "Ein gleitendes Fenster wird verwendet, um festzustellen, wann ein Monitor ausfällt. Der Status eines Monitors ändert sich erst, wenn der Prozentsatz der Checks im gleitenden Fenster den angegebenen Wert erreicht.", + "option": { + "checks": { + "label": "Anzahl der Checks im gleitenden Fenster" + }, + "percentage": { + "label": "Welcher Prozentsatz der Checks im gleitenden Fenster muss fehlschlagen/erfolgreich sein, bevor sich der Monitor-Status ändert?" + } + }, + "title": "Vorfälle" + }, + "notifications": { + "description": "Wählen Sie die Benachrichtigungskanäle aus, die Sie verwenden möchten", + "title": "Benachrichtigungen" + }, + "type": { + "description": "Wählen Sie die Art der durchzuführenden Prüfung", + "optionDockerDescription": "Docker verwenden, um zu überwachen, ob ein Container läuft.", + "optionGameDescription": "Überwachen, ob ein bestimmter Spieleserver online ist.", + "optionGrpcDescription": "gRPC-Dienste mit dem Standard Health Checking Protocol überwachen.", + "optionWebSocketDescription": "WebSocket-Endpunkte auf Verbindungszustand und Antwortzeit überwachen.", + "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" + }, + "thresholds": { + "title": "Alarmschwellenwerte", + "description": "Definieren Sie die Schwellenwerte, bei denen Alarme für diesen Hardware-Monitor ausgelöst werden sollen.", + "option": { + "cpuThreshold": { + "label": "CPU-Alarmschwelle (%)" + }, + "memoryThreshold": { + "label": "Arbeitsspeicher-Alarmschwelle (%)" + }, + "diskThreshold": { + "label": "Festplatten-Alarmschwelle (%)" + }, + "tempThreshold": { + "label": "Temperatur-Alarmschwelle (°C)" + } + } + }, + "geoChecks": { + "title": "Geo-verteilte Checks", + "description": "Führen Sie Checks von mehreren geografischen Standorten aus, um die globale Verfügbarkeit und Leistung zu überwachen.", + "option": { + "enabled": { + "label": "Geo-verteilte Checks aktivieren" + }, + "locations": { + "label": "Standorte", + "placeholder": "Standorte auswählen", + "options": { + "EU": "Europa", + "NA": "Nordamerika", + "AS": "Asien", + "SA": "Südamerika", + "AF": "Afrika", + "OC": "Ozeanien" + } + }, + "interval": { + "label": "Prüfintervall", + "value": { + "fiveMinutes": "5 Minuten", + "tenMinutes": "10 Minuten", + "fifteenMinutes": "15 Minuten", + "thirtyMinutes": "30 Minuten" + } + } + } + }, + "url": { + "title": "Monitor-IP/URL auf Statusseite", + "description": "Zeigt die IP-Adresse oder URL des Monitors auf der öffentlichen Statusseite an. Wenn deaktiviert, wird nur der Monitor-Name angezeigt, um sensible Informationen zu schützen.", + "option": { + "showURL": { + "label": "IP/URL auf Statusseite anzeigen", + "enabled": "Aktiviert", + "disabled": "Deaktiviert" + } + } + }, + "stats": { + "title": "Monitor-Verlauf", + "description": "Löschen Sie alle Überwachungsverläufe und Statistiken für Ihr Team. Diese Aktion ist unwiderruflich.", + "buttonText": "Alle Statistiken löschen", + "dialog": { + "title": "Alle Überwachungsverläufe löschen?", + "description": "Dies wird alle Überwachungsverläufe, Statistiken und Check-Daten für Ihr Team dauerhaft löschen. Diese Aktion kann nicht rückgängig gemacht werden.", + "confirm": "Ja, alle Statistiken löschen" + } + } + } }, - "emailSettings": { - "buttonSendTestEmail": "Test-Email senden", - "description": "Konfigurieren Sie die E-Mail-Einstellungen für Ihr System. Diese werden zum Senden von Benachrichtigungen und Warnungen verwendet.", - "descriptionTransport": "Dies erstellt einen SMTP-Transport für NodeMailer", - "labelAddress": "E-Mail-Adresse - Wird zur Authentifizierung verwendet", - "labelConnectionHost": "E-Mail-Verbindungshost – Hostname zur Verwendung in der HELO/EHLO-Begrüßung", - "labelHost": "E-Mail-Host – Hostname oder IP-Adresse, mit der eine Verbindung hergestellt werden soll", - "labelIgnoreTLS": "STARTTLS abschalten: TLS nicht nutzen, selbst wenn der Server es unterstützt", - "labelPassword": "Email password - Password für Anmeldung", - "labelPasswordSet": "Passwort erstellt. Reset drücken um es zu ändern.", - "labelPool": "Verbindungspooling aktivieren: Vorhandene Verbindungen wiederverwenden, um die Leistung zu verbessern", - "labelPort": "Email port - Port zum verbinden", - "labelRejectUnauthorized": "Ungültige Zertifikate ablehnen. Verbindungen mit self-signed oder nicht vertrauenswürdigen Zertifikaten ablehnen.", - "labelRequireTLS": "STARTTLS erzwingen: TLS-Upgrade erforderlich, schlägt fehl, wenn nicht unterstützt", - "labelSecure": "SSL verwenden (empfohlen): Verbindung mit SSL/TLS verschlüsseln", - "labelTLSServername": "TLS-Servername – Optionaler Hostname für die TLS-Validierung, wenn der Host eine IP ist", - "labelUser": "E-Mail-Benutzer – Benutzername zur Authentifizierung, überschreibt die E-Mail-Adresse, falls angegeben", - "linkTransport": "Spezifikationen finden Sie hier", - "placeholderUser": "Leer lassen, wenn nicht benötigt", - "title": "Email", - "toastEmailRequiredFieldsError": "E-Mail-Adresse, Host, Port und Passwort sind erforderlich" + "editUser": { + "form": { + "roles": { + "title": "Rollen", + "description": "Weisen Sie dem Benutzer Rollen zu. Es können mehrere Rollen ausgewählt werden." + } + }, + "dialog": { + "removeUser": { + "title": "Benutzer entfernen", + "content": "Möchten Sie {{name}} wirklich aus Ihrem Team entfernen? Diese Aktion kann nicht rückgängig gemacht werden." + } + } }, - "pageSpeedSettings": { - "description": "Geben Sie Ihren Google PageSpeed API-Schlüssel ein, um die Google PageSpeed-Überwachung zu aktivieren. Klicken Sie auf „Zurücksetzen“, um den Schlüssel zu aktualisieren.", - "labelApiKeySet": "API key erstellt. Reset drücken um ihn zu ändern.", - "labelApiKey": "PageSpeed API key", - "title": "Google PageSpeed API key" + "incidents": { + "fallback": { + "title": "Keine Vorfälle — alle Systeme funktionieren", + "description": "Wenn ein Monitor ausfällt oder wiederhergestellt wird, erscheint der Vorfall hier mit Zeitangaben, Monitordetails und Lösungsverlauf." + }, + "dialog": { + "resolveIncident": { + "title": "Vorfall lösen", + "option": { + "comment": { + "label": "Kommentar (optional)", + "placeholder": "Kommentar zur Lösung hinzufügen..." + } + } + }, + "details": { + "analysis": "Vorfallanalyse", + "comment": "Kommentar:", + "detailsLabel": "Details", + "downtime": "Ausfallzeit:", + "message": "Nachricht:", + "monitor": "Monitor:", + "overview": "Übersicht", + "resolutionDetails": "Lösungsdetails", + "resolutionType": "Typ:", + "resolutionTypes": { + "automatic": "Automatisch", + "manual": "Manuell" + }, + "resolve": "Vorfall lösen", + "resolvedAt": "Gelöst am:", + "resolvedBy": "Gelöst von:", + "startedAt": "Begonnen am:", + "status": "Status:", + "statusCode": "Statuscode:", + "timeline": "Zeitverlauf", + "title": "Vorfalldetails", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "Alle Monitore", + "monitor": "Monitor", + "resolutionType": "Lösungstyp", + "resolutionTypes": { + "manual": "Manuell", + "automatic": "Automatisch", + "all": "Alle" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "Aktive Vorfälle", + "active_zero": "Keine aktiven Vorfälle", + "active_one": "{{count}} aktiver Vorfall", + "active_other": "{{count}} aktive Vorfälle" + }, + "incidentStats": { + "avgResolutionTime": "Durchschn. Lösungszeit", + "mostAffectedMonitor": "Am meisten betroffener Monitor", + "title": "Vorfallstatistiken", + "totalIncidents": "Vorfälle gesamt" + }, + "latestIncidents": { + "title": "Neueste Vorfälle" + } + }, + "table": { + "actions": { + "details": "Details", + "goToMonitor": "Zum Monitor", + "resolveManually": "Manuell lösen" + }, + "activeIncidents": "Aktive Vorfälle", + "headers": { + "endTime": "Endzeit", + "resolutionType": "Lösungstyp", + "startTime": "Anfangszeit", + "statusCode": "Statuscode" + }, + "resolvedIncidents": "Gelöste Vorfälle", + "status": { + "active": "Aktiv", + "resolved": "Gelöst" + } + }, + "header": { + "title": "Vorfälle", + "description": "Wenn ein Monitor ausfällt oder wiederhergestellt wird, erscheinen Vorfälle hier mit Zeitangaben, Monitordetails und Lösungsverlauf." + } }, - "saveButtonLabel": "Speichern", - "statsSettings": { - "clearAllStatsButton": "Alle Statistiken löschen", - "clearAllStatsDescription": "Alle Statistiken löschen. Dies ist irreversibel.", - "clearAllStatsDialogConfirm": "Ja, alle Statistiken löschen", - "clearAllStatsDialogDescription": "Nach dem Entfernen können der Überwachungsverlauf und die Statistiken nicht mehr abgerufen werden.", - "clearAllStatsDialogTitle": "Möchten Sie alle Statistiken löschen?", - "description": "Legen Sie fest, wie lange Sie historische Daten aufbewahren möchten. Sie können auch alle vorhandenen Daten löschen.", - "labelTTL": "Die Tage, an denen Sie den Überwachungsverlauf beibehalten möchten.", - "labelTTLOptional": "0 für unendlich", - "title": "Monitorverlauf" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "Alle Monitore löschen", - "description": "Alle Monitore löschen", - "dialogConfirm": "Ja, alle Monitore löschen", - "dialogDescription": "Gelöschte Monitore können nicht wiederhergestellt werden.", - "dialogTitle": "Wirklich alle Monitore löschen?", - "title": "System zurücksetzen" - }, - "timezoneSettings": { - "description": "Wählen Sie die Zeitzone aus, die zum Anzeigen von Datum und Uhrzeit in der gesamten Anwendung verwendet wird.", - "label": "Zeitzone", - "title": "Zeitzone" - }, - "title": "Einstellungen", - "uiSettings": { - "description": "Wechseln Sie zwischen hellem und dunklem Modus oder ändern Sie die Sprache der Benutzeroberfläche.", - "labelLanguage": "Sprache", - "labelTheme": "Themenmodus", - "title": "Aussehen" - }, - "urlSettings": { - "description": "Zeigt die IP-Adresse oder URL des Monitors auf der öffentlichen Statusseite an. Wenn diese Option deaktiviert ist, wird zum Schutz vertraulicher Informationen nur der Monitorname angezeigt.", - "label": "IP/URL auf der Statusseite anzeigen", - "selectDisabled": "Inaktiv", - "selectEnabled": "Aktiv", - "title": "Überwachen Sie IP/URL auf der Statusseite" - }, - "globalThresholds": { - "title": "Globale Schwellenwerte", - "description": "Globale CPU-, Speicher-, Festplatten- und Temperaturschwellenwerte festlegen. Wenn ein Wert angegeben ist, wird er automatisch für die Überwachung genutzt." - } - }, - "statusPageCreate": { - "buttonSave": "Speichern" - }, - "incidentsOptionsHeaderFilterResolved": "Gelöst", - "settingsSave": "Speichern", - "statusPageCreateAppearanceTitle": "Aussehen", - "confirmPassword": "Passwort bestätigen", - "monitorHooks": { - "failureAddDemoMonitors": "Demo Monitor konnte nicht hinzugefügt werden", - "successAddDemoMonitors": "Demo Monitor erfolgreich hinzugefügt" - }, - "settingsAppearance": "Aussehen", - "settingsDisplayTimezone": "Zeitzone anzeigen", - "settingsGeneralSettings": "Allgemeine Einstellungen", - "incidentsOptionsHeaderTotalIncidents": "Gesamtanzahl der Vorfälle", - "statusPage": { - "deleteSuccess": "Statusseite erfolgreich gelöscht", - "deleteFailed": "Löschen der Statusseite fehlgeschlagen", - "createSuccess": "Statusseite erfolgreich erstellt", - "updateSuccess": "Statusseite erfolgreich aktualisiert", - "generalSettings": "Allgemeine Einstellungen", - "contents": "Inhalt", - "fallback": { - "checks": [ - "Überwachen und zeigen Sie den Zustand Ihrer Dienste in Echtzeit an.", - "Verfolgen Sie mehrere Dienste und teilen Sie deren Status.", - "Informieren Sie Nutzer über Ausfälle und Leistung." - ], - "title": "Eine Statusseite wird verwendet, um:", - "actionButton": "Lassen Sie uns Ihre erste Statusseite erstellen!" - } - }, - "testNotificationsDisabled": "Für diesen Monitor sind keine Benachrichtigungen eingerichtet. Sie müssen eine hinzufügen, indem Sie auf die Schaltfläche „Konfigurieren“ klicken.", - "incidentsTableResolvedAt": "Gelöst am", - "incidentsTableActionResolve": "Lösen", - "checkHooks": { - "failureResolveOne": "Der Vorfall konnte nicht gelöst werden.", - "failureResolveAll": "Es konnten nicht alle Vorfälle gelöst werden.", - "failureResolveMonitor": "Monitorvorfälle konnten nicht behoben werden." - }, - "checkFormError": "Bitte überprüfen Sie das Formular auf Fehler.", - "diagnosticsPage": { - "diagnosticDescription": "Systemdiagnose", - "statsDescription": "Systemstatistiken", - "gauges": { - "heapAllocationTitle": "Heap-Zuweisung", - "heapAllocationSubtitle": "% des verfügbaren Speichers", - "heapUsageTitle": "Heap-Nutzung", - "heapUsageSubtitle": "% des verfügbaren Speichers", - "heapUtilizationTitle": "Heap-Auslastung", - "heapUtilizationSubtitle": "% der zugewiesenen", - "instantCpuUsageTitle": "Sofortige CPU-Auslastung", - "instantCpuUsageSubtitle": "% der von der CPU genutzten 1s" - }, - "stats": { - "eventLoopDelayTitle": "Ereignisschleifenverzögerung", - "uptimeTitle": "Betriebszeit", - "usedHeapSizeTitle": "Verwendete Heap-Größe", - "totalHeapSizeTitle": "Gesamtgröße des Heapspeichers", - "osMemoryLimitTitle": "Betriebssystem-Speicherlimit" - } - }, - "pageSpeedLighthouseAPI": "Verwenden Sie die Lighthouse PageSpeed API, um Ihre Website zu überwachen", - "time": { - "threeMinutes": "3 Minuten", - "fiveMinutes": "5 Minuten", - "tenMinutes": "10 Minuten", - "twentyMinutes": "20 Minuten", - "oneHour": "1 Stunde", - "oneDay": "1 Tag", - "oneWeek": "1 Woche", - "fourMinutes": "4 Minuten", - "oneMinute": "Eine Minute", - "twoMinutes": "Zwei Minuten", - "fifteenSeconds": "15 Sekunden", - "thirtySeconds": "30 Sekunden" - }, - "general": { - "noOptionsFound": "Keine {{unit}} gefunden" - }, - "infrastructureMonitor": { - "fallback": { - "checks": [ - "Verfolgen Sie die Leistung Ihrer Server", - "Identifizieren Sie Engpässe und optimieren Sie die Nutzung", - "Sorgen Sie für Zuverlässigkeit durch Echtzeitüberwachung" - ], - "title": "Ein Infrastrukturmonitor wird verwendet, um:", - "actionButton": "Lassen Sie uns Ihren ersten Infrastrukturmonitor erstellen!" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [ - "Markieren Sie Ihre Wartungszeiträume.", - "Beseitigen Sie Missverständnisse.", - "Beenden Sie das Senden von Warnmeldungen in Wartungsfenstern." - ], - "title": "Ein Wartungsfenster wird verwendet, um:", - "actionButton": "Lassen Sie uns Ihr erstes Wartungsfenster erstellen!" - } - }, - "pageSpeed": { - "fallback": { - "checks": [ - "Berichten Sie über die Benutzerfreundlichkeit einer Seite.", - "Helfen Sie bei der Analyse der Webseitengeschwindigkeit.", - "Geben Sie Verbesserungsvorschläge für die Seite." - ], - "title": "Ein PageSpeed-Monitor wird verwendet, um:", - "actionButton": "Lassen Sie uns Ihren ersten PageSpeed-Monitor erstellen!" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [ - "Prüfen Sie, ob Websites oder Server online und reaktionsfähig sind.", - "Benachrichtigen Sie Ihre Teams über Ausfallzeiten oder Leistungsprobleme.", - "Überwachen Sie HTTP-Endpunkte, Pings, Container und Ports.", - "Verfolgen Sie historische Verfügbarkeits- und Zuverlässigkeitstrends." - ], - "title": "Ein Uptime-Monitor wird verwendet, um:", - "actionButton": "Lassen Sie uns Ihren ersten Uptime-Monitor erstellen!" - } - }, - "editUserPage": { - "form": { - "email": "E-Mail", - "firstName": "Vorname", - "lastName": "Nachname", - "role": "Rollen", - "save": "Speichern" - }, - "table": { - "actionHeader": "Aktion", - "roleHeader": "Rolle" - }, - "title": "Benutzer bearbeiten", - "toast": { - "successUserUpdate": "Benutzer erfolgreich aktualisiert", - "validationErrors": "Validierungsfehler" - } - }, - "incidentsPageActionResolveMonitor": "Monitor-Vorfälle beheben", - "incidentsPageActionResolveAll": "Alle Vorfälle lösen", - "matchMethodOptions": { - "equal": "Gleich", - "equalPlaceholder": "Erfolg", - "include": "Enthält", - "includePlaceholder": "OK", - "regex": "Regex", - "regexPlaceholder": "^(success|ok)$", - "text": "Übereinstummungsmethode" - }, - "monitorType": { - "docker": { - "label": "Container Name/ID", - "namePlaceholder": "Mein Container", - "placeholder": "Meine-App" - }, - "http": { - "label": "Zu überwachende URL", - "namePlaceholder": "Google", - "placeholder": "google.de" - }, - "ping": { - "label": "Zu überwachende IP-Adresse", - "namePlaceholder": "Google", - "placeholder": "1.1.1.1" - }, - "port": { - "label": "Zu überwachende URL", - "namePlaceholder": "localhost:5173", - "placeholder": "localhost" - }, - "game": { - "label": "Zu überwachende URL", - "namePlaceholder": "localhost:5173", - "placeholder": "localhost" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "JSON Pfad" - }, - "bytesPerSecond": "Bytes pro Sekunde", - "bytesReceived": "Empfangene Bytes", - "bytesSent": "Gesendete Bytes", - "chooseGame": "Spiel auswählen", - "createMonitorPage": { - "incidentConfigDescription": "Mithilfe eines gleitenden Fensters wird ermittelt, wann ein Monitor ausfällt. Der Status eines Monitors ändert sich nur, wenn der Prozentsatz der Prüfungen im gleitenden Fenster den angegebenen Wert erreicht.", - "incidentConfigStatusWindowLabel": "Wie viele Prüfungen sollen im gleitenden Fenster ausgeführt werden?", - "incidentConfigStatusWindowThresholdLabel": "Wie viel Prozent der Prüfungen im gleitenden Fenster sollen fehlschlagen/erfolgreich sein, bevor sich der Status ändert?", - "incidentConfigTitle": "Vorfälle", - "incidentConfigDescriptionV2": "Anzahl der aufeinanderfolgenden Prüfungen, die zum Ändern des Monitorstatus erforderlich sind. Maximal 25", - "incidentConfigStatusCheckNumber": "Anzahl der Prüfungen bevor sich er Status ändert", - "intervalTitle": "Prüfintervall", - "intervalDescription": "Wie oft soll die Überwachung geprüft werden." - }, - "dataRate": "Datenrate", - "dataReceived": "Empfangene Daten", - "dataSent": "Gesendete Daten", - "details": "Details", - "drops": "verloren", - "errors": "Fehler", - "errorsIn": "Fehler eingehend", - "errorsOut": "Fehler ausgehend", - "gameServerMonitoring": "Spiele Server Überwachung", - "gameServerMonitoringDescription": "Prüfen ob der Spiele-Server läuft oder nicht", - "network": "Netzwerk", - "networkDrops": "Netzwerkabbrüche", - "networkErrors": "Netzwerkfehler", - "networkInterface": "Netzwerkschnittstelle", - "noNetworkStatsAvailable": "Keine Netzwerkstatistiken verfügbar.", - "packetsPerSecond": "Pakete pro Sekunde", - "packetsReceived": "Empfangene Pakete", - "packetsReceivedRate": "Rate empfangener Pakete", - "packetsSent": "Gesendete Pakete", - "rate": "Rate", - "selectInterface": "Schnittstelle auswählen", - "v1": { "infrastructure": { - "disk_selection_title": "Festplattenauswahl", - "disk_selection_info": "Momentan wurde keine Festplatte erkannt.", - "disk_selection_description": "Wählen Sie die spezifischen Festplatten aus, die Sie überwachen möchten." + "charts": { + "labels": { + "cpu": "CPU-Auslastung", + "disk": "Festplattenauslastung", + "memory": "Arbeitsspeicherauslastung", + "netBytesRecv": "{{name}} - Empfangene Bytes", + "netBytesSent": "{{name}} - Gesendete Bytes", + "netNoTraffic": "Capture meldet null Datenverkehr auf allen Schnittstellen. Das ist normal, wenn Capture in einem Container läuft, der nur Loopback-Schnittstellen sieht — führe Capture direkt auf dem Host aus, um echten Netzwerkverkehr zu sehen.", + "temp": "Temp." + } + }, + "gauges": { + "cpu": { + "lowerLabel": "Max. Frequenz", + "title": "CPU-Auslastung", + "upperLabel": "Aktuelle Frequenz" + }, + "disk": { + "lowerLabel": "Frei", + "title": "Festplatte {{idx}} Auslastung", + "upperLabel": "Belegt" + }, + "memory": { + "lowerLabel": "Frei", + "title": "Arbeitsspeicherauslastung", + "upperLabel": "Belegt" + } + }, + "statBoxes": { + "avgCpuTemperature": "Durchschn. CPU Termperatur", + "cpuFrequency": "CPU Frequenz", + "cpuLogical": "CPU (logisch)", + "cpuPhysical": "CPU (physisch)", + "disk": "Festplatte", + "memory": "Arbeitsspeicher", + "os": "Betriebssystem" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "Festplatte", + "memory": "Arbeitsspeicher" + } + }, + "tabs": { + "labels": { + "network": "Netzwerk", + "overview": "Übersicht" + } + }, + "fallback": { + "actionButton": "Monitor erstellen!", + "title": "Noch keine Infrastruktur-Monitore", + "description": "Verfolgen Sie die Serverleistung, identifizieren Sie Engpässe und gewährleisten Sie Zuverlässigkeit mit Echtzeit-Infrastrukturüberwachung." + }, + "header": { + "title": "Infrastruktur-Monitore", + "description": "Verfolgen Sie die Serverleistung, identifizieren Sie Engpässe und gewährleisten Sie Zuverlässigkeit mit Echtzeit-Infrastrukturüberwachung." + } + }, + "logs": { + "fallback": { + "title": "Noch keine Protokolle", + "description": "Serveraktivitäten, Warnungen und Fehler werden hier angezeigt, während Checkmate läuft." + }, + "tabs": { + "diagnostics": "Diagnose", + "logs": "Server logs", + "queue": "Warteschlange" + }, + "logLevelSelect": { + "label": "Log-Level" + }, + "jobQueue": "Job-Warteschlange", + "jobQueueExplainer": "Hervorgehobene Zeilen sind Jobs, die derzeit von einem Worker verarbeitet werden.", + "failedJobs": "Fehlgeschlagene Jobs", + "failedJobsExplainer": "Aktuelle Fehler nach Monitor — Zeit des letzten Fehlers, Fehleranzahl und Grund.", + "noLogs": "Keine Protokolle gefunden", + "metrics": { + "jobs": "Jobs", + "activeJobs": "Aktive Jobs", + "failingJobs": "Fehlschlagende Jobs", + "totalRuns": "Ausführungen gesamt", + "totalFailures": "Fehler gesamt" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "Event-Loop-Verzögerung", + "uptime": "Betriebszeit", + "usedHeapSize": "Verwendete Heap-Größe", + "totalHeapSize": "Gesamte Heap-Größe", + "osMemoryLimit": "OS-Speicherlimit" + }, + "gauges": { + "heapAllocation": "Heap-Zuweisung", + "heapUsage": "Heap-Nutzung", + "heapUtilization": "Heap-Auslastung", + "instantCpuUsage": "Momentane CPU-Auslastung", + "availableMemoryPercentage": "% des verfügbaren Speichers", + "allocatedPercentage": "% zugewiesen", + "usedSPercentage": "% von 1s durch CPU genutzt", + "total": "Gesamt", + "used": "Belegt" + }, + "sections": { + "runtimeStats": { + "title": "Laufzeitstatistiken", + "description": "Snapshot des Node.js-Prozesses und Host-Rechners. Wird alle 5 Sekunden aktualisiert." + }, + "memoryCpu": { + "title": "Speicher & CPU", + "description": "Wie der Prozess seinen zugewiesenen Speicher und die Host-CPU nutzt." + } + } + }, + "table": { + "headers": { + "timestamp": "Zeitstempel", + "level": "Stufe", + "service": "Dienst", + "method": "Methode", + "monitorId": "Monitor-ID", + "runCount": "Ausführungen", + "failCount": "Fehlerzahl", + "lastRunAt": "Letzter Lauf am", + "lockedAt": "Gesperrt am", + "lastFinishedAt": "Zuletzt abgeschlossen am", + "lastRunTook": "Letzter Lauf dauerte", + "lastFailedAt": "Letzter Fehler am", + "failReason": "Fehlergrund" + } + }, + "queueFallback": { + "title": "Keine Aufträge in der Warteschlange", + "description": "Geplante oder laufende Monitorprüfungen erscheinen hier. Eine leere Warteschlange bedeutet, dass alles aktuell ist." + }, + "header": { + "title": "Protokolle & Warteschlange", + "description": "Serverprotokolle, Monitor-Warteschlange und Laufzeitdiagnosen an einem Ort." + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "Wartungsfenster erstellen!", + "title": "Keine Wartungsfenster geplant", + "description": "Planen Sie Wartungsausfälle, damit Warnungen unterdrückt werden und Team und Nutzer wissen, was sie erwartet." + }, + "table": { + "headers": { + "nextWindow": "Nächstes Fenster", + "repeat": "Wiederholen" + } + }, + "form": { + "general": { + "title": "Allgemeine Einstellungen", + "description": "Legen Sie einen Namen und eine Wiederholungsoption für Ihr Wartungsfenster fest.", + "option": { + "name": { + "label": "Name", + "placeholder": "z. B. Wöchentliche Wartung" + }, + "repeat": { + "label": "Wiederholen" + } + } + }, + "startDate": { + "title": "Startdatum", + "description": "Wählen Sie das Startdatum für Ihr Wartungsfenster.", + "option": { + "startDate": { + "label": "Startdatum" + } + } + }, + "startTime": { + "title": "Startzeit", + "description": "Legen Sie die Startzeit und Dauer für Ihr Wartungsfenster fest. Alle Werte sind in UTC", + "option": { + "duration": { + "label": "Dauer" + }, + "startTime": { + "label": "Startzeit" + } + }, + "monitors": { + "title": "Monitore", + "description": "Monitore, für die das Wartungsfenster gelten soll", + "option": { + "addMonitors": { + "label": "Monitore hinzufügen" + } + } + } + } + }, + "header": { + "title": "Wartungsfenster", + "description": "Planen Sie Wartungsausfälle, damit Warnungen unterdrückt werden und Team und Nutzer wissen, was sie erwartet." + } + }, + "notifications": { + "fallback": { + "actionButton": "Kanal erstellen", + "title": "Noch keine Benachrichtigungskanäle", + "description": "Verbinden Sie Slack, Discord, E-Mail, Webhooks und mehr, damit die richtigen Personen sofort von Vorfällen erfahren." + }, + "form": { + "accessToken": { + "optionAccessToken": "Zugriffstoken", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "Die Adresse, an die Benachrichtigungen gesendet werden.", + "optionAddress": "Adresse", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "Adresse" + }, + "homeServer": { + "optionHomeServer": "Home-Server", + "placeholder": "example.com" + }, + "matrix": { + "description": "Konfigurieren Sie Ihre Matrix-Homeserver-Verbindung für Benachrichtigungen.", + "title": "Matrix-Konfiguration" + }, + "notificationName": { + "description": "Ein beschreibender Name für den Benachrichtigungskanal", + "optionName": "Kanalname", + "placeholder": "z. B. Produktionsalarme", + "title": "Kanalname" + }, + "pagerDuty": { + "description": "Ihr PagerDuty-Integrationsschlüssel für den Empfang von Alarmen.", + "optionIntegrationKey": "Integrationsschlüssel", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "Integrationsschlüssel" + }, + "roomId": { + "optionRoomId": "Raum-ID", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "Wählen Sie den Typ des zu erstellenden Benachrichtigungskanals.", + "optionType": "Typ", + "title": "Kanaltyp" + }, + "telegram": { + "title": "Telegram-Konfiguration", + "description": "Um Telegram-Benachrichtigungen zu aktivieren, erstelle einen Telegram-Bot mit BotFather, einem offiziellen Bot zum Erstellen und Verwalten von Telegram-Bots. Dann hole dir das API-Token und die Chat-ID und notiere sie hier.", + "optionBotToken": "Bot-Token", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "Chat-ID", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Pushover-Konfiguration", + "description": "Geben Sie Ihr Anwendungstoken und Ihren Benutzerschlüssel von Pushover ein.", + "optionAppToken": "Anwendungstoken", + "placeholderAppToken": "Anwendungstoken eingeben", + "optionUserKey": "Benutzerschlüssel", + "placeholderUserKey": "Benutzerschlüssel eingeben" + }, + "twilio": { + "title": "Twilio-Konfiguration", + "description": "Geben Sie Ihre Twilio-Kontoanmeldeinformationen ein, um SMS zu senden.", + "optionAccountSid": "Account-SID", + "placeholderAccountSid": "Account-SID eingeben", + "optionAuthToken": "Auth-Token", + "placeholderAuthToken": "Auth-Token eingeben", + "optionFromNumber": "Absendernummer", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "Empfängernummer", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "Ziel" + } + }, + "header": { + "title": "Benachrichtigungen", + "description": "Verbinden Sie Slack, Discord, E-Mail, Webhooks und mehr, damit die richtigen Personen sofort von Vorfällen erfahren." + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "Cumulative Layout Shift (CLS)", + "fcp": "First Contentful Paint (FCP)", + "lcp": "Largest Contentful Paint (LCP)", + "si": "Speed Index (SI)", + "tbt": "Total Blocking Time (TBT)" + }, + "legend": { + "title": "PageSpeed-Bericht", + "weight": "Gewichtung" + }, + "pie": { + "title": "Leistungsbericht" + } + }, + "table": { + "headers": { + "pageSpeedScore": "PageSpeed-Wert" + } + }, + "fallback": { + "actionButton": "Monitor erstellen!", + "title": "Noch keine PageSpeed-Monitore", + "description": "Verfolgen Sie Core Web Vitals und Lighthouse-Werte im Zeitverlauf, um Regressionen zu erkennen, bevor Nutzer es tun." + }, + "header": { + "title": "PageSpeed-Monitore", + "description": "Verfolgen Sie Core Web Vitals und Lighthouse-Werte im Zeitverlauf, um Regressionen zu erkennen, bevor Nutzer es tun." + } + }, + "settings": { + "form": { + "timezone": { + "title": "Anzeigesprache Zeitzone", + "description": "Wählen Sie die Zeitzone, die für die Anzeige von Datum und Uhrzeit in der Anwendung verwendet wird.", + "option": { + "timezone": { + "label": "Anzeige-Zeitzone" + } + } + }, + "ui": { + "title": "Erscheinungsbild", + "description": "Wechseln Sie zwischen hellem und dunklem Modus, ändern Sie die Sprache oder passen Sie den Diagrammtyp an.", + "option": { + "theme": { + "label": "Theme-Modus", + "light": "Hell", + "dark": "Dunkel" + }, + "language": { + "label": "Sprache" + }, + "chartType": { + "label": "Diagrammtyp", + "histogram": "Histogramm", + "heatmap": "Heatmap" + } + } + }, + "pagespeed": { + "title": "Google PageSpeed API-Schlüssel", + "description": "Geben Sie Ihren Google PageSpeed API-Schlüssel ein, um die Google PageSpeed-Überwachung zu aktivieren. Klicken Sie auf Zurücksetzen, um den Schlüssel zu aktualisieren.", + "option": { + "apiKey": { + "label": "PageSpeed API-Schlüssel", + "labelSet": "API-Schlüssel ist gesetzt. Klicken Sie auf Zurücksetzen, um ihn zu ändern.", + "placeholder": "Geben Sie Ihren Google PageSpeed API-Schlüssel ein" + } + } + }, + "url": { + "title": "Monitor-IP/URL auf Statusseite", + "description": "Zeigt die IP-Adresse oder URL des Monitors auf der öffentlichen Statusseite an. Wenn deaktiviert, wird nur der Monitor-Name angezeigt, um sensible Informationen zu schützen.", + "option": { + "showURL": { + "label": "IP/URL auf Statusseite anzeigen", + "enabled": "Aktiviert", + "disabled": "Deaktiviert" + } + } + }, + "stats": { + "title": "Monitor-Verlauf", + "description": "Löschen Sie alle Überwachungsverläufe und Statistiken für Ihr Team. Diese Aktion ist unwiderruflich.", + "option": { + "clear": { + "label": "Alle Statistiken löschen. Dies ist unwiderruflich." + } + }, + "dialog": { + "title": "Alle Überwachungsverläufe löschen?", + "description": "Dies kann nicht rückgängig gemacht werden" + } + }, + "retention": { + "title": "Check-Aufbewahrung", + "description": "Legen Sie fest, wie lange Check-Daten aufbewahrt werden, bevor sie automatisch bereinigt werden.", + "option": { + "days": { + "label": "Aufbewahrungsdauer (Tage)", + "unlimited": "Unbegrenzt" + } + } + }, + "thresholds": { + "title": "Globale Schwellenwerte", + "description": "Legen Sie globale CPU-, Arbeitsspeicher-, Festplatten- und Temperaturschwellenwerte für die Infrastrukturüberwachung fest. Diese gelten für alle Hardware-Monitore, sofern nicht überschrieben.", + "option": { + "cpu": { + "label": "CPU-Schwellenwert (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "Arbeitsspeicher-Schwellenwert (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "Festplatten-Schwellenwert (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "Temperatur-Schwellenwert (°C)", + "placeholder": "1 - 150" + } + } + }, + "email": { + "title": "E-Mail-Einstellungen", + "description": "Konfigurieren Sie die E-Mail-Einstellungen für Ihr System. Dies wird zum Senden von Benachrichtigungen und Alarmen verwendet.", + "descriptionTransport": "Dies erstellt einen SMTP-Transport für NodeMailer", + "descriptionTransportLink": "Spezifikationen hier ansehen", + "option": { + "host": { + "label": "E-Mail-Host - Hostname oder IP-Adresse für die Verbindung", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "E-Mail-Port - Port für die Verbindung", + "placeholder": "587" + }, + "address": { + "label": "E-Mail-Adresse - Für die Authentifizierung verwendet", + "placeholder": "you@example.com" + }, + "user": { + "label": "E-Mail-Benutzer - Benutzername für die Authentifizierung, überschreibt E-Mail-Adresse wenn angegeben", + "placeholder": "Leer lassen, wenn nicht erforderlich" + }, + "password": { + "label": "E-Mail-Passwort - Passwort für die Authentifizierung", + "labelSet": "Passwort ist gesetzt. Klicken Sie auf Zurücksetzen, um es zu ändern.", + "placeholder": "Geben Sie Ihr Passwort ein" + }, + "tlsServername": { + "label": "TLS-Servername - Optionaler Hostname für TLS-Validierung wenn Host eine IP ist", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "E-Mail-Verbindungshost - Hostname für die HELO/EHLO-Begrüßung", + "placeholder": "localhost" + }, + "secure": { + "label": "SSL verwenden (empfohlen): Verbindung mit SSL/TLS verschlüsseln" + }, + "pool": { + "label": "Verbindungspooling aktivieren: Bestehende Verbindungen zur Leistungsverbesserung wiederverwenden" + }, + "ignoreTLS": { + "label": "STARTTLS deaktivieren: TLS nicht verwenden, auch wenn der Server es unterstützt" + }, + "requireTLS": { + "label": "STARTTLS erzwingen: TLS-Upgrade erforderlich, Fehler wenn nicht unterstützt" + }, + "rejectUnauthorized": { + "label": "Ungültige Zertifikate ablehnen: Verbindungen mit selbstsignierten oder nicht vertrauenswürdigen Zertifikaten ablehnen" + } + } + }, + "demoMonitors": { + "title": "Demo-Monitore", + "description": "Beispiel-Monitore zu Demonstrationszwecken hinzufügen." + }, + "removeMonitors": { + "title": "System zurücksetzen", + "description": "Alle Monitore aus dem System entfernen.", + "dialog": { + "title": "Alle Monitore entfernen?", + "description": "Diese Aktion kann nicht rückgängig gemacht werden.", + "paragraph": "Dabei werden auch der gesamte Prüfverlauf, alle Vorfälle, geplante Wartungsfenster sowie Statusseiten entfernt, die diese Monitore verwenden. Exportiere die Monitore zuerst, wenn du eine Sicherung der Daten behalten möchtest." + } + }, + "exportMonitors": { + "title": "Monitore exportieren" + }, + "importExportMonitors": { + "title": "Monitore importieren / exportieren", + "description": "Importieren oder exportieren Sie Ihre Monitor-Daten als JSON-Datei zur Sicherung oder Übertragung." + }, + "about": { + "title": "Über", + "developedBy": "Entwickelt von Bluewave Labs" + }, + "validation": { + "errorMessage": "Bitte beheben Sie die folgenden Validierungsfehler:" + } + }, + "header": { + "title": "Einstellungen", + "description": "Konfigurieren Sie SMTP, Integrationen, Aufbewahrung und globales App-Verhalten." + } + }, + "statusPages": { + "deleteSuccess": "Statusseite erfolgreich gelöscht", + "fallback": { + "title": "Noch keine Statusseiten", + "actionButton": "Statusseite erstellen!", + "description": "Veröffentlichen Sie eine öffentliche Seite, die Kunden und Stakeholdern Echtzeit-Verfügbarkeit und Vorfallsverlauf zeigt." + }, + "monitorsList": { + "chartTypeHeatmap": "Heatmap", + "chartTypeHistogram": "Histogramm", + "noData": "Keine Daten verfügbar", + "infrastructure": { + "title": "Infrastruktur", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "Arbeitsspeicher", + "disk": "Festplatte", + "usage": "Auslastung", + "used": "Belegt", + "total": "Gesamt" + }, + "uptime": { + "title": "Verfügbarkeit", + "responseTime": "Antwortzeit" + } + }, + "statusBar": { + "allDown": "Alle Systeme sind ausgefallen", + "allUp": "Alle Systeme funktionsfähig", + "degraded": "Einige Systeme haben Probleme", + "unknown": "Systemstatus kann nicht ermittelt werden", + "allBreached": "Alle Systeme haben Schwellenwerte überschritten", + "allMaintenance": "Alle Systeme befinden sich in Wartung", + "allPaused": "Alle Systeme sind pausiert", + "allInitializing": "Alle Systeme werden initialisiert", + "breachedAndDown": "Einige Systeme haben Schwellenwerte überschritten und sind ausgefallen", + "breached": "Einige Systeme haben Schwellenwerte überschritten", + "maintenanceAndDown": "Einige Systeme sind während der Wartung ausgefallen", + "maintenance": "Einige Systeme befinden sich in Wartung", + "partiallyPaused": "Einige Systeme sind pausiert", + "initializing": "Einige Systeme werden initialisiert", + "noMonitors": "Es wurden keine Monitore konfiguriert" + }, + "table": { + "headers": { + "name": "Name der Statusseite", + "url": "Öffentliche URL" + }, + "published": "Veröffentlicht", + "unpublished": "Unveröffentlicht" + }, + "title": "Statusseiten", + "details": { + "empty": { + "title": "Hier gibt es noch nichts", + "addMonitor": "Fügen Sie einen Monitor hinzu, um zu beginnen" + } + }, + "form": { + "access": { + "title": "Zugriff", + "description": "Wenn Ihre Statusseite bereit ist, können Sie sie als veröffentlicht markieren.", + "option": { + "published": { + "name": "Veröffentlicht und öffentlich sichtbar" + } + } + }, + "basicInfo": { + "title": "Grundinformationen", + "description": "Definieren Sie den Firmennamen und die Subdomain, auf die Ihre Statusseite verweist.", + "option": { + "name": { + "label": "Firmenname", + "placeholder": "Muster GmbH" + }, + "url": { + "label": "Ihre Statusseiten-Adresse", + "placeholder": "my-status-page" + } + } + }, + "monitors": { + "title": "Monitore", + "description": "Wählen Sie die Monitore aus, die auf Ihrer Statusseite angezeigt werden sollen.", + "noMonitors": "Keine Monitore ausgewählt", + "option": { + "monitors": { + "label": "Monitore auswählen", + "placeholder": "Monitore suchen und auswählen..." + } + } + }, + "timezone": { + "title": "Zeitzone", + "description": "Wählen Sie die Zeitzone, in der Ihre Statusseite angezeigt wird.", + "option": { + "timezone": { + "label": "Zeitzone", + "placeholder": "Zeitzone auswählen..." + } + } + }, + "appearance": { + "title": "Erscheinungsbild", + "description": "Definieren Sie das Standardaussehen Ihrer öffentlichen Statusseite.", + "option": { + "logo": { + "label": "Logo" + }, + "color": { + "label": "Markenfarbe" + } + } + }, + "features": { + "title": "Funktionen", + "description": "Konfigurieren Sie, welche Informationen auf Ihrer Statusseite angezeigt werden.", + "option": { + "showCharts": { + "label": "Antwortzeit-Diagramme anzeigen" + }, + "showUptimePercentage": { + "label": "Verfügbarkeitsprozentsatz anzeigen" + }, + "showAdminLoginLink": { + "label": "Admin-Login-Link anzeigen" + }, + "showInfrastructure": { + "label": "Infrastruktur-Metriken anzeigen" + } + } + } + }, + "header": { + "title": "Statusseiten", + "description": "Veröffentlichen Sie öffentliche Seiten, die Kunden und Stakeholdern Echtzeit-Verfügbarkeit und Vorfallsverlauf zeigen." + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "Monitore suchen..." + } + }, + "table": { + "headers": { + "responseTime": "Antwortzeit" + } + }, + "fallback": { + "actionButton": "Monitor erstellen!", + "title": "Noch keine Uptime-Monitore", + "description": "Überwachen Sie HTTP-Endpunkte, Pings, Container und Ports und werden Sie sofort benachrichtigt, wenn etwas ausfällt." + }, + "header": { + "title": "Uptime-Monitore", + "description": "Überwachen Sie HTTP-Endpunkte, Pings, Container und Ports und werden Sie sofort benachrichtigt, wenn etwas ausfällt." + } } } } diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 013858adc..b964eec84 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" @@ -70,7 +81,9 @@ }, "labels": { "active": "Active", - "paused": "paused", + "running": "Running", + "paused": "Paused", + "idle": "Idle", "na": "N/A", "resolved": "Resolved", "responseTime": "Response time" @@ -105,7 +118,7 @@ } }, "table": { - "empty": "Nothing here", + "empty": "Nothing to show yet", "headers": { "actions": "Actions", "dateTime": "Date & time", @@ -123,6 +136,14 @@ } }, "components": { + "pageHeader": { + "links": { + "support": "Support", + "discussions": "Discussions", + "docs": "Documentation", + "changelog": "Changelog" + } + }, "imageUpload": { "clickToUpload": "Click to upload", "dragAndDrop": "or drag and drop", @@ -249,6 +270,10 @@ } }, "team": { + "fallback": { + "title": "No team members yet", + "description": "Invite teammates by email or add them directly. Once they join, they'll appear here with their role." + }, "filter": { "placeholder": "Filter by role", "all": "All roles", @@ -279,9 +304,18 @@ }, "linkLabel": "Invite link" } + }, + "header": { + "title": "Account", + "description": "Manage your profile, password, team, and workspace preferences." } }, "auth": { + "brandPanel": { + "eyebrow": "Open-source infrastructure monitoring", + "tagline": "Watch every move your servers make.", + "description": "Real-time uptime, response time and incident tracking - self-hosted and yours to keep." + }, "common": { "passwordRules": { "length": { @@ -343,6 +377,7 @@ "register": { "title": "Welcome back to Checkmate!", "subtitle": "Sign up to get started", + "setupNotice": "This page is only available during initial setup. Once a super admin is registered, additional users can be invited from team settings.", "submit": "Register" }, "forgotPassword": { @@ -362,6 +397,10 @@ } }, "checks": { + "fallback": { + "title": "No checks yet", + "description": "Once you create a monitor, every status probe and response time will appear here as a check." + }, "selects": { "monitor": { "all": "All monitors" @@ -378,6 +417,10 @@ "statusCode": "Status code", "location": "Location" } + }, + "header": { + "title": "Checks", + "description": "Every probe Checkmate runs against your monitors lands here, with status code and response time." } }, "common": { @@ -391,13 +434,28 @@ "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" }, "statBoxes": { "activeFor": "Active for", "certificateExpiry": "Certificate expiry", "lastCheck": "Last check", - "lastResponseTime": "Last response time" + "lastResponseTime": "Last response time", + "serviceIsDown": "Service is down" }, "status": { "down": "down", @@ -417,7 +475,8 @@ "optionPagespeed": "PageSpeed", "optionHardware": "Infrastructure", "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket" + "optionWebSocket": "WebSocket", + "optionDns": "DNS" } } }, @@ -508,6 +567,25 @@ "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", @@ -516,11 +594,12 @@ "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", - "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", + "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the supported games list for protocol details.", "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": { @@ -557,6 +636,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": { @@ -645,6 +725,10 @@ } }, "incidents": { + "fallback": { + "title": "No incidents — all systems healthy", + "description": "When a monitor goes down or recovers, the incident will appear here with timing, monitor details, and resolution history." + }, "dialog": { "resolveIncident": { "title": "Resolve incident", @@ -725,6 +809,10 @@ "active": "Active", "resolved": "Resolved" } + }, + "header": { + "title": "Incidents", + "description": "When monitors go down or recover, incidents appear here with timing, monitor details and resolution history." } }, "infrastructure": { @@ -735,6 +823,7 @@ "memory": "Memory usage", "netBytesRecv": "{{name}} - Bytes Received", "netBytesSent": "{{name}} - Bytes Sent", + "netNoTraffic": "Capture is reporting zero traffic on every interface. This is normal when Capture runs inside a container that can only see loopback interfaces — run Capture directly on the host to see real network activity.", "temp": "Temp" } }, @@ -778,16 +867,24 @@ } }, "fallback": { - "actionButton": "Create a monitor!", - "checks": [ - "Track the performance of your servers", - "Identify bottlenecks and optimize usage", - "Ensure reliability with real-time monitoring" - ], - "title": "An infrastructure monitor is used to:" + "actionButton": "Create a monitor", + "description": "Track server performance, identify bottlenecks, and ensure reliability with real-time infrastructure monitoring.", + "title": "No infrastructure monitors yet" + }, + "header": { + "title": "Infrastructure monitors", + "description": "Track server performance, identify bottlenecks, and ensure reliability with real-time infrastructure monitoring." } }, "logs": { + "fallback": { + "title": "No logs yet", + "description": "Server activity, warnings, and errors will appear here as Checkmate runs." + }, + "queueFallback": { + "title": "No jobs in the queue", + "description": "Monitor checks that are scheduled or running will appear here. An empty queue means everything is up to date." + }, "tabs": { "diagnostics": "Diagnostics", "logs": "Logs", @@ -797,7 +894,9 @@ "label": "Log level" }, "jobQueue": "Job queue", + "jobQueueExplainer": "Highlighted rows are jobs currently being processed by a worker.", "failedJobs": "Failed jobs", + "failedJobsExplainer": "Recent failures by monitor — last failure time, fail count, and the reason.", "noLogs": "No logs found", "metrics": { "jobs": "Jobs", @@ -807,6 +906,16 @@ "totalFailures": "Total failures" }, "diagnostics": { + "sections": { + "runtimeStats": { + "title": "Runtime stats", + "description": "Snapshot of the Node.js process and host machine. Updates every 5 seconds." + }, + "memoryCpu": { + "title": "Memory & CPU", + "description": "How the process is using its allocated memory and host CPU." + } + }, "stats": { "eventLoopDelay": "Event loop delay", "uptime": "Uptime", @@ -815,7 +924,7 @@ "osMemoryLimit": "OS memory limit" }, "gauges": { - "heapAllocation": "Heap allocaton", + "heapAllocation": "Heap allocation", "heapUsage": "Heap usage", "heapUtilization": "Heap utilization", "instantCpuUsage": "Instant CPU usage", @@ -828,31 +937,32 @@ }, "table": { "headers": { + "state": "State", "timestamp": "Timestamp", "level": "Level", "service": "Service", "method": "Method", "monitorId": "Monitor ID", "runCount": "Run count", - "failCount": "Fail count", + "failCount": "Count", "lastRunAt": "Last run at", "lockedAt": "Locked at", "lastFinishedAt": "Last finished at", "lastRunTook": "Last run took", - "lastFailedAt": "Last failed at", + "lastFailedAt": "Last failed", "failReason": "Fail reason" } + }, + "header": { + "title": "Logs & queue", + "description": "Inspect server logs, the monitor job queue, and runtime diagnostics in one place." } }, "maintenanceWindow": { "fallback": { - "actionButton": "Create a maintenance window!", - "checks": [ - "Mark your maintenance periods", - "Eliminate any misunderstandings", - "Stop sending alerts in maintenance windows" - ], - "title": "A maintenance window is used to:" + "actionButton": "Create a maintenance window", + "description": "Schedule planned downtime so alerts are suppressed and your team and users know what to expect.", + "title": "No maintenance windows scheduled" }, "table": { "headers": { @@ -904,17 +1014,17 @@ } } } + }, + "header": { + "title": "Maintenance windows", + "description": "Schedule planned downtime so alerts are suppressed and your team and users know what to expect." } }, "notifications": { "fallback": { "actionButton": "Create a channel", - "checks": [ - "Alert teams about downtime or performance issues", - "Let engineers know when incidents happen", - "Keep administrators informed of system changes" - ], - "title": "Notification channles are used to:" + "description": "Connect Slack, Discord, email, webhooks and more so the right people hear about incidents the moment they happen.", + "title": "No notification channels yet" }, "form": { "accessToken": { @@ -964,12 +1074,36 @@ "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", "optionChatId": "Chat ID", "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Pushover configuration", + "description": "Configure Pushover to receive push notifications on your devices.", + "optionAppToken": "Application API token", + "placeholderAppToken": "azGDORePK8gMaC0QOYAMyEEuzJnyUi", + "optionUserKey": "User key", + "placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG" + }, + "twilio": { + "title": "Twilio SMS configuration", + "description": "Configure Twilio to send SMS notifications to a phone number.", + "optionAccountSid": "Account SID", + "placeholderAccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "optionAuthToken": "Auth token", + "placeholderAuthToken": "your_auth_token", + "optionFromNumber": "From number (Twilio)", + "placeholderFromNumber": "+15551234567", + "optionToNumber": "To number (recipient)", + "placeholderToNumber": "+15559876543" } }, "table": { "headers": { "destination": "Destination" } + }, + "header": { + "title": "Notifications", + "description": "Connect Slack, Discord, email, webhooks and more so the right people hear about incidents the moment they happen." } }, "pageSpeed": { @@ -999,13 +1133,13 @@ } }, "fallback": { - "actionButton": "Create a monitor!", - "checks": [ - "Report on the user experience of a page", - "Help analyze webpage speed", - "Give suggestions on how the page can be improved" - ], - "title": "A PageSpeed monitor is used to:" + "actionButton": "Create a monitor", + "description": "Track Core Web Vitals and Lighthouse scores over time so you can spot regressions before users do.", + "title": "No PageSpeed monitors yet" + }, + "header": { + "title": "PageSpeed monitors", + "description": "Track Core Web Vitals and Lighthouse scores over time so you can spot regressions before users do." } }, "settings": { @@ -1123,6 +1257,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" @@ -1166,7 +1304,8 @@ "description": "Remove all monitors from your system.", "dialog": { "title": "Remove all monitors?", - "description": "This cannot be undone." + "description": "This cannot be undone.", + "paragraph": "All check history, incidents, scheduled maintenance windows, and any status pages that referenced these monitors will be removed too. Export your monitors first to keep a backup of their data." } }, "exportMonitors": { @@ -1183,24 +1322,37 @@ "validation": { "errorMessage": "Please fix the following validation errors:" } + }, + "header": { + "title": "Settings", + "description": "Configure SMTP, integrations, retention, and global app behavior." } }, "statusPages": { "deleteSuccess": "Status page deleted successfully", "fallback": { - "title": "A status page is used to:", - "checks": [ - "Communicate system status to users and stakeholders", - "Display real-time uptime information publicly", - "Build trust with transparent service monitoring", - "Reduce support requests during incidents" - ], - "actionButton": "Create a status page!" + "title": "No status pages yet", + "description": "Publish a public page that shows real-time uptime and incident history to your customers and stakeholders.", + "actionButton": "Create a status page" }, "monitorsList": { "chartTypeHeatmap": "Heatmap", "chartTypeHistogram": "Histogram", "noData": "No data available", + "status": { + "up": "Operational", + "down": "Down", + "breached": "Threshold exceeded", + "maintenance": "Maintenance", + "paused": "Paused", + "initializing": "Initializing" + }, + "chart": { + "avg": "avg {{value}}ms", + "max": "max {{value}}ms", + "downTooltip": "Down", + "heatmapAria": "Response time heatmap" + }, "infrastructure": { "title": "Infrastructure", "cpu": "CPU", @@ -1217,9 +1369,22 @@ } }, "statusBar": { - "allDown": "All systems are down", + "monitoringSummary": "Monitoring {{count}} service · Updates every 10 seconds", + "monitoringSummary_other": "Monitoring {{count}} services · Updates every 10 seconds", "allUp": "All systems operational", + "allBreached": "All systems have exceeded thresholds", + "allMaintenance": "All systems are under maintenance", + "allDown": "All systems are down", + "allPaused": "All systems are paused", + "allInitializing": "All systems are initializing", + "breachedAndDown": "Some systems have exceeded thresholds and are down", + "breached": "Some systems have exceeded thresholds", "degraded": "Some systems are experiencing issues", + "maintenanceAndDown": "Some systems are down during maintenance", + "maintenance": "Some systems are under maintenance", + "partiallyPaused": "Some systems are paused", + "initializing": "Some systems are initializing", + "noMonitors": "No monitors have been configured", "unknown": "Unable to determine system status" }, "table": { @@ -1237,6 +1402,13 @@ "addMonitor": "Add a monitor to get started" } }, + "footer": { + "poweredBy": "Powered by" + }, + "editorial": { + "reportTitle": "Service Report", + "allUp": "All systems are operating normally." + }, "form": { "access": { "title": "Access", @@ -1294,6 +1466,35 @@ } } }, + "theme": { + "title": "Page theme", + "description": "Choose the visual style for your public status page.", + "label": "Theme", + "options": { + "refined": { + "name": "Refined", + "description": "Clean, professional, close to the classic Checkmate look." + }, + "modern": { + "name": "Modern", + "description": "SaaS-grade with a pulsing status dot and softly fading cards." + }, + "bold": { + "name": "Bold", + "description": "Large type and confident spacing. Great for brand-forward pages." + }, + "editorial": { + "name": "Editorial", + "description": "Serif headings and hairline dividers. Distinctive and calm." + } + } + }, + "themeMode": { + "label": "Theme mode", + "auto": "Auto (follow visitor's device)", + "light": "Light only", + "dark": "Dark only" + }, "features": { "title": "Features", "description": "Configure what information is displayed on your status page.", @@ -1312,6 +1513,10 @@ } } } + }, + "header": { + "title": "Status pages", + "description": "Publish public pages that show real-time uptime and incident history to your customers and stakeholders." } }, "uptime": { @@ -1326,14 +1531,13 @@ } }, "fallback": { - "actionButton": "Create a monitor!", - "checks": [ - "Check if websites or servers are online & responsive", - "Alert teams about downtime or performance issues", - "Monitor HTTP endpoints, pings, containers & ports", - "Track historical uptime and reliability trends" - ], - "title": "An uptime monitor is used to:" + "actionButton": "Create a monitor", + "description": "Watch HTTP endpoints, pings, containers, and ports — and get alerted the moment something goes down.", + "title": "No uptime monitors yet" + }, + "header": { + "title": "Uptime monitors", + "description": "Watch HTTP endpoints, pings, containers, and ports — and get alerted the moment something goes down." } } } diff --git a/client/src/locales/es.json b/client/src/locales/es.json index ed6a98c5d..be2f6531c 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -1,1108 +1,1461 @@ { - "submit": "Enviar", - "title": "Titulo", - "distributedStatusHeaderText": "Cobertura en tiempo real, para dispositivos reales", - "distributedStatusSubHeaderText": "Potenciado por millones de dispositivos alrededor del mundo, vea el rendimiento del sistema por región global, país o ciudad", - "settingsDisabled": "Deshabilitado", - "settingsSuccessSaved": "Ajustes guardados correctamente", - "settingsFailedToSave": "Fallo al guardar los ajustes", - "settingsStatsCleared": "Estadísticas eliminadas correctamente", - "settingsFailedToClearStats": "Fallo al eliminar las estadísticas", - "settingsMonitorsDeleted": "Todos los monitores eliminados correctamente", - "settingsFailedToDeleteMonitors": "Fallo al eliminar todos los monitores", - "starPromptTitle": "", - "starPromptDescription": "Vea las últimas versiones y ayude a incrementar la comunidad en Github", - "https": "HTTPS", - "http": "HTTP", - "monitor": "monitor", - "aboutus": "Acerca de", - "now": "Ahora", - "delete": "Eliminar", - "configure": "Configurar", - "responseTime": "Tiempo de Respuesta", - "ms": "ms", - "bar": "Barra", - "area": "Área", - "country": "PAIS", - "city": "CIUDAD", - "response": "RESPUESTA", - "monitorStatusUp": "El monitor {name} ({url}) está ahora activo y respondiendo", - "monitorStatusDown": "El monitor {name} ({url}) está inactivo y no está respondiendo", - "webhookSendSuccess": "Notificación webhook enviada correctamente", - "webhookSendError": "Error al enviar notificación webhook a {platform}", - "webhookUnsupportedPlatform": "Plataforma no soportada: {platform}", - "distributedRightCategoryTitle": "Monitor", - "distributedStatusServerMonitors": "Monitores de Servidores", - "distributedStatusServerMonitorsDescription": "Monitoree el estado de los servidores relacionados", - "distributedUptimeCreateSelectURL": "Aquí puede seleccionar la URL del anfitrión, junto con el tipo de monitor.", - "distributedUptimeCreateChecks": "Verificaciones a realizar", - "distributedUptimeCreateChecksDescription": "Usted siempre puede agregar o eliminar verificaciones luego de añadir su sitio.", - "distributedUptimeCreateIncidentNotification": "Notificaciones de incidentes", - "distributedUptimeCreateIncidentDescription": "Cuando ocurra un incidente, notificar a usuarios.", - "distributedUptimeCreateAdvancedSettings": "Ajustes avanzados", - "distributedUptimeDetailsNoMonitorHistory": "No hay historial de verificaciones para este monitor aún.", - "distributedUptimeDetailsStatusHeaderUptime": "Tiempo de funcionamiento:", - "distributedUptimeDetailsStatusHeaderLastUpdate": "Actualizado por última vez", - "notifications": { - "enableNotifications": "Habilitar notificaciones para {{platform}}", - "testNotification": "Notificación de prueba", - "addOrEditNotifications": "Agregar o eliminar notificaciones", - "slack": { - "label": "Slack", - "description": "Para habilitar notificaciones de Slack, cree una aplicación de Slack y habilite los webhooks entrantes. Luego de eso, simplemente introduzca la URL del webhook aquí.", - "webhookLabel": "URL del webhook", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "discord": { - "label": "Discord", - "description": "Para enviar datos a un canal de Discord desde Checkmate a través de notificaciones de Discord utilizando webhooks, puede utilizar la característica de webhooks entrantes de Discord.", - "webhookLabel": "URL de Webhooks de Discord", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "telegram": { - "label": "Telegram", - "description": "Para habilitar las notificaciones de Telegram, cree un bot de Telegram utilizando BotFather, un bot oficial para crear y administrar bots de Telegram. Entonces, obtenga una clave API y un ID de chat y escríbalos aquí.", - "tokenLabel": "La clave de su bot", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "El ID de su Chat", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "" - }, - "webhook": { - "label": "Webhooks", - "description": "Puede establecer un webhook personalizado para recibir notificaciones cuando ocurra un incidente.", - "urlLabel": "URL del webhook", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "" - }, - "testNotificationDevelop": "Notificación de prueba 2", - "integrationButton": "", - "testSuccess": "", - "testFailed": "", - "unsupportedType": "", - "networkError": "", - "fallback": { - "title": "", - "checks": [""], - "actionButton": "" - }, - "createButton": "", - "createTitle": "", - "create": { - "success": "", - "failed": "" - }, - "fetch": { - "success": "", - "failed": "" - }, - "delete": { - "success": "", - "failed": "" - }, - "edit": { - "success": "", - "failed": "" - }, - "test": { - "success": "", - "failed": "" - } - }, - "testLocale": "testLocale", - "add": "Agregar", - "monitors": "monitores", - "distributedUptimeStatusCreateStatusPage": "página de estado", - "distributedUptimeStatusCreateStatusPageAccess": "", - "distributedUptimeStatusCreateStatusPageReady": "", - "distributedUptimeStatusBasicInfoHeader": "", - "distributedUptimeStatusBasicInfoDescription": "", - "distributedUptimeStatusLogoHeader": "", - "distributedUptimeStatusLogoDescription": "", - "distributedUptimeStatusLogoUploadButton": "", - "distributedUptimeStatusStandardMonitorsHeader": "", - "distributedUptimeStatusStandardMonitorsDescription": "", - "distributedUptimeStatusCreateYour": "", - "distributedUptimeStatusEditYour": "", - "distributedUptimeStatusPublishedLabel": "", - "distributedUptimeStatusCompanyNameLabel": "", - "distributedUptimeStatusPageAddressLabel": "", - "distributedUptimeStatus30Days": "30 dias", - "distributedUptimeStatus60Days": "60 días", - "distributedUptimeStatus90Days": "90 dias", - "distributedUptimeStatusPageNotSetUp": "", - "distributedUptimeStatusContactAdmin": "", - "distributedUptimeStatusPageNotPublic": "", - "distributedUptimeStatusPageDeleteDialog": "", - "distributedUptimeStatusPageDeleteConfirm": "", - "distributedUptimeStatusPageDeleteDescription": "", - "distributedUptimeStatusDevices": "Dispositivos", - "distributedUptimeStatusUpt": "", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "", - "incidentsTableNoIncidents": "", - "incidentsTablePaginationLabel": "Incidentes", - "incidentsTableMonitorName": "", - "incidentsTableStatus": "Estado", - "incidentsTableDateTime": "", - "incidentsTableStatusCode": "", - "incidentsTableMessage": "Mensaje", - "incidentsOptionsHeader": "", - "incidentsOptionsHeaderFilterBy": "", - "incidentsOptionsHeaderFilterAll": "Todo", - "incidentsOptionsHeaderFilterDown": "", - "incidentsOptionsHeaderFilterCannotResolve": "", - "incidentsOptionsHeaderShow": "Mostrar:", - "incidentsOptionsHeaderLastHour": "", - "incidentsOptionsHeaderLastDay": "", - "incidentsOptionsHeaderLastWeek": "", - "incidentsOptionsPlaceholderAllServers": "", - "infrastructureCreateYour": "", - "infrastructureCreateGeneralSettingsDescription": "", - "infrastructureServerRequirement": "", - "infrastructureCustomizeAlerts": "", - "infrastructureAlertNotificationDescription": "", - "infrastructureCreateMonitor": "", - "infrastructureProtocol": "Protocolo", - "infrastructureServerUrlLabel": "", - "infrastructureDisplayNameLabel": "", - "infrastructureAuthorizationSecretLabel": "", - "gb": "GB", - "mb": "MB", - "mem": "Mem", - "memoryUsage": "", - "cpu": "CPU", - "cpuUsage": "", - "cpuTemperature": "", - "diskUsage": "", - "used": "", - "total": "Total", - "cores": "Nucleos", - "frequency": "Frecuencia", - "status": "", - "cpuPhysical": "", - "cpuLogical": "", - "cpuFrequency": "", - "avgCpuTemperature": "", - "memory": "", - "disk": "", - "uptime": "", - "os": "SO", - "host": "", - "actions": "", - "integrations": "", - "integrationsPrism": "", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "", - "commonSave": "Guardar", - "createYour": "", - "createMonitor": "", - "pause": "Pausar", - "resume": "Resumir", - "editing": "Editando...", - "url": "URL", - "access": "Acceso", - "timezone": "", - "features": "", - "administrator": "", - "loginHere": "", - "displayName": "", - "urlMonitor": "", - "portToMonitor": "", - "websiteMonitoring": "", - "websiteMonitoringDescription": "", - "pingMonitoring": "", - "pingMonitoringDescription": "", - "dockerContainerMonitoring": "", - "dockerContainerMonitoringDescription": "", - "portMonitoring": "", - "portMonitoringDescription": "", - "createMaintenanceWindow": "", - "createMaintenance": "", - "editMaintenance": "", - "maintenanceWindowName": "", - "friendlyNameInput": "", - "friendlyNamePlaceholder": "", - "maintenanceRepeat": "", - "maintenance": "", - "duration": "Duración", - "addMonitors": "", - "window": "ventana", - "cancel": "Cancelar", - "message": "Mensaje", - "low": "bajo", - "high": "alto", - "statusCode": "", - "date&Time": "", - "type": "", - "statusPageName": "", - "publicURL": "", - "repeat": "", - "edit": "", - "createA": "", - "remove": "", - "maintenanceWindowDescription": "", - "startTime": "", - "timeZoneInfo": "", - "monitorsToApply": "", - "nextWindow": "", - "notFoundButton": "", - "pageSpeedConfigureSettingsDescription": "", - "monitorDisplayName": "", - "whenNewIncident": "", - "notifySMS": "", - "notifyEmails": "", - "seperateEmails": "", - "checkFrequency": "", - "matchMethod": "", - "expectedValue": "", - "deleteDialogTitle": "", - "deleteDialogDescription": "", - "pageSpeedMonitor": "", - "shown": "", - "ago": "", - "companyName": "", - "pageSpeedDetailsPerformanceReport": "", - "pageSpeedDetailsPerformanceReportCalculator": "", - "checkingEvery": "", - "statusPageCreateSettings": "", - "basicInformation": "", - "statusPageCreateBasicInfoDescription": "", - "statusPageCreateSelectTimeZoneDescription": "", - "statusPageCreateAppearanceDescription": "", - "statusPageCreateSettingsCheckboxLabel": "", - "statusPageCreateBasicInfoStatusPageAddress": "", - "statusPageCreateTabsContent": "", - "statusPageCreateTabsContentDescription": "", - "statusPageCreateTabsContentFeaturesDescription": "", - "showCharts": "", - "showUptimePercentage": "", - "removeLogo": "", - "statusPageStatus": "", - "statusPageStatusContactAdmin": "", - "statusPageStatusNotPublic": "", - "statusPageStatusNoPage": "", - "statusPageStatusServiceStatus": "", - "deleteStatusPage": "", - "deleteStatusPageConfirm": "", - "deleteStatusPageDescription": "", - "uptimeCreate": "", - "uptimeCreateJsonPath": "", - "uptimeCreateJsonPathQuery": "", - "maintenanceTableActionMenuDialogTitle": "", - "infrastructureEditYour": "", - "infrastructureEditMonitor": "", - "infrastructureMonitorCreated": "", - "infrastructureMonitorUpdated": "", - "errorInvalidTypeId": "", - "errorInvalidFieldId": "", - "inviteNoTokenFound": "", - "pageSpeedWarning": "", - "pageSpeedLearnMoreLink": "", - "pageSpeedAddApiKey": "", - "update": "", - "invalidFileFormat": "", - "invalidFileSize": "", - "ClickUpload": "", - "DragandDrop": "", - "MaxSize": "", - "SupportedFormats": "", - "FirstName": "", - "LastName": "", - "EmailDescriptionText": "", - "YourPhoto": "", - "PhotoDescriptionText": "", - "save": "", - "DeleteDescriptionText": "", - "DeleteAccountWarning": "", - "DeleteWarningTitle": "", - "bulkImport": { - "title": "", - "selectFileTips": "", - "selectFileDescription": "", - "selectFile": "", - "parsingFailed": "", - "uploadSuccess": "", - "validationFailed": "", - "noFileSelected": "", - "fallbackPage": "", - "invalidFileType": "", - "uploadFailed": "" - }, - "DeleteAccountTitle": "", - "DeleteAccountButton": "", - "publicLink": "", - "maskedPageSpeedKeyPlaceholder": "", - "reset": "", - "ignoreTLSError": "", - "tlsErrorIgnored": "", - "ignoreTLSErrorDescription": "", - "createNew": "", - "greeting": { - "prepend": "", - "append": "", - "overview": "" - }, - "roles": { - "superAdmin": "", - "admin": "", - "teamMember": "", - "demoUser": "" - }, - "teamPanel": { - "teamMembers": "", - "filter": { - "all": "", - "member": "" - }, - "inviteTeamMember": "", - "inviteNewTeamMember": "", - "inviteDescription": "", - "email": "", - "selectRole": "", - "inviteLink": "", - "cancel": "", - "noMembers": "", - "getToken": "", - "emailToken": "", - "table": { - "name": "", - "email": "", - "role": "", - "created": "" - }, - "addTeamMember": { - "addMemberMenu": "", - "title": "", - "description": "", - "addButton": "" - }, - "register": "", - "registerToast": { - "success": "", - "dbUserExists": "", - "unknownError": "" - }, - "registerTeamMember": { - "title": "", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "lastName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "email": { - "errors": { - "empty": "", - "invalid": "" - } - }, - "role": { - "errors": { - "empty": "" - } - } - } - } - } - }, - "role": "", - "changeTeamPassword": { - "changePasswordMenu": "", - "title": "", - "description": "", - "success": "" - } - }, - "monitorState": { - "paused": "", - "resumed": "", - "active": "" - }, - "menu": { - "uptime": "", - "pagespeed": "", - "infrastructure": "", - "incidents": "", - "statusPages": "", - "maintenance": "", - "integrations": "", - "settings": "", - "support": "", - "discussions": "", - "docs": "", - "changelog": "", - "profile": "", - "password": "", - "team": "", - "logOut": "", - "notifications": "", - "logs": "" - }, - "settingsEmailUser": "", - "state": "", - "statusBreadCrumbsStatusPages": "", - "statusBreadCrumbsDetails": "", - "commonSaving": "", - "navControls": "", - "incidentsPageTitle": "", - "passwordPanel": { - "passwordChangedSuccess": "", - "passwordInputIncorrect": "", - "currentPassword": "", - "enterCurrentPassword": "", - "newPassword": "", - "enterNewPassword": "", - "confirmNewPassword": "", - "passwordRequirements": "", - "saving": "" - }, - "emailSent": "", - "failedToSendEmail": "", - "settingsTestEmailSuccess": "", - "settingsTestEmailFailed": "", - "settingsTestEmailFailedWithReason": "", - "settingsTestEmailUnknownError": "", - "statusMsg": { - "paused": "", - "up": "", - "down": "", - "pending": "" - }, - "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "", - "game": "", - "https": "" - }, "common": { - "appName": "", - "monitoringAgentName": "", - "buttons": { - "toggleTheme": "" + "auth": { + "roles": { + "admin": "Administrador", + "demo": "Demostración", + "superadmin": "Superadministrador", + "user": "Usuario" + } }, - "toasts": { - "networkError": "", - "checkConnection": "", - "unknownError": "" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "", - "back": "" + "alerts": { + "pageSpeedApiKey": { + "content": "Advertencia: Aún no ha agregado una clave de API de Google PageSpeed. Visite Configuración para agregar una. Sin ella, el monitor de PageSpeed no funcionará." + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "Detalles", + "home": "Inicio" + }, + "buttons": { + "addMember": "Agregar miembro", + "cancel": "Cancelar", + "close": "Cerrar", + "configure": "Configurar", + "confirm": "Confirmar", + "create": "Crear", + "delete": "Eliminar", + "generateToken": "Generar token", + "incidents": "Incidentes", + "inviteMember": "Invitar miembro", + "pause": "Pausar", + "resume": "Reanudar", + "save": "Guardar", + "sendInvite": "Enviar invitación", + "test": "Probar", + "testNotifications": "Probar notificaciones", + "toggleTheme": "Cambiar tema claro / oscuro", + "flushQueue": "Vaciar cola", + "notFound": "Ir al panel principal", + "resetPassword": "Restablecer contraseña", + "reset": "Restablecer", + "clear": "Limpiar", + "addDemo": "Agregar monitores de demostración", + "removeMonitors": "Eliminar monitores", + "sendTestEmail": "Enviar correo de prueba", + "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" + }, + "charts": { + "labels": { + "averageResponseTime": "Tiempo de respuesta promedio", + "downtime": "Tiempo de inactividad", + "high": "alto", + "low": "bajo", + "uptime": "Tiempo de actividad" }, - "inputs": { - "email": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "invalid": "" - } - }, - "password": { - "label": "", - "rules": { - "length": { - "beginning": "", - "highlighted": "" - }, - "special": { - "beginning": "", - "highlighted": "" - }, - "number": { - "beginning": "", - "highlighted": "" - }, - "uppercase": { - "beginning": "", - "highlighted": "" - }, - "lowercase": { - "beginning": "", - "highlighted": "" - }, - "match": { - "beginning": "", - "highlighted": "" - } - }, - "errors": { - "empty": "", - "length": "", - "uppercase": "", - "lowercase": "", - "number": "", - "special": "", - "incorrect": "" - } - }, - "passwordConfirm": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "different": "" - } - }, - "firstName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - }, - "lastName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" + "histogram": { + "avg": "Prom: {{value}} ms", + "max": "Máx: {{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "Esta acción no se puede deshacer.", + "title": "¿Está seguro de que desea eliminar esto?" + } + }, + "labels": { + "active": "Activo", + "paused": "pausado", + "na": "N/D", + "resolved": "Resuelto", + "responseTime": "Tiempo de respuesta" + }, + "form": { + "role": { + "option": { + "role": { + "label": "Roles" } } }, - "errors": { - "validation": "" - }, - "fields": { - "password": { - "errors": { - "incorrect": "" + "name": { + "option": { + "firstName": { + "label": "Nombre", + "placeholder": "Introduce tu nombre" + }, + "lastName": { + "label": "Apellidos", + "placeholder": "Introduce tus apellidos" } - }, - "role": { - "errors": { - "min": "" + } + }, + "email": { + "option": { + "email": { + "label": "Correo electrónico", + "placeholder": "tu@ejemplo.com" } } } }, - "login": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "" - }, - "links": { - "forgotPassword": "", - "register": "", - "forgotPasswordLink": "", - "registerLink": "" - }, - "toasts": { - "success": "", - "incorrectPassword": "" - }, + "table": { + "empty": "No hay nada aquí", + "headers": { + "actions": "Acciones", + "dateTime": "Fecha y hora", + "message": "Mensaje", + "monitor": "Monitor", + "monitorId": "ID de monitor", + "name": "Nombre", + "status": "Estado", + "type": "Tipo", + "url": "URL", + "interval": "Intervalo", + "active": "Activo", + "responseTime": "Tiempo de respuesta" + } + } + }, + "components": { + "imageUpload": { + "clickToUpload": "Haga clic para subir", + "dragAndDrop": "o arrastra y suelta", + "supportedFormats": "Formatos admitidos", + "maxSize": "Tamaño máximo", + "orDragAndDrop": "o arrastre y suelte", "errors": { - "password": { - "incorrect": "" - } - }, - "welcome": "" + "invalidFileSize": "¡El archivo es demasiado grande!", + "invalidFileFormat": "¡Formato de archivo no admitido!" + } }, - "registration": { - "heading": { - "superAdmin": "", - "user": "" - }, - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "" + "headerStatusPageControls": { + "publicLink": "Enlace público" + }, + "headerTimeRange": { + "labels": { + "day": "Día", + "month": "Mes", + "recent": "Reciente", + "week": "Semana" }, "description": { - "superAdmin": "", - "user": "" + "recent": "Mostrando estadísticas de las últimas 2 horas.", + "day": "Mostrando estadísticas de las últimas 24 horas.", + "week": "Mostrando estadísticas de los últimos 7 días.", + "month": "Mostrando estadísticas de los últimos 30 días." + } + }, + "offlineBanner": { + "serverUnreachable": "No se puede conectar al servidor", + "retry": "Reintentar", + "retrying": "Reintentando...", + "reconnected": "Conexión restablecida" + }, + "sidebar": { + "menu": { + "uptime": "Disponibilidad", + "pagespeed": "Pagespeed", + "infrastructure": "Infraestructura", + "notifications": "Notificaciones", + "checks": "Verificaciones", + "incidents": "Incidentes", + "statusPages": "Páginas de estado", + "maintenance": "Mantenimiento", + "logs": "Registros", + "settings": "Configuración" }, - "gettingStartedButton": { - "superAdmin": "", - "user": "" + "bottomMenu": { + "support": "Soporte", + "discussions": "Discusiones", + "docs": "Documentación", + "changelog": "Registro de cambios" }, - "termsAndPolicies": "", + "accountMenu": { + "profile": "Perfil", + "password": "Contraseña", + "team": "Equipo" + }, + "starPrompt": { + "title": "Dale estrella a Checkmate", + "description": "Vea las últimas versiones y ayude a crecer la comunidad en GitHub" + }, + "authFooter": { + "navControls": "Controles", + "logOut": "Cerrar sesión", + "roles": { + "superAdmin": "Superadministrador", + "admin": "Administrador", + "user": "Usuario", + "demoUser": "Usuario demo" + } + } + }, + "starPrompt": { + "title": "Dale estrella a Checkmate", + "description": "Vea las últimas versiones y ayude a crecer la comunidad en GitHub" + }, + "pageHeader": { "links": { - "login": "" - }, - "toasts": { - "success": "" - }, - "welcome": "" - }, - "forgotPassword": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "", - "stepFour": "" - }, - "buttons": { - "openEmail": "", - "resetPassword": "" - }, - "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" - }, - "links": { - "login": "", - "resend": "" - }, - "toasts": { - "sent": "", - "emailNotFound": "", - "redirect": "", - "success": "", - "error": "" + "support": "Soporte", + "discussions": "Discusiones", + "docs": "Documentación", + "changelog": "Registro de cambios" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "", - "stillUnreachable": "" + "pages": { + "notFound": { + "title": "¡Oh no! ¡Se le cayó el sushi!", + "subtitle": "La URL no existe o no tiene acceso a ella." + }, + "account": { + "tabs": { + "profile": "Perfil", + "password": "Contraseña", + "team": "Equipo" }, - "alertBox": "", - "description": "", - "retryButton": { - "default": "", - "processing": "" + "form": { + "name": { + "title": "Nombre", + "description": "Actualice su información personal" + }, + "photo": { + "title": "Foto de perfil", + "description": "Suba una foto de perfil" + }, + "currentPassword": { + "title": "Contraseña actual", + "description": "Ingrese su contraseña actual para verificar su identidad", + "option": { + "label": "Contraseña actual", + "placeholder": "Ingrese la contraseña actual" + } + }, + "newPassword": { + "title": "Nueva contraseña", + "description": "Elija una contraseña segura de al menos 8 caracteres", + "option": { + "newPassword": { + "label": "Nueva contraseña", + "placeholder": "Ingrese la nueva contraseña" + }, + "confirm": { + "label": "Confirmar contraseña", + "placeholder": "Confirmar nueva contraseña" + } + } + }, + "deleteAccount": { + "title": "Eliminar cuenta", + "description": "Esta acción es permanente y no se puede deshacer" + } + }, + "team": { + "filter": { + "placeholder": "Filtrar por rol", + "all": "Todos los roles", + "admin": "Administrador", + "member": "Miembro" + }, + "table": { + "headers": { + "email": "Correo electrónico", + "role": "Rol", + "created": "Creado el" + } + }, + "addMember": { + "title": "Registrar nuevo miembro del equipo", + "description": "Cree una nueva cuenta de usuario. Comparta las credenciales de forma segura después de la creación." + }, + "invite": { + "title": "Invitar miembro del equipo", + "description": "Envíe una invitación para unirse a su equipo", + "email": { + "label": "Dirección de correo electrónico", + "placeholder": "Ingrese la dirección de correo electrónico" + }, + "role": { + "label": "Rol", + "placeholder": "Seleccione un rol" + }, + "linkLabel": "Enlace de invitación" + }, + "fallback": { + "title": "Aún no hay miembros del equipo", + "description": "Invita a tus compañeros por correo o añádelos directamente. Una vez que se unan, aparecerán aquí con su rol." + } + }, + "header": { + "title": "Cuenta", + "description": "Administra tu perfil, contraseña, equipo y preferencias del espacio de trabajo." + } + }, + "auth": { + "brandPanel": { + "eyebrow": "Monitorización de infraestructura de código abierto", + "tagline": "Vigila cada movimiento de tus servidores.", + "description": "Monitorización en tiempo real de disponibilidad, tiempo de respuesta e incidentes - autoalojada y tuya." + }, + "common": { + "passwordRules": { + "length": { + "beginning": "Debe tener al menos", + "highlighted": "8 caracteres" + }, + "lowercase": { + "beginning": "Debe contener al menos", + "highlighted": "una letra minúscula" + }, + "match": { + "beginning": "Las contraseñas", + "highlighted": "deben coincidir" + }, + "number": { + "beginning": "Debe contener al menos", + "highlighted": "un número" + }, + "special": { + "beginning": "Debe contener al menos", + "highlighted": "un carácter especial (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" + }, + "uppercase": { + "beginning": "Debe contener al menos", + "highlighted": "una letra mayúscula" + } + }, + "form": { + "option": { + "email": { + "label": "Correo electrónico", + "placeholder": "yo@ejemplo.com" + }, + "password": { + "label": "Contraseña", + "placeholder": "********" + }, + "confirmPassword": { + "label": "Confirmar contraseña" + } + } + } + }, + "login": { + "title": "¡Bienvenido de nuevo a Checkmate!", + "subtitle": "Inicie sesión para continuar", + "submit": "Iniciar sesión", + "links": { + "forgotPassword": { + "text": "¿Olvidó su contraseña?", + "linkText": "Restablecer contraseña" + }, + "register": { + "text": "¿No tiene una cuenta?", + "linkText": "Regístrese aquí" + } + } + }, + "register": { + "title": "¡Bienvenido de nuevo a Checkmate!", + "subtitle": "Regístrese para comenzar", + "submit": "Registrarse", + "setupNotice": "Esta página solo está disponible durante la configuración inicial. Una vez que se haya registrado un super administrador, se podrá invitar a usuarios adicionales desde la configuración del equipo." + }, + "forgotPassword": { + "title": "¿Olvidó su contraseña?", + "subtitle": "No se preocupe, le enviaremos instrucciones para restablecer.", + "submit": "Solicitar recuperación", + "links": { + "login": { + "text": "Regresar a", + "linkText": "iniciar sesión" + } + } + }, + "setNewPassword": { + "title": "Restablezca su contraseña", + "subtitle": "Su nueva contraseña debe ser diferente a las contraseñas utilizadas anteriormente." + } + }, + "checks": { + "selects": { + "monitor": { + "all": "Todos los monitores" + }, + "status": { + "all": "Todos", + "down": "Caído", + "up": "Activo" + } + }, + "table": { + "empty": "No hay verificaciones caídas en este rango de tiempo", + "headers": { + "statusCode": "Código de estado", + "location": "Ubicación" + } + }, + "fallback": { + "title": "Aún no hay comprobaciones", + "description": "Una vez que crees un monitor, cada sonda de estado y tiempo de respuesta aparecerá aquí como una comprobación." + }, + "header": { + "title": "Comprobaciones", + "description": "Cada sonda que Checkmate ejecuta contra tus monitores aparece aquí con código de estado y tiempo de respuesta." + } + }, + "common": { + "monitors": { + "actions": { + "configure": "Configurar", + "delete": "Eliminar", + "generateToken": "Generar token", + "details": "Detalles", + "incidents": "Incidentes", + "inviteMember": "Invitar miembro", + "openSite": "Abrir sitio", + "pause": "Pausar", + "resume": "Reanudar", + "selectAll": "Seleccionar todos los monitores", + "selectMonitor": "Seleccionar {{name}}", + "bulkSelected_one": "{{count}} monitor seleccionado", + "bulkSelected_other": "{{count}} monitores seleccionados" + }, + "statBoxes": { + "activeFor": "Activo desde hace", + "certificateExpiry": "Vencimiento del certificado", + "lastCheck": "Última verificación", + "lastResponseTime": "Último tiempo de respuesta", + "serviceIsDown": "El servicio está caído" + }, + "status": { + "down": "caído", + "breached": "umbral excedido", + "initializing": "inicializando", + "maintenance": "mantenimiento", + "paused": "pausado", + "total": "total", + "up": "activo" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "Juego", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "Puerto", + "optionPagespeed": "PageSpeed", + "optionHardware": "Infraestructura", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} monitor pausado", + "paused_other": "{{count}} monitores pausados", + "resumed_one": "{{count}} monitor reanudado", + "resumed_other": "{{count}} monitores reanudados", + "alreadyPaused_one": "El monitor seleccionado ya está pausado", + "alreadyPaused_other": "Todos los monitores seleccionados ya están pausados", + "alreadyRunning_one": "El monitor seleccionado ya se está ejecutando", + "alreadyRunning_other": "Todos los monitores seleccionados ya se están ejecutando" + } + } + }, + "createMonitor": { + "form": { + "advanced": { + "description": "Configuración opcional para casos de uso avanzados", + "option": { + "advancedMatching": { + "label": "Usar coincidencia avanzada" + }, + "expectedValue": { + "label": "Valor esperado" + }, + "jsonPath": { + "description": "Esta expresión se evaluará contra los datos JSON de la respuesta y el resultado se usará para comparar con el valor esperado. Consulte jmespath.org para documentación del lenguaje de consulta.", + "label": "Expresión JSONPath" + }, + "matchMethod": { + "label": "Método de coincidencia", + "equal": "Igual", + "include": "Contiene", + "regex": "Regex" + } + }, + "title": "Configuración avanzada" + }, + "frequency": { + "description": "¿Con qué frecuencia desea verificar el estado de este monitor?", + "option": { + "frequency": { + "label": "Frecuencia de verificación", + "value": { + "fifteenMinutes": "15 minutos", + "fifteenSeconds": "15 segundos", + "fiveMinutes": "5 minutos", + "fourMinutes": "4 minutos", + "oneMinute": "1 minuto", + "tenMinutes": "10 minutos", + "thirtyMinutes": "30 minutos", + "thirtySeconds": "30 segundos", + "threeMinutes": "3 minutos", + "twoMinutes": "2 minutos" + } + } + }, + "title": "Frecuencia de verificación" + }, + "general": { + "option": { + "container": { + "label": "Nombre/ID de contenedor", + "placeholder": "my-app o abcd1234" + }, + "host": { + "label": "Host", + "placeholder": "192.168.1.100 o example.com" + }, + "name": { + "label": "Nombre para mostrar", + "placeholder": "p. ej. Mi sitio web" + }, + "secret": { + "label": "Secreto de autorización", + "placeholder": "Ingrese su clave secreta" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "Elige el juego", + "placeholder": "Selecciona un juego" + }, + "port": { + "label": "Puerto a monitorear", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "Nombre del servicio", + "placeholder": "p. ej. my.service.v1 (dejar vacío para estado general)" + }, + "wsUrl": { + "label": "URL de WebSocket", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Endpoint de Capture", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "Configuración general", + "description": { + "http": "Ingrese la URL o IP a monitorear (p. ej., https://example.com/ o 192.168.1.100) y agregue un nombre para mostrar que aparecerá en el panel.", + "ping": "Introduce la dirección IP o el nombre de host para hacer ping (p. ej., 192.168.1.100 o ejemplo.com) y un nombre descriptivo que aparecerá en el panel.", + "port": "Introduce la URL o IP del servidor, el número de puerto y un nombre descriptivo que aparecerá en el panel.", + "docker": "Introduce el nombre o ID del contenedor de Docker. Puedes usar el nombre del contenedor (p. ej., my-app) o su ID.", + "game": "Introduce la dirección IP o el nombre de host y el número de puerto para hacer ping (p. ej., 192.168.1.100 o ejemplo.com) y un nombre descriptivo que aparecerá en el panel. Algunos juegos requieren habilitar consultas del servidor en su archivo de configuración — consulta la documentación de tu servidor. Consulta la lista de juegos compatibles para ver los detalles del protocolo.", + "pagespeed": "Rastree el rendimiento de carga de página, Core Web Vitals y puntuaciones de optimización de su sitio web.", + "grpc": "Ingrese el nombre de host y puerto del servidor gRPC, opcionalmente especifique un nombre de servicio para la verificación de salud, y agregue un nombre para mostrar.", + "websocket": "Ingrese la URL de WebSocket a monitorear (p. ej., wss://example.com/socket) y agregue un nombre para mostrar.", + "hardware": "Supervisa el uso de CPU, memoria, disco y temperatura de tu infraestructura. La URL del endpoint de Capture debe incluir la ruta completa /api/v1/metrics (p. ej., http://192.168.1.10:5001/api/v1/metrics). Para el secreto de autorización, pega solo la cadena del token — no incluyas 'Bearer'." + } + }, + "ignoreTls": { + "description": "Configure la validación de certificados TLS/SSL para conexiones HTTPS.", + "option": { + "tls": { + "label": "Ignorar errores de TLS/SSL" + } + }, + "title": "Configuración de TLS/SSL" + }, + "incidents": { + "description": "Se utiliza una ventana deslizante para determinar cuándo un monitor cae. El estado de un monitor solo cambiará cuando el porcentaje de verificaciones en la ventana deslizante cumpla con el valor especificado.", + "option": { + "checks": { + "label": "Número de verificaciones en la ventana deslizante" + }, + "percentage": { + "label": "¿Qué porcentaje de verificaciones en la ventana deslizante deben fallar/tener éxito antes de que cambie el estado del monitor?" + } + }, + "title": "Incidentes" + }, + "notifications": { + "description": "Seleccione los canales de notificación que desea usar", + "title": "Notificaciones" + }, + "type": { + "description": "Seleccione el tipo de verificación a realizar", + "optionDockerDescription": "Use Docker para monitorear si un contenedor está en ejecución.", + "optionGameDescription": "Monitoree si un servidor de juegos específico está en línea.", + "optionGrpcDescription": "Monitoree servicios gRPC usando el protocolo estándar de verificación de salud.", + "optionWebSocketDescription": "Monitoree endpoints de WebSocket para verificar la salud de la conexión y el tiempo de respuesta.", + "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" + }, + "thresholds": { + "title": "Umbrales de alerta", + "description": "Defina los umbrales en los que se deben activar las alertas para este monitor de hardware.", + "option": { + "cpuThreshold": { + "label": "Umbral de alerta de CPU (%)" + }, + "memoryThreshold": { + "label": "Umbral de alerta de memoria (%)" + }, + "diskThreshold": { + "label": "Umbral de alerta de disco (%)" + }, + "tempThreshold": { + "label": "Umbral de alerta de temperatura (°C)" + } + } + }, + "geoChecks": { + "title": "Verificaciones geodistribuidas", + "description": "Ejecute verificaciones desde múltiples ubicaciones geográficas para monitorear la disponibilidad y el rendimiento global.", + "option": { + "enabled": { + "label": "Habilitar verificaciones geodistribuidas" + }, + "locations": { + "label": "Ubicaciones", + "placeholder": "Seleccionar ubicaciones", + "options": { + "EU": "Europa", + "NA": "Norteamérica", + "AS": "Asia", + "SA": "Sudamérica", + "AF": "África", + "OC": "Oceanía" + } + }, + "interval": { + "label": "Intervalo de verificación", + "value": { + "fiveMinutes": "5 minutos", + "tenMinutes": "10 minutos", + "fifteenMinutes": "15 minutos", + "thirtyMinutes": "30 minutos" + } + } + } + }, + "url": { + "title": "IP/URL del monitor en la página de estado", + "description": "Muestre la dirección IP o URL del monitor en la página de estado pública. Si está deshabilitado, solo se mostrará el nombre del monitor para proteger información sensible.", + "option": { + "showURL": { + "label": "Mostrar IP/URL en la página de estado", + "enabled": "Habilitado", + "disabled": "Deshabilitado" + } + } + }, + "stats": { + "title": "Historial del monitor", + "description": "Limpie todo el historial de monitoreo y estadísticas de su equipo. Esta acción es irreversible.", + "buttonText": "Limpiar todas las estadísticas", + "dialog": { + "title": "¿Limpiar todo el historial de monitoreo?", + "description": "Esto eliminará permanentemente todo el historial de monitoreo, estadísticas y datos de verificación de su equipo. Esta acción no se puede deshacer.", + "confirm": "Sí, limpiar todas las estadísticas" + } + } + } + }, + "editUser": { + "form": { + "roles": { + "title": "Roles", + "description": "Asigne roles al usuario. Se pueden seleccionar múltiples roles." + } + }, + "dialog": { + "removeUser": { + "title": "Eliminar usuario", + "content": "¿Está seguro de que desea eliminar a {{name}} de su equipo? Esta acción no se puede deshacer." + } + } + }, + "incidents": { + "fallback": { + "title": "Sin incidentes — todos los sistemas saludables", + "description": "Cuando un monitor falla o se recupera, el incidente aparecerá aquí con los tiempos, los detalles del monitor y el historial de resolución." + }, + "dialog": { + "resolveIncident": { + "title": "Resolver incidente", + "option": { + "comment": { + "label": "Comentario (opcional)", + "placeholder": "Agregue un comentario sobre la resolución..." + } + } + }, + "details": { + "analysis": "Análisis del incidente", + "comment": "Comentario:", + "detailsLabel": "Detalles", + "downtime": "Tiempo de inactividad:", + "message": "Mensaje:", + "monitor": "Monitor:", + "overview": "Resumen", + "resolutionDetails": "Detalles de la resolución", + "resolutionType": "Tipo:", + "resolutionTypes": { + "automatic": "Automático", + "manual": "Manual" + }, + "resolve": "Resolver incidente", + "resolvedAt": "Resuelto a las:", + "resolvedBy": "Resuelto por:", + "startedAt": "Iniciado a las:", + "status": "Estado:", + "statusCode": "Código de estado:", + "timeline": "Línea de tiempo", + "title": "Detalles del incidente", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "Todos los monitores", + "monitor": "Monitor", + "resolutionType": "Tipo de resolución", + "resolutionTypes": { + "manual": "Manual", + "automatic": "Automático", + "all": "Todos" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "Incidentes activos", + "active_zero": "Sin incidentes activos", + "active_one": "{{count}} incidente activo", + "active_other": "{{count}} incidentes activos" + }, + "incidentStats": { + "avgResolutionTime": "Tiempo prom. de resolución", + "mostAffectedMonitor": "Monitor más afectado", + "title": "Estadísticas de incidentes", + "totalIncidents": "Total de incidentes" + }, + "latestIncidents": { + "title": "Últimos incidentes" + } + }, + "table": { + "actions": { + "details": "Detalles", + "goToMonitor": "Ir al monitor", + "resolveManually": "Resolver manualmente" + }, + "activeIncidents": "Incidentes activos", + "headers": { + "endTime": "Hora de fin", + "resolutionType": "Tipo de resolución", + "startTime": "Hora de inicio", + "statusCode": "Código de estado" + }, + "resolvedIncidents": "Incidentes resueltos", + "status": { + "active": "Activo", + "resolved": "Resuelto" + } + }, + "header": { + "title": "Incidentes", + "description": "Cuando un monitor falla o se recupera, los incidentes aparecen aquí con los tiempos, los detalles del monitor y el historial de resolución." + } + }, + "infrastructure": { + "charts": { + "labels": { + "cpu": "Uso de CPU", + "disk": "Uso de disco", + "memory": "Uso de memoria", + "netBytesRecv": "{{name}} - Bytes recibidos", + "netBytesSent": "{{name}} - Bytes enviados", + "netNoTraffic": "Capture no informa tráfico en ninguna interfaz. Esto es normal cuando Capture se ejecuta dentro de un contenedor que solo ve interfaces de loopback — ejecuta Capture directamente en el host para ver actividad de red real.", + "temp": "Temp" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "Frecuencia máxima", + "title": "Uso de CPU", + "upperLabel": "Frecuencia actual" + }, + "disk": { + "lowerLabel": "Libre", + "title": "Uso de disco {{idx}}", + "upperLabel": "Usado" + }, + "memory": { + "lowerLabel": "Libre", + "title": "Uso de memoria", + "upperLabel": "Usado" + } + }, + "statBoxes": { + "avgCpuTemperature": "Temperatura media de la CPU", + "cpuFrequency": "Frecuencia de la CPU", + "cpuLogical": "CPU (lógicas)", + "cpuPhysical": "CPU (físicas)", + "disk": "Disco", + "memory": "Memoria", + "os": "SO" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "Disco", + "memory": "Memoria" + } + }, + "tabs": { + "labels": { + "network": "Red", + "overview": "Resumen" + } + }, + "fallback": { + "actionButton": "¡Cree un monitor!", + "title": "Aún no hay monitores de infraestructura", + "description": "Rastrea el rendimiento de los servidores, identifica cuellos de botella y garantiza la fiabilidad con monitoreo en tiempo real." + }, + "header": { + "title": "Monitores de infraestructura", + "description": "Rastrea el rendimiento de los servidores, identifica cuellos de botella y garantiza la fiabilidad con monitoreo en tiempo real." + } + }, + "logs": { + "fallback": { + "title": "Aún no hay registros", + "description": "La actividad del servidor, advertencias y errores aparecerán aquí mientras Checkmate se ejecuta." + }, + "tabs": { + "diagnostics": "Diagnóstico", + "logs": "Registros", + "queue": "Cola de trabajos" + }, + "logLevelSelect": { + "label": "Nivel de registro" + }, + "jobQueue": "Cola de trabajos", + "jobQueueExplainer": "Las filas resaltadas son los trabajos que un worker está procesando actualmente.", + "failedJobs": "Trabajos fallidos", + "failedJobsExplainer": "Fallos recientes por monitor — hora del último fallo, número de fallos y motivo.", + "noLogs": "No se encontraron registros", + "metrics": { + "jobs": "Trabajos", + "activeJobs": "Trabajos activos", + "failingJobs": "Trabajos con fallas", + "totalRuns": "Ejecuciones totales", + "totalFailures": "Fallas totales" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "Retraso del bucle de eventos", + "uptime": "Tiempo de actividad", + "usedHeapSize": "Tamaño de heap usado", + "totalHeapSize": "Tamaño total de heap", + "osMemoryLimit": "Límite de memoria del SO" + }, + "gauges": { + "heapAllocation": "Asignación de heap", + "heapUsage": "Uso de heap", + "heapUtilization": "Utilización de heap", + "instantCpuUsage": "Uso instantáneo de CPU", + "availableMemoryPercentage": "% de memoria disponible", + "allocatedPercentage": "% asignado", + "usedSPercentage": "% de 1s usado por CPU", + "total": "Total", + "used": "Usado" + }, + "sections": { + "runtimeStats": { + "title": "Estadísticas de tiempo de ejecución", + "description": "Instantánea del proceso Node.js y la máquina anfitriona. Se actualiza cada 5 segundos." + }, + "memoryCpu": { + "title": "Memoria y CPU", + "description": "Cómo el proceso utiliza la memoria asignada y la CPU del host." + } + } + }, + "table": { + "headers": { + "timestamp": "Marca de tiempo", + "level": "Nivel", + "service": "Servicio", + "method": "Método", + "monitorId": "ID de monitor", + "runCount": "Conteo de ejecuciones", + "failCount": "Conteo de fallas", + "lastRunAt": "Última ejecución", + "lockedAt": "Bloqueado a las", + "lastFinishedAt": "Última finalización", + "lastRunTook": "Última ejecución tomó", + "lastFailedAt": "Última falla", + "failReason": "Razón de falla" + } + }, + "queueFallback": { + "title": "No hay trabajos en la cola", + "description": "Las comprobaciones de monitores programadas o en ejecución aparecerán aquí. Una cola vacía significa que todo está al día." + }, + "header": { + "title": "Registros y cola", + "description": "Inspecciona registros del servidor, la cola de trabajos del monitor y los diagnósticos de runtime en un solo lugar." + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "¡Cree una ventana de mantenimiento!", + "title": "No hay ventanas de mantenimiento programadas", + "description": "Programa interrupciones planificadas para silenciar las alertas y que tu equipo y usuarios sepan qué esperar." + }, + "table": { + "headers": { + "nextWindow": "Próxima ventana", + "repeat": "Repetición" + } + }, + "form": { + "general": { + "title": "Configuración general", + "description": "Establezca un nombre y opción de repetición para su ventana de mantenimiento.", + "option": { + "name": { + "label": "Nombre", + "placeholder": "p. ej. Mantenimiento semanal" + }, + "repeat": { + "label": "Repetir" + } + } + }, + "startDate": { + "title": "Fecha de inicio", + "description": "Seleccione la fecha de inicio de su ventana de mantenimiento.", + "option": { + "startDate": { + "label": "Fecha de inicio" + } + } + }, + "startTime": { + "title": "Hora de inicio", + "description": "Establezca la hora de inicio y duración de su ventana de mantenimiento. Todos los valores están en UTC", + "option": { + "duration": { + "label": "Duración" + }, + "startTime": { + "label": "Hora de inicio" + } + }, + "monitors": { + "title": "Monitores", + "description": "Monitores a los que se debe aplicar la ventana de mantenimiento", + "option": { + "addMonitors": { + "label": "Agregar monitores" + } + } + } + } + }, + "header": { + "title": "Ventanas de mantenimiento", + "description": "Programa interrupciones planificadas para silenciar las alertas y que tu equipo y usuarios sepan qué esperar." + } + }, + "notifications": { + "fallback": { + "actionButton": "Crear un canal", + "title": "Aún no hay canales de notificación", + "description": "Conecta Slack, Discord, correo electrónico, webhooks y más para que las personas adecuadas se enteren de los incidentes en cuanto ocurran." + }, + "form": { + "accessToken": { + "optionAccessToken": "Token de acceso", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "La dirección donde se enviarán las notificaciones.", + "optionAddress": "Dirección", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "Dirección" + }, + "homeServer": { + "optionHomeServer": "Servidor principal", + "placeholder": "example.com" + }, + "matrix": { + "description": "Configure la conexión de su servidor Matrix para notificaciones.", + "title": "Configuración de Matrix" + }, + "notificationName": { + "description": "Un nombre descriptivo para el canal de notificación", + "optionName": "Nombre del canal", + "placeholder": "p. ej. Alertas de producción", + "title": "Nombre del canal" + }, + "pagerDuty": { + "description": "Su clave de integración de PagerDuty para recibir alertas.", + "optionIntegrationKey": "Clave de integración", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "Clave de integración" + }, + "roomId": { + "optionRoomId": "ID de sala", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "Seleccione el tipo de canal de notificación a crear.", + "optionType": "Tipo", + "title": "Tipo de canal" + }, + "telegram": { + "title": "Configuración de Telegram", + "description": "Para habilitar las notificaciones de Telegram, cree un bot de Telegram utilizando BotFather, un bot oficial para crear y administrar bots de Telegram. Entonces, obtenga una clave API y un ID de chat y escríbalos aquí.", + "optionBotToken": "Token del bot", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "ID de chat", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Configuración de Pushover", + "description": "Introduce tu token de aplicación y la clave de usuario de Pushover.", + "optionAppToken": "Token de aplicación", + "placeholderAppToken": "Introduce el token de aplicación", + "optionUserKey": "Clave de usuario", + "placeholderUserKey": "Introduce la clave de usuario" + }, + "twilio": { + "title": "Configuración de Twilio", + "description": "Introduce las credenciales de tu cuenta de Twilio para enviar SMS.", + "optionAccountSid": "SID de la cuenta", + "placeholderAccountSid": "Introduce el SID de la cuenta", + "optionAuthToken": "Token de autenticación", + "placeholderAuthToken": "Introduce el token de autenticación", + "optionFromNumber": "Número de origen", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "Número de destino", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "Destino" + } + }, + "header": { + "title": "Notificaciones", + "description": "Conecta Slack, Discord, correo electrónico, webhooks y más para que las personas adecuadas se enteren de los incidentes en cuanto ocurran." + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "Cambio acumulativo de diseño (CLS)", + "fcp": "Primera pintura con contenido (FCP)", + "lcp": "Mayor pintura con contenido (LCP)", + "si": "Índice de velocidad (SI)", + "tbt": "Tiempo total de bloqueo (TBT)" + }, + "legend": { + "title": "Reporte de PageSpeed", + "weight": "Peso" + }, + "pie": { + "title": "Reporte de rendimiento" + } + }, + "table": { + "headers": { + "pageSpeedScore": "Puntuación de PageSpeed" + } + }, + "fallback": { + "actionButton": "¡Cree un monitor!", + "title": "Aún no hay 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." + }, + "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." + } + }, + "settings": { + "form": { + "timezone": { + "title": "Zona horaria de visualización", + "description": "Seleccione la zona horaria utilizada para mostrar fechas y horas en toda la aplicación.", + "option": { + "timezone": { + "label": "Zona horaria de visualización" + } + } + }, + "ui": { + "title": "Apariencia", + "description": "Cambie entre modo claro y oscuro, cambie el idioma o personalice el tipo de visualización de gráficos.", + "option": { + "theme": { + "label": "Modo de tema", + "light": "Claro", + "dark": "Oscuro" + }, + "language": { + "label": "Idioma" + }, + "chartType": { + "label": "Tipo de gráfico", + "histogram": "Histograma", + "heatmap": "Mapa de calor" + } + } + }, + "pagespeed": { + "title": "Clave de API de Google PageSpeed", + "description": "Ingrese su clave de API de Google PageSpeed para habilitar el monitoreo de Google PageSpeed. Haga clic en Restablecer para actualizar la clave.", + "option": { + "apiKey": { + "label": "Clave de API de PageSpeed", + "labelSet": "La clave de API está configurada. Haga clic en Restablecer para cambiarla.", + "placeholder": "Ingrese su clave de API de Google PageSpeed" + } + } + }, + "url": { + "title": "IP/URL del monitor en la página de estado", + "description": "Muestre la dirección IP o URL del monitor en la página de estado pública. Si está deshabilitado, solo se mostrará el nombre del monitor para proteger información sensible.", + "option": { + "showURL": { + "label": "Mostrar IP/URL en la página de estado", + "enabled": "Habilitado", + "disabled": "Deshabilitado" + } + } + }, + "stats": { + "title": "Historial del monitor", + "description": "Limpie todo el historial de monitoreo y estadísticas de su equipo. Esta acción es irreversible.", + "option": { + "clear": { + "label": "Limpiar todas las estadísticas. Esto es irreversible." + } + }, + "dialog": { + "title": "¿Limpiar todo el historial de monitoreo?", + "description": "Esto no se puede deshacer" + } + }, + "retention": { + "title": "Retención de verificaciones", + "description": "Establezca cuánto tiempo se retienen los datos de verificación antes de ser limpiados automáticamente.", + "option": { + "days": { + "label": "Período de retención (días)", + "unlimited": "Ilimitado" + } + } + }, + "thresholds": { + "title": "Umbrales globales", + "description": "Establezca umbrales globales de CPU, memoria, disco y temperatura para monitoreo de infraestructura. Se aplican a todos los monitores de hardware a menos que se anulen.", + "option": { + "cpu": { + "label": "Umbral de CPU (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "Umbral de memoria (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "Umbral de disco (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "Umbral de temperatura (°C)", + "placeholder": "1 - 150" + } + } + }, + "email": { + "title": "Configuración de correo electrónico", + "description": "Configure los ajustes de correo electrónico de su sistema. Se usa para enviar notificaciones y alertas.", + "descriptionTransport": "Esto crea un transporte SMTP para NodeMailer", + "descriptionTransportLink": "Ver especificaciones aquí", + "option": { + "host": { + "label": "Host de correo - Nombre de host o dirección IP para conectar", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "Puerto de correo - Puerto de conexión", + "placeholder": "587" + }, + "address": { + "label": "Dirección de correo - Usada para autenticación", + "placeholder": "you@example.com" + }, + "user": { + "label": "Usuario de correo - Nombre de usuario para autenticación, reemplaza la dirección de correo si se especifica", + "placeholder": "Dejar vacío si no es requerido" + }, + "password": { + "label": "Contraseña de correo - Contraseña para autenticación", + "labelSet": "La contraseña está configurada. Haga clic en Restablecer para cambiarla.", + "placeholder": "Ingrese su contraseña" + }, + "tlsServername": { + "label": "Nombre de servidor TLS - Nombre de host opcional para validación TLS cuando el host es una IP", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "Host de conexión de correo - Nombre de host a usar en el saludo HELO/EHLO", + "placeholder": "localhost" + }, + "secure": { + "label": "Usar SSL (recomendado): Cifrar la conexión usando SSL/TLS" + }, + "pool": { + "label": "Habilitar agrupación de conexiones: Reutilizar conexiones existentes para mejorar el rendimiento" + }, + "ignoreTLS": { + "label": "Deshabilitar STARTTLS: No usar TLS aunque el servidor lo soporte" + }, + "requireTLS": { + "label": "Forzar STARTTLS: Requerir actualización TLS, fallar si no es soportado" + }, + "rejectUnauthorized": { + "label": "Rechazar certificados inválidos: Rechazar conexiones con certificados autofirmados o no confiables" + } + } + }, + "demoMonitors": { + "title": "Monitores de demostración", + "description": "Agregue monitores de ejemplo con fines demostrativos." + }, + "removeMonitors": { + "title": "Reiniciar sistema", + "description": "Eliminar todos los monitores del sistema.", + "dialog": { + "title": "¿Eliminar todos los monitores?", + "description": "Esta acción no se puede deshacer.", + "paragraph": "También se eliminarán todo el historial de comprobaciones, los incidentes, las ventanas de mantenimiento programadas y las páginas de estado que usaban estos monitores. Exporta los monitores primero si quieres conservar una copia de seguridad de sus datos." + } + }, + "exportMonitors": { + "title": "Exportar monitores" + }, + "importExportMonitors": { + "title": "Importar / Exportar monitores", + "description": "Importe o exporte los datos de sus monitores como archivo JSON para respaldo o transferencia." + }, + "about": { + "title": "Acerca de", + "developedBy": "Desarrollado por Bluewave Labs" + }, + "validation": { + "errorMessage": "Corrija los siguientes errores de validación:" + } + }, + "header": { + "title": "Configuración", + "description": "Configura SMTP, integraciones, retención y comportamiento global de la app." + } + }, + "statusPages": { + "deleteSuccess": "Página de estado eliminada exitosamente", + "fallback": { + "title": "Aún no hay páginas de estado", + "actionButton": "¡Cree una página de estado!", + "description": "Publica una página pública que muestre a tus clientes y partes interesadas la disponibilidad en tiempo real y el historial de incidentes." + }, + "monitorsList": { + "chartTypeHeatmap": "Mapa de calor", + "chartTypeHistogram": "Histograma", + "noData": "No hay datos disponibles", + "infrastructure": { + "title": "Infraestructura", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "Memoria", + "disk": "Disco", + "usage": "Uso", + "used": "Usado", + "total": "Total" + }, + "uptime": { + "title": "Tiempo de actividad", + "responseTime": "Tiempo de respuesta" + } + }, + "statusBar": { + "allDown": "Todos los sistemas están caídos", + "allUp": "Todos los sistemas operativos", + "degraded": "Algunos sistemas están experimentando problemas", + "unknown": "No se puede determinar el estado del sistema", + "allBreached": "Todos los sistemas han superado los umbrales", + "allMaintenance": "Todos los sistemas están en mantenimiento", + "allPaused": "Todos los sistemas están en pausa", + "allInitializing": "Todos los sistemas se están inicializando", + "breachedAndDown": "Algunos sistemas han superado los umbrales y están caídos", + "breached": "Algunos sistemas han superado los umbrales", + "maintenanceAndDown": "Algunos sistemas están caídos durante el mantenimiento", + "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" + }, + "table": { + "headers": { + "name": "Nombre de la página de estado", + "url": "URL pública" + }, + "published": "Publicado", + "unpublished": "No publicado" + }, + "title": "Páginas de estado", + "details": { + "empty": { + "title": "Aún no hay nada aquí", + "addMonitor": "Agregue un monitor para comenzar" + } + }, + "form": { + "access": { + "title": "Acceso", + "description": "Si su página de estado está lista, puede marcarla como publicada.", + "option": { + "published": { + "name": "Publicado y visible al público" + } + } + }, + "basicInfo": { + "title": "Información básica", + "description": "Defina el nombre de la empresa y el subdominio al que apunta su página de estado.", + "option": { + "name": { + "label": "Nombre de la empresa", + "placeholder": "Acme Inc." + }, + "url": { + "label": "Dirección de su página de estado", + "placeholder": "mi-pagina-de-estado" + } + } + }, + "monitors": { + "title": "Monitores", + "description": "Seleccione los monitores a mostrar en su página de estado.", + "noMonitors": "No hay monitores seleccionados", + "option": { + "monitors": { + "label": "Seleccionar monitores", + "placeholder": "Buscar y seleccionar monitores..." + } + } + }, + "timezone": { + "title": "Zona horaria", + "description": "Seleccione la zona horaria en la que se mostrará su página de estado.", + "option": { + "timezone": { + "label": "Zona horaria", + "placeholder": "Seleccionar zona horaria..." + } + } + }, + "appearance": { + "title": "Apariencia", + "description": "Defina la apariencia predeterminada de su página de estado pública.", + "option": { + "logo": { + "label": "Logo" + }, + "color": { + "label": "Color de marca" + } + } + }, + "features": { + "title": "Funcionalidades", + "description": "Configure qué información se muestra en su página de estado.", + "option": { + "showCharts": { + "label": "Mostrar gráficos de tiempo de respuesta" + }, + "showUptimePercentage": { + "label": "Mostrar porcentaje de tiempo de actividad" + }, + "showAdminLoginLink": { + "label": "Mostrar enlace de inicio de sesión de administrador" + }, + "showInfrastructure": { + "label": "Mostrar métricas de infraestructura" + } + } + } + }, + "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" + } + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "Buscar monitores..." + } + }, + "table": { + "headers": { + "responseTime": "Tiempo de respuesta" + } + }, + "fallback": { + "actionButton": "¡Cree un monitor!", + "title": "Aún no hay monitores de uptime", + "description": "Observa endpoints HTTP, pings, contenedores y puertos — recibe alertas en el momento en que algo falla." + }, + "header": { + "title": "Monitores de uptime", + "description": "Observa endpoints HTTP, pings, contenedores y puertos — recibe alertas en el momento en que algo falla." } } - }, - "createNotifications": { - "title": "", - "nameSettings": { - "title": "", - "description": "", - "nameLabel": "", - "namePlaceholder": "" - }, - "typeSettings": { - "title": "", - "description": "", - "typeLabel": "" - }, - "emailSettings": { - "title": "", - "description": "", - "emailLabel": "", - "emailPlaceholder": "" - }, - "slackSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "pagerdutySettings": { - "title": "", - "description": "", - "integrationKeyLabel": "", - "integrationKeyPlaceholder": "" - }, - "discordSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "webhookSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "testNotification": "", - "dialogDeleteTitle": "", - "dialogDeleteConfirm": "" - }, - "notificationConfig": { - "title": "", - "description": "" - }, - "monitorStatus": { - "checkingEvery": "", - "withCaptureAgent": "", - "up": "", - "down": "", - "paused": "" - }, - "advancedMatching": "", - "sendTestNotifications": "", - "selectAll": "", - "showAdminLoginLink": "", - "logsPage": { - "title": "", - "description": "", - "tabs": { - "queue": "", - "logs": "", - "diagnostics": "" - }, - "toast": { - "fetchLogsSuccess": "" - }, - "logLevelSelect": { - "title": "", - "values": { - "all": "", - "info": "", - "warn": "", - "error": "", - "debug": "" - } - } - }, - "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", - "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "", - "activeHeader": "", - "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "", - "lastRunTookHeader": "", - "intervalHeader": "" - }, - "metricsTable": { - "title": "", - "metricHeader": "", - "valueHeader": "" - }, - "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", - "failedAtHeader": "", - "failReasonHeader": "" - } - }, - "export": { - "title": "", - "success": "", - "failed": "" - }, - "monitorActions": { - "title": "", - "import": "", - "export": "", - "deleteSuccess": "", - "deleteFailed": "", - "details": "" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "" - }, - "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" - }, - "emailSettings": { - "buttonSendTestEmail": "", - "description": "", - "descriptionTransport": "", - "labelAddress": "", - "labelConnectionHost": "", - "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", - "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", - "labelRequireTLS": "", - "labelSecure": "", - "labelTLSServername": "", - "labelUser": "", - "linkTransport": "", - "placeholderUser": "", - "title": "", - "toastEmailRequiredFieldsError": "" - }, - "pageSpeedSettings": { - "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "" - }, - "saveButtonLabel": "", - "statsSettings": { - "clearAllStatsButton": "", - "clearAllStatsDescription": "", - "clearAllStatsDialogConfirm": "", - "clearAllStatsDialogDescription": "", - "clearAllStatsDialogTitle": "", - "description": "", - "labelTTL": "", - "labelTTLOptional": "", - "title": "" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", - "dialogDescription": "", - "dialogTitle": "", - "title": "" - }, - "timezoneSettings": { - "description": "", - "label": "", - "title": "" - }, - "title": "", - "uiSettings": { - "description": "", - "labelLanguage": "", - "labelTheme": "", - "title": "" - }, - "urlSettings": { - "description": "", - "label": "", - "selectDisabled": "", - "selectEnabled": "", - "title": "" - }, - "globalThresholds": { - "title": "", - "description": "" - } - }, - "statusPageCreate": { - "buttonSave": "" - }, - "incidentsOptionsHeaderFilterResolved": "", - "settingsSave": "", - "statusPageCreateAppearanceTitle": "", - "confirmPassword": "", - "monitorHooks": { - "failureAddDemoMonitors": "", - "successAddDemoMonitors": "" - }, - "settingsAppearance": "", - "settingsDisplayTimezone": "", - "settingsGeneralSettings": "", - "incidentsOptionsHeaderTotalIncidents": "", - "statusPage": { - "deleteSuccess": "", - "deleteFailed": "", - "createSuccess": "", - "updateSuccess": "", - "generalSettings": "", - "contents": "", - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "testNotificationsDisabled": "", - "incidentsTableResolvedAt": "", - "incidentsTableActionResolve": "", - "checkHooks": { - "failureResolveOne": "", - "failureResolveAll": "", - "failureResolveMonitor": "" - }, - "checkFormError": "", - "diagnosticsPage": { - "diagnosticDescription": "", - "statsDescription": "", - "gauges": { - "heapAllocationTitle": "", - "heapAllocationSubtitle": "", - "heapUsageTitle": "", - "heapUsageSubtitle": "", - "heapUtilizationTitle": "", - "heapUtilizationSubtitle": "", - "instantCpuUsageTitle": "", - "instantCpuUsageSubtitle": "" - }, - "stats": { - "eventLoopDelayTitle": "", - "uptimeTitle": "", - "usedHeapSizeTitle": "", - "totalHeapSizeTitle": "", - "osMemoryLimitTitle": "" - } - }, - "pageSpeedLighthouseAPI": "", - "time": { - "threeMinutes": "", - "fiveMinutes": "", - "tenMinutes": "", - "twentyMinutes": "", - "oneHour": "", - "oneDay": "", - "oneWeek": "", - "fourMinutes": "", - "oneMinute": "", - "twoMinutes": "", - "fifteenSeconds": "", - "thirtySeconds": "" - }, - "general": { - "noOptionsFound": "" - }, - "infrastructureMonitor": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "pageSpeed": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "editUserPage": { - "form": { - "email": "", - "firstName": "", - "lastName": "", - "role": "", - "save": "" - }, - "table": { - "actionHeader": "", - "roleHeader": "" - }, - "title": "", - "toast": { - "successUserUpdate": "", - "validationErrors": "" - } - }, - "incidentsPageActionResolveMonitor": "", - "incidentsPageActionResolveAll": "", - "matchMethodOptions": { - "equal": "", - "equalPlaceholder": "", - "include": "", - "includePlaceholder": "", - "regex": "", - "regexPlaceholder": "", - "text": "" - }, - "monitorType": { - "docker": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "http": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "ping": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "port": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "game": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "" - }, - "bytesPerSecond": "", - "bytesReceived": "", - "bytesSent": "", - "chooseGame": "", - "createMonitorPage": { - "incidentConfigDescription": "", - "incidentConfigStatusWindowLabel": "", - "incidentConfigStatusWindowThresholdLabel": "", - "incidentConfigTitle": "", - "incidentConfigDescriptionV2": "", - "incidentConfigStatusCheckNumber": "", - "intervalTitle": "", - "intervalDescription": "" - }, - "dataRate": "", - "dataReceived": "", - "dataSent": "", - "details": "", - "drops": "", - "errors": "", - "errorsIn": "", - "errorsOut": "", - "gameServerMonitoring": "", - "gameServerMonitoringDescription": "", - "network": "", - "networkDrops": "", - "networkErrors": "", - "networkInterface": "", - "noNetworkStatsAvailable": "", - "packetsPerSecond": "", - "packetsReceived": "", - "packetsReceivedRate": "", - "packetsSent": "", - "rate": "", - "selectInterface": "" + } } diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 4949817c7..0fcf71906 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -1,1108 +1,1447 @@ { - "submit": "Lähetä", - "title": "Otsikko", - "distributedStatusHeaderText": "Reaaliaikainen kattavuus oikeilla laitteilla", - "distributedStatusSubHeaderText": "Miljoonien laitteiden voimin ympäri maailman näet järjestelmän suorituskyvyn maailman alueittain, maittain tai kaupungeittain", - "settingsDisabled": "Poistettu käytöstä", - "settingsSuccessSaved": "", - "settingsFailedToSave": "", - "settingsStatsCleared": "", - "settingsFailedToClearStats": "", - "settingsMonitorsDeleted": "", - "settingsFailedToDeleteMonitors": "", - "starPromptTitle": "", - "starPromptDescription": "", - "https": "HTTPS", - "http": "HTTP", - "monitor": "Seurain", - "aboutus": "Tietoja meistä", - "now": "Nyt", - "delete": "Poista", - "configure": "Määritä", - "responseTime": "Vasteaika", - "ms": "ms", - "bar": "Palkki", - "area": "Alue", - "country": "MAA", - "city": "KAUPUNKI", - "response": "Vastaus", - "monitorStatusUp": "Valvontakohde {name} ({url}) on nyt toiminnassa ja vastaa", - "monitorStatusDown": "Valvontakohde {name} ({url}) ei ole toiminnassa, eikä vastaa", - "webhookSendSuccess": "Webhook-ilmoitus lähetettiin onnistuneesti", - "webhookSendError": "Virhe lähetettäessä webhook-ilmoitusta palveluun {platform}", - "webhookUnsupportedPlatform": "Ei tuettu palvelu: {platform}", - "distributedRightCategoryTitle": "Valvonta", - "distributedStatusServerMonitors": "Palvelin valvonta", - "distributedStatusServerMonitorsDescription": "Valvo palvelimien tilaa", - "distributedUptimeCreateSelectURL": "Tässä voit valita isäntäkohteen URL-osoitteen ja valvontatyypin.", - "distributedUptimeCreateChecks": "Suoritettavat tarkistukset", - "distributedUptimeCreateChecksDescription": "Voit aina lisätä tai poistaa tarkistuksia sivuston lisäämisen jälkeen.", - "distributedUptimeCreateIncidentNotification": "Häiriöilmoitukset", - "distributedUptimeCreateIncidentDescription": "Jos ilmenee häiriö, ilmoita käyttäjille.", - "distributedUptimeCreateAdvancedSettings": "Lisäasetukset", - "distributedUptimeDetailsNoMonitorHistory": "Tälle valvontakohteelle ei ole vielä tarkistushistoriaa.", - "distributedUptimeDetailsStatusHeaderUptime": "", - "distributedUptimeDetailsStatusHeaderLastUpdate": "Viimeksi päivitetty", - "notifications": { - "enableNotifications": "", - "testNotification": "Testi-ilmoitus", - "addOrEditNotifications": "Lisää tai muokkaa ilmoituksia", - "slack": { - "label": "Slack", - "description": "", - "webhookLabel": "Webhookin URL", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "discord": { - "label": "Discord", - "description": "", - "webhookLabel": "Discord Webhookin URL-osoite", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "" - }, - "telegram": { - "label": "Telegram", - "description": "", - "tokenLabel": "", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "" - }, - "webhook": { - "label": "Webhookit", - "description": "", - "urlLabel": "Webhookin URL", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "" - }, - "testNotificationDevelop": "Testi-ilmoitus 2", - "integrationButton": "", - "testSuccess": "Testi ilmoituksen lähetys onnistui!", - "testFailed": "Testi ilmoituksen lähetys epäonnistui", - "unsupportedType": "", - "networkError": "", - "fallback": { - "title": "", - "checks": [""], - "actionButton": "" - }, - "createButton": "", - "createTitle": "", - "create": { - "success": "", - "failed": "" - }, - "fetch": { - "success": "", - "failed": "" - }, - "delete": { - "success": "", - "failed": "" - }, - "edit": { - "success": "", - "failed": "" - }, - "test": { - "success": "", - "failed": "" - } - }, - "testLocale": "", - "add": "Lisää", - "monitors": "Seuraimet", - "distributedUptimeStatusCreateStatusPage": "", - "distributedUptimeStatusCreateStatusPageAccess": "", - "distributedUptimeStatusCreateStatusPageReady": "", - "distributedUptimeStatusBasicInfoHeader": "", - "distributedUptimeStatusBasicInfoDescription": "", - "distributedUptimeStatusLogoHeader": "Logo", - "distributedUptimeStatusLogoDescription": "", - "distributedUptimeStatusLogoUploadButton": "", - "distributedUptimeStatusStandardMonitorsHeader": "", - "distributedUptimeStatusStandardMonitorsDescription": "", - "distributedUptimeStatusCreateYour": "", - "distributedUptimeStatusEditYour": "", - "distributedUptimeStatusPublishedLabel": "", - "distributedUptimeStatusCompanyNameLabel": "Yrityksen nimi", - "distributedUptimeStatusPageAddressLabel": "", - "distributedUptimeStatus30Days": "30 päivää", - "distributedUptimeStatus60Days": "60 päivää", - "distributedUptimeStatus90Days": "90 päivää", - "distributedUptimeStatusPageNotSetUp": "", - "distributedUptimeStatusContactAdmin": "Ota yhteys järjestelmävalvojaasi", - "distributedUptimeStatusPageNotPublic": "", - "distributedUptimeStatusPageDeleteDialog": "", - "distributedUptimeStatusPageDeleteConfirm": "", - "distributedUptimeStatusPageDeleteDescription": "", - "distributedUptimeStatusDevices": "", - "distributedUptimeStatusUpt": "", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "UPT logo", - "incidentsTableNoIncidents": "", - "incidentsTablePaginationLabel": "", - "incidentsTableMonitorName": "", - "incidentsTableStatus": "Tila", - "incidentsTableDateTime": "Päivämäärä ja aika", - "incidentsTableStatusCode": "", - "incidentsTableMessage": "Viesti", - "incidentsOptionsHeader": "", - "incidentsOptionsHeaderFilterBy": "", - "incidentsOptionsHeaderFilterAll": "Kaikki", - "incidentsOptionsHeaderFilterDown": "Alhaalla", - "incidentsOptionsHeaderFilterCannotResolve": "", - "incidentsOptionsHeaderShow": "Näytä:", - "incidentsOptionsHeaderLastHour": "Viime tunti", - "incidentsOptionsHeaderLastDay": "Viime päivä", - "incidentsOptionsHeaderLastWeek": "Viime viikko", - "incidentsOptionsPlaceholderAllServers": "Kaikki palvelimet", - "infrastructureCreateYour": "", - "infrastructureCreateGeneralSettingsDescription": "", - "infrastructureServerRequirement": "", - "infrastructureCustomizeAlerts": "", - "infrastructureAlertNotificationDescription": "", - "infrastructureCreateMonitor": "", - "infrastructureProtocol": "Protokolla", - "infrastructureServerUrlLabel": "Palvelimen URL-osoite", - "infrastructureDisplayNameLabel": "", - "infrastructureAuthorizationSecretLabel": "", - "gb": "", - "mb": "", - "mem": "", - "memoryUsage": "Muistinkäyttö", - "cpu": "", - "cpuUsage": "", - "cpuTemperature": "", - "diskUsage": "", - "used": "", - "total": "", - "cores": "", - "frequency": "", - "status": "Tila", - "cpuPhysical": "", - "cpuLogical": "", - "cpuFrequency": "", - "avgCpuTemperature": "", - "memory": "Muisti", - "disk": "", - "uptime": "", - "os": "", - "host": "", - "actions": "", - "integrations": "", - "integrationsPrism": "", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "", - "integrationsDiscord": "", - "integrationsDiscordInfo": "", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "", - "commonSave": "Tallenna", - "createYour": "", - "createMonitor": "", - "pause": "", - "resume": "", - "editing": "", - "url": "", - "access": "", - "timezone": "Aikavyöhyke", - "features": "", - "administrator": "", - "loginHere": "", - "displayName": "", - "urlMonitor": "", - "portToMonitor": "", - "websiteMonitoring": "", - "websiteMonitoringDescription": "", - "pingMonitoring": "", - "pingMonitoringDescription": "", - "dockerContainerMonitoring": "", - "dockerContainerMonitoringDescription": "", - "portMonitoring": "", - "portMonitoringDescription": "", - "createMaintenanceWindow": "", - "createMaintenance": "", - "editMaintenance": "", - "maintenanceWindowName": "", - "friendlyNameInput": "", - "friendlyNamePlaceholder": "", - "maintenanceRepeat": "", - "maintenance": "", - "duration": "", - "addMonitors": "", - "window": "", - "cancel": "Peruuttaa", - "message": "Viesti", - "low": "", - "high": "", - "statusCode": "Tilakoodi", - "date&Time": "Päivämäärä ja aika", - "type": "", - "statusPageName": "", - "publicURL": "Julkinen URL-osoite", - "repeat": "", - "edit": "Muokkaa", - "createA": "", - "remove": "", - "maintenanceWindowDescription": "", - "startTime": "", - "timeZoneInfo": "", - "monitorsToApply": "", - "nextWindow": "", - "notFoundButton": "", - "pageSpeedConfigureSettingsDescription": "", - "monitorDisplayName": "", - "whenNewIncident": "", - "notifySMS": "Ilmoita tekstiviestillä (tulossa pian)", - "notifyEmails": "", - "seperateEmails": "", - "checkFrequency": "", - "matchMethod": "", - "expectedValue": "", - "deleteDialogTitle": "", - "deleteDialogDescription": "", - "pageSpeedMonitor": "", - "shown": "", - "ago": "sitten", - "companyName": "Yrityksen nimi", - "pageSpeedDetailsPerformanceReport": "", - "pageSpeedDetailsPerformanceReportCalculator": "", - "checkingEvery": "", - "statusPageCreateSettings": "", - "basicInformation": "", - "statusPageCreateBasicInfoDescription": "", - "statusPageCreateSelectTimeZoneDescription": "", - "statusPageCreateAppearanceDescription": "", - "statusPageCreateSettingsCheckboxLabel": "", - "statusPageCreateBasicInfoStatusPageAddress": "", - "statusPageCreateTabsContent": "", - "statusPageCreateTabsContentDescription": "", - "statusPageCreateTabsContentFeaturesDescription": "", - "showCharts": "Näytä kaaviot", - "showUptimePercentage": "", - "removeLogo": "Poista logo", - "statusPageStatus": "", - "statusPageStatusContactAdmin": "Ota yhteyttä järjestelmänvalvojaan", - "statusPageStatusNotPublic": "", - "statusPageStatusNoPage": "", - "statusPageStatusServiceStatus": "", - "deleteStatusPage": "", - "deleteStatusPageConfirm": "", - "deleteStatusPageDescription": "", - "uptimeCreate": "", - "uptimeCreateJsonPath": "", - "uptimeCreateJsonPathQuery": "", - "maintenanceTableActionMenuDialogTitle": "", - "infrastructureEditYour": "Muokkaa omaa", - "infrastructureEditMonitor": "", - "infrastructureMonitorCreated": "", - "infrastructureMonitorUpdated": "", - "errorInvalidTypeId": "", - "errorInvalidFieldId": "", - "inviteNoTokenFound": "", - "pageSpeedWarning": "", - "pageSpeedLearnMoreLink": "", - "pageSpeedAddApiKey": "", - "update": "Päivitä", - "invalidFileFormat": "", - "invalidFileSize": "Tiedoston koko on liian suuri!", - "ClickUpload": "", - "DragandDrop": "raahaa ja pudota", - "MaxSize": "Enimmäiskoko", - "SupportedFormats": "Tuetut muodot", - "FirstName": "Etunimi", - "LastName": "Sukunimi", - "EmailDescriptionText": "Tämä on nykyinen sähköpostiosoitteesi — sitä ei voi vaihtaa.", - "YourPhoto": "Profiilikuva", - "PhotoDescriptionText": "", - "save": "Tallenna", - "DeleteDescriptionText": "", - "DeleteAccountWarning": "", - "DeleteWarningTitle": "Haluatko todella poistaa tämän tilin?", - "bulkImport": { - "title": "", - "selectFileTips": "", - "selectFileDescription": "", - "selectFile": "Valitse tiedosto", - "parsingFailed": "", - "uploadSuccess": "", - "validationFailed": "", - "noFileSelected": "Ei valittua tiedostoa", - "fallbackPage": "", - "invalidFileType": "", - "uploadFailed": "" - }, - "DeleteAccountTitle": "Poista tili", - "DeleteAccountButton": "Poista tili", - "publicLink": "Julkinen linkki", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "reset": "Palauta", - "ignoreTLSError": "", - "tlsErrorIgnored": "", - "ignoreTLSErrorDescription": "", - "createNew": "Luo uusi", - "greeting": { - "prepend": "Hei", - "append": "", - "overview": "" - }, - "roles": { - "superAdmin": "Pääylläpitäjä", - "admin": "Ylläpitäjä", - "teamMember": "Tiimin jäsen", - "demoUser": "Demokäyttäjä" - }, - "teamPanel": { - "teamMembers": "Tiimin jäsenet", - "filter": { - "all": "Kaikki", - "member": "Jäsen" - }, - "inviteTeamMember": "", - "inviteNewTeamMember": "", - "inviteDescription": "", - "email": "Sähköposti", - "selectRole": "Valitse rooli", - "inviteLink": "Kutsulinkki", - "cancel": "Peruuttaa", - "noMembers": "", - "getToken": "Hae tunniste", - "emailToken": "Sähköpostitunnus", - "table": { - "name": "Nimi", - "email": "Sähköposti", - "role": "Rooli", - "created": "Luotu" - }, - "addTeamMember": { - "addMemberMenu": "", - "title": "", - "description": "", - "addButton": "" - }, - "register": "", - "registerToast": { - "success": "", - "dbUserExists": "", - "unknownError": "" - }, - "registerTeamMember": { - "title": "", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "lastName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "email": { - "errors": { - "empty": "", - "invalid": "" - } - }, - "role": { - "errors": { - "empty": "" - } - } - } - } - } - }, - "role": "", - "changeTeamPassword": { - "changePasswordMenu": "", - "title": "", - "description": "", - "success": "" - } - }, - "monitorState": { - "paused": "Tauota", - "resumed": "Jatka", - "active": "" - }, - "menu": { - "uptime": "", - "pagespeed": "", - "infrastructure": "Infrastruktuuri", - "incidents": "", - "statusPages": "Tilasivut", - "maintenance": "Huolto", - "integrations": "", - "settings": "Asetukset", - "support": "Tuki", - "discussions": "Keskustelut", - "docs": "Dokumentit", - "changelog": "Muutosloki", - "profile": "Profiili", - "password": "Salasana", - "team": "Tiimi", - "logOut": "Kirjaudu ulos", - "notifications": "", - "logs": "" - }, - "settingsEmailUser": "", - "state": "Tila", - "statusBreadCrumbsStatusPages": "Tilasivut", - "statusBreadCrumbsDetails": "Tiedot", - "commonSaving": "Tallennetaan...", - "navControls": "Ohjaimet", - "incidentsPageTitle": "Häiriöt", - "passwordPanel": { - "passwordChangedSuccess": "Salasanasi vaihdettiin onnistuneesti.", - "passwordInputIncorrect": "", - "currentPassword": "Nykyinen salasana", - "enterCurrentPassword": "Kirjoita nykyinen salasanasi", - "newPassword": "Uusi salasana", - "enterNewPassword": "Kirjoita uusi salasana", - "confirmNewPassword": "Vahvista uusi salasana", - "passwordRequirements": "Uuden salasanan on oltava vähintään 8 merkkiä pitkä ja sen on sisältävä vähintään yksi iso kirjain, yksi pieni kirjain, yksi numero sekä yksi erikoismerkki.", - "saving": "Tallennetaan..." - }, - "emailSent": "Sähköpostin lähetys onnistui", - "failedToSendEmail": "Sähköpostin lähetys epäonnistui", - "settingsTestEmailSuccess": "Testisähköpostin lähetys onnistui", - "settingsTestEmailFailed": "Testisähköpostin lähetys epäonnistui", - "settingsTestEmailFailedWithReason": "Testisähköpostin lähetys epäonnistui: {{reason}}", - "settingsTestEmailUnknownError": "Tuntematon virhe", - "statusMsg": { - "paused": "Valvonta on keskeytetty.", - "up": "Sivusi on ylhäällä.", - "down": "Sivusi on alhaalla.", - "pending": "Odottaa..." - }, - "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "", - "game": "", - "https": "" - }, "common": { - "appName": "", - "monitoringAgentName": "", - "buttons": { - "toggleTheme": "" + "auth": { + "roles": { + "admin": "Ylläpitäjä", + "demo": "Demo", + "superadmin": "Pääylläpitäjä", + "user": "Käyttäjä" + } }, - "toasts": { - "networkError": "", - "checkConnection": "", - "unknownError": "" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "", - "back": "" + "alerts": { + "pageSpeedApiKey": { + "content": "Varoitus: Et ole vielä lisännyt Google PageSpeed API -avainta. Siirry Asetuksiin lisätäksesi sellainen. Ilman sitä PageSpeed-monitorointi ei toimi." + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "Tiedot", + "home": "Etusivu" + }, + "buttons": { + "addMember": "Lisää jäsen", + "cancel": "Peruuta", + "close": "Sulje", + "configure": "Määritä", + "confirm": "Vahvista", + "create": "Luo", + "delete": "Poista", + "generateToken": "Luo tunniste", + "incidents": "Häiriöt", + "inviteMember": "Kutsu jäsen", + "pause": "Keskeytä", + "resume": "Jatka", + "save": "Tallenna", + "sendInvite": "Lähetä kutsu", + "test": "Testaa", + "testNotifications": "Testaa ilmoitukset", + "toggleTheme": "Vaihda vaalea / tumma", + "flushQueue": "Tyhjennä jono", + "notFound": "Siirry pääsivulle", + "resetPassword": "Nollaa salasana", + "reset": "Palauta", + "clear": "Tyhjennä", + "addDemo": "Lisää demomonitorit", + "removeMonitors": "Poista monitorit", + "sendTestEmail": "Lähetä testisähköposti", + "exportToJSON": "Vie JSON-muotoon", + "importFromJSON": "Tuo JSON-muodosta", + "clearFilters": "Tyhjennä suodattimet", + "removeUser": "Poista käyttäjä" + }, + "charts": { + "labels": { + "averageResponseTime": "Keskimääräinen vasteaika", + "downtime": "Käyttökatko", + "high": "Korkea", + "low": "Matala", + "uptime": "Käytettävyys" }, - "inputs": { - "email": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "invalid": "" - } - }, - "password": { - "label": "", - "rules": { - "length": { - "beginning": "", - "highlighted": "" - }, - "special": { - "beginning": "", - "highlighted": "" - }, - "number": { - "beginning": "", - "highlighted": "" - }, - "uppercase": { - "beginning": "", - "highlighted": "" - }, - "lowercase": { - "beginning": "", - "highlighted": "" - }, - "match": { - "beginning": "", - "highlighted": "" - } - }, - "errors": { - "empty": "", - "length": "", - "uppercase": "", - "lowercase": "", - "number": "", - "special": "", - "incorrect": "" - } - }, - "passwordConfirm": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "different": "" - } - }, - "firstName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - }, - "lastName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" + "histogram": { + "avg": "Keskim.: {{value}} ms", + "max": "Maks.: {{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "Tätä toimintoa ei voi kumota.", + "title": "Haluatko varmasti poistaa tämän?" + } + }, + "labels": { + "active": "Aktiivinen", + "paused": "keskeytetty", + "na": "Ei saatavilla", + "resolved": "Ratkaistu", + "responseTime": "Vasteaika" + }, + "form": { + "role": { + "option": { + "role": { + "label": "Roolit" } } }, - "errors": { - "validation": "" - }, - "fields": { - "password": { - "errors": { - "incorrect": "" + "name": { + "option": { + "firstName": { + "label": "Etunimi", + "placeholder": "Anna etunimesi" + }, + "lastName": { + "label": "Sukunimi", + "placeholder": "Anna sukunimesi" } - }, - "role": { - "errors": { - "min": "" + } + }, + "email": { + "option": { + "email": { + "label": "Sähköposti", + "placeholder": "you@example.com" } } } }, - "login": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "" - }, - "links": { - "forgotPassword": "", - "register": "", - "forgotPasswordLink": "", - "registerLink": "" - }, - "toasts": { - "success": "", - "incorrectPassword": "" - }, + "table": { + "empty": "Täällä ei ole mitään", + "headers": { + "actions": "Toiminnot", + "dateTime": "Päivämäärä ja aika", + "message": "Viesti", + "monitor": "Monitori", + "monitorId": "Monitorin tunnus", + "name": "Nimi", + "status": "Tila", + "type": "Tyyppi", + "url": "Osoite", + "interval": "Aikaväli", + "active": "Aktiivinen", + "responseTime": "Vasteaika" + } + } + }, + "components": { + "imageUpload": { + "clickToUpload": "Napsauta ladataksesi", + "dragAndDrop": "raahaa ja pudota", + "supportedFormats": "Tuetut muodot", + "maxSize": "Enimmäiskoko", + "orDragAndDrop": "tai vedä ja pudota", "errors": { - "password": { - "incorrect": "" - } - }, - "welcome": "" + "invalidFileSize": "Tiedoston koko on liian suuri!", + "invalidFileFormat": "Tiedostomuotoa ei tueta!" + } }, - "registration": { - "heading": { - "superAdmin": "", - "user": "" - }, - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "" + "headerStatusPageControls": { + "publicLink": "Julkinen linkki" + }, + "headerTimeRange": { + "labels": { + "day": "Päivä", + "month": "Kuukausi", + "recent": "Viimeisimmät", + "week": "Viikko" }, "description": { - "superAdmin": "", - "user": "" + "recent": "Näytetään tilastot viimeisiltä 2 tunnilta.", + "day": "Näytetään tilastot viimeisiltä 24 tunnilta.", + "week": "Näytetään tilastot viimeisiltä 7 päivältä.", + "month": "Näytetään tilastot viimeisiltä 30 päivältä." + } + }, + "offlineBanner": { + "serverUnreachable": "Palvelimeen ei saada yhteyttä", + "retry": "Yritä uudelleen", + "retrying": "Yritetään uudelleen...", + "reconnected": "Yhteys palautettu" + }, + "sidebar": { + "menu": { + "uptime": "Käytettävyys", + "pagespeed": "Pagespeed", + "infrastructure": "Infrastruktuuri", + "notifications": "Ilmoitukset", + "checks": "Tarkistukset", + "incidents": "Häiriöt", + "statusPages": "Tilasivut", + "maintenance": "Huolto", + "logs": "Lokit", + "settings": "Asetukset" }, - "gettingStartedButton": { - "superAdmin": "", - "user": "" + "bottomMenu": { + "support": "Tuki", + "discussions": "Keskustelut", + "docs": "Dokumentit", + "changelog": "Muutosloki" }, - "termsAndPolicies": "", + "accountMenu": { + "profile": "Profiili", + "password": "Salasana", + "team": "Tiimi" + }, + "starPrompt": { + "title": "Lisää Checkmate suosikkeihin", + "description": "Katso uusimmat julkaisut ja auta kasvattamaan yhteisöä GitHubissa" + }, + "authFooter": { + "navControls": "Ohjaimet", + "logOut": "Kirjaudu ulos", + "roles": { + "superAdmin": "Pääylläpitäjä", + "admin": "Ylläpitäjä", + "user": "Käyttäjä", + "demoUser": "Demokäyttäjä" + } + } + }, + "starPrompt": { + "title": "Lisää Checkmate suosikkeihin", + "description": "Katso uusimmat julkaisut ja auta kasvattamaan yhteisöä GitHubissa" + }, + "pageHeader": { "links": { - "login": "" - }, - "toasts": { - "success": "" - }, - "welcome": "" - }, - "forgotPassword": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "", - "stepFour": "" - }, - "buttons": { - "openEmail": "", - "resetPassword": "" - }, - "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" - }, - "links": { - "login": "", - "resend": "" - }, - "toasts": { - "sent": "", - "emailNotFound": "", - "redirect": "", - "success": "", - "error": "" + "support": "Tuki", + "discussions": "Keskustelut", + "docs": "Dokumentaatio", + "changelog": "Muutosloki" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "", - "stillUnreachable": "" + "pages": { + "notFound": { + "title": "Voi ei! Sushisi putosi!", + "subtitle": "Joko URL-osoitetta ei ole olemassa tai sinulla ei ole pääsyä siihen." + }, + "account": { + "tabs": { + "profile": "Profiili", + "password": "Salasana", + "team": "Tiimi" }, - "alertBox": "", - "description": "", - "retryButton": { - "default": "", - "processing": "" + "form": { + "name": { + "title": "Nimi", + "description": "Päivitä henkilökohtaiset tietosi" + }, + "photo": { + "title": "Profiilikuva", + "description": "Lataa profiilikuva" + }, + "currentPassword": { + "title": "Nykyinen salasana", + "description": "Syötä nykyinen salasanasi henkilöllisyytesi vahvistamiseksi", + "option": { + "label": "Nykyinen salasana", + "placeholder": "Syötä nykyinen salasana" + } + }, + "newPassword": { + "title": "Uusi salasana", + "description": "Valitse vahva salasana, jossa on vähintään 8 merkkiä", + "option": { + "newPassword": { + "label": "Uusi salasana", + "placeholder": "Syötä uusi salasana" + }, + "confirm": { + "label": "Vahvista salasana", + "placeholder": "Vahvista uusi salasana" + } + } + }, + "deleteAccount": { + "title": "Poista tili", + "description": "Tämä toiminto on pysyvä eikä sitä voi kumota" + } + }, + "team": { + "filter": { + "placeholder": "Suodata roolin mukaan", + "all": "Kaikki", + "admin": "Ylläpitäjä", + "member": "Jäsen" + }, + "table": { + "headers": { + "email": "Sähköposti", + "role": "Rooli", + "created": "Luotu" + } + }, + "addMember": { + "title": "Rekisteröi uusi tiimin jäsen", + "description": "Luo uusi käyttäjätili. Jaa tunnukset turvallisesti luomisen jälkeen." + }, + "invite": { + "title": "Kutsu tiimin jäsen", + "description": "Lähetä kutsu liittyä tiimiisi", + "email": { + "label": "Sähköpostiosoite", + "placeholder": "Syötä sähköpostiosoite" + }, + "role": { + "label": "Rooli", + "placeholder": "Valitse rooli" + }, + "linkLabel": "Kutsulinkki" + }, + "fallback": { + "title": "Ei vielä tiimin jäseniä", + "description": "Kutsu tiimin jäsenet sähköpostitse tai lisää heidät suoraan. Kun he liittyvät, he näkyvät tässä roolinsa kanssa." + } + }, + "header": { + "title": "Tili", + "description": "Hallitse profiilia, salasanaa, tiimiä ja työtilan asetuksia." + } + }, + "auth": { + "brandPanel": { + "eyebrow": "Avoimen lähdekoodin infrastruktuurin valvonta", + "tagline": "Seuraa palvelimiesi jokaista liikettä.", + "description": "Reaaliaikainen käytettävyyden, vasteajan ja häiriöiden valvonta - itse isännöity ja sinun." + }, + "common": { + "passwordRules": { + "length": { + "beginning": "", + "highlighted": "" + }, + "lowercase": { + "beginning": "", + "highlighted": "" + }, + "match": { + "beginning": "", + "highlighted": "" + }, + "number": { + "beginning": "", + "highlighted": "" + }, + "special": { + "beginning": "", + "highlighted": "" + }, + "uppercase": { + "beginning": "", + "highlighted": "" + } + }, + "form": { + "option": { + "email": { + "label": "Sähköposti", + "placeholder": "me@example.com" + }, + "password": { + "label": "Salasana", + "placeholder": "********" + }, + "confirmPassword": { + "label": "Vahvista salasana" + } + } + } + }, + "login": { + "title": "Tervetuloa takaisin Checkmateen!", + "subtitle": "Kirjaudu sisään jatkaaksesi", + "submit": "Kirjaudu sisään", + "links": { + "forgotPassword": { + "text": "Unohditko salasanan?", + "linkText": "Nollaa salasana" + }, + "register": { + "text": "Eikö sinulla ole tiliä?", + "linkText": "Rekisteröidy täällä" + } + } + }, + "register": { + "title": "Tervetuloa takaisin Checkmateen!", + "subtitle": "Rekisteröidy aloittaaksesi", + "submit": "Rekisteröidy", + "setupNotice": "Tämä sivu on käytettävissä vain alkuasennuksen aikana. Kun super-admin on rekisteröity, lisäkäyttäjiä voi kutsua tiimiasetuksista." + }, + "forgotPassword": { + "title": "Unohditko salasanasi?", + "subtitle": "Ei hätää, lähetämme sinulle nollausohjeet.", + "submit": "Pyydä palautusta", + "links": { + "login": { + "text": "Palaa", + "linkText": "kirjautumiseen" + } + } + }, + "setNewPassword": { + "title": "Nollaa salasanasi", + "subtitle": "Uuden salasanasi on oltava eri kuin aiemmin käytetyt salasanat." + } + }, + "checks": { + "selects": { + "monitor": { + "all": "Kaikki monitorit" + }, + "status": { + "all": "Kaikki", + "down": "Alhaalla", + "up": "Ylhäällä" + } + }, + "table": { + "empty": "Ei alhaalla-tarkistuksia tällä aikavälillä", + "headers": { + "statusCode": "Tilakoodi", + "location": "Sijainti" + } + }, + "fallback": { + "title": "Ei vielä tarkistuksia", + "description": "Kun luot monitorin, jokainen tilatarkistus ja vasteaika näkyy täällä tarkistuksena." + }, + "header": { + "title": "Tarkistukset", + "description": "Jokainen tarkistus, jonka Checkmate suorittaa monitoreitasi vastaan, näkyy täällä tilakoodin ja vasteajan kanssa." + } + }, + "common": { + "monitors": { + "actions": { + "configure": "Määritä", + "delete": "Poista", + "generateToken": "Luo tunniste", + "details": "Tiedot", + "incidents": "Häiriöt", + "inviteMember": "Kutsu jäsen", + "openSite": "Avaa sivusto", + "pause": "Keskeytä", + "resume": "Jatka", + "selectAll": "Valitse kaikki näytöt", + "selectMonitor": "Valitse {{name}}", + "bulkSelected_one": "{{count}} näyttö valittu", + "bulkSelected_other": "{{count}} näyttöä valittu" + }, + "statBoxes": { + "activeFor": "Aktiivinen", + "certificateExpiry": "Varmenteen vanheneminen", + "lastCheck": "Viimeisin tarkistus", + "lastResponseTime": "Viimeisin vasteaika", + "serviceIsDown": "Palvelu ei ole käytettävissä" + }, + "status": { + "down": "alhaalla", + "breached": "kynnysarvo ylitetty", + "initializing": "alustamassa", + "maintenance": "huollossa", + "paused": "keskeytetty", + "total": "yhteensä", + "up": "ylhäällä" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "Peli", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "Portti", + "optionPagespeed": "PageSpeed", + "optionHardware": "Infrastruktuuri", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} näyttö keskeytetty", + "paused_other": "{{count}} näyttöä keskeytetty", + "resumed_one": "{{count}} näyttö jatkettu", + "resumed_other": "{{count}} näyttöä jatkettu", + "alreadyPaused_one": "Valittu näyttö on jo keskeytetty", + "alreadyPaused_other": "Kaikki valitut näytöt on jo keskeytetty", + "alreadyRunning_one": "Valittu näyttö on jo käynnissä", + "alreadyRunning_other": "Kaikki valitut näytöt ovat jo käynnissä" + } + } + }, + "createMonitor": { + "form": { + "advanced": { + "description": "Valinnaisia asetuksia edistyneille käyttötapauksille", + "option": { + "advancedMatching": { + "label": "Käytä edistynyttä vastaavuutta" + }, + "expectedValue": { + "label": "Odotettu arvo" + }, + "jsonPath": { + "description": "Tämä lauseke arvioidaan vastauksen JSON-dataa vasten ja tulosta käytetään odotetun arvon vertailuun. Katso jmespath.org kyselykielen dokumentaatio.", + "label": "JSONPath-lauseke" + }, + "matchMethod": { + "label": "Vertailutapa", + "equal": "", + "include": "", + "regex": "" + } + }, + "title": "Lisäasetukset" + }, + "frequency": { + "description": "Kuinka usein haluat tarkistaa tämän monitorin tilan?", + "option": { + "frequency": { + "label": "Tarkistusväli", + "value": { + "fifteenMinutes": "15 minuuttia", + "fifteenSeconds": "", + "fiveMinutes": "5 minuuttia", + "fourMinutes": "", + "oneMinute": "", + "tenMinutes": "10 minuuttia", + "thirtyMinutes": "30 minuuttia", + "thirtySeconds": "", + "threeMinutes": "", + "twoMinutes": "" + } + } + }, + "title": "Tarkistusväli" + }, + "general": { + "option": { + "container": { + "label": "Kontin nimi/tunnus", + "placeholder": "my-app tai abcd1234" + }, + "host": { + "label": "Isäntä", + "placeholder": "192.168.1.100 tai example.com" + }, + "name": { + "label": "Näyttönimi", + "placeholder": "esim. Oma verkkosivusto" + }, + "secret": { + "label": "Valtuutusavain", + "placeholder": "Syötä salausavaimesi" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "", + "placeholder": "" + }, + "port": { + "label": "Monitoroitava portti", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "Palvelun nimi", + "placeholder": "esim. my.service.v1 (jätä tyhjäksi yleistä tilaa varten)" + }, + "wsUrl": { + "label": "WebSocket URL", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture-päätepiste", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "Yleiset asetukset", + "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": "", + "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": "Seuraa infrastruktuurisi suorittimen, muistin, levyn käyttöä ja lämpötilaa. Capture-päätepisteen URL-osoitteen on sisällettävä koko polku /api/v1/metrics (esim. http://192.168.1.10:5001/api/v1/metrics). Liitä valtuutusavaimeen vain tokenin merkkijono — älä sisällytä sanaa 'Bearer'." + } + }, + "ignoreTls": { + "description": "Määritä TLS/SSL-varmenteen vahvistus HTTPS-yhteyksiä varten.", + "option": { + "tls": { + "label": "Ohita TLS/SSL-virheet" + } + }, + "title": "TLS/SSL-asetukset" + }, + "incidents": { + "description": "Liukuva ikkuna määrittää, milloin monitori katsotaan alhaalla olevaksi. Monitorin tila muuttuu vasta, kun tarkistusten prosenttiosuus liukuvassa ikkunassa saavuttaa määritellyn arvon.", + "option": { + "checks": { + "label": "Tarkistusten määrä liukuvassa ikkunassa" + }, + "percentage": { + "label": "Kuinka suuren osuuden tarkistuksista liukuvassa ikkunassa on epäonnistuttava/onnistuttava ennen kuin monitorin tila muuttuu?" + } + }, + "title": "Häiriöt" + }, + "notifications": { + "description": "Valitse ilmoituskanavat, joita haluat käyttää", + "title": "Ilmoitukset" + }, + "type": { + "description": "Valitse suoritettavan tarkistuksen tyyppi", + "optionDockerDescription": "Käytä Dockeria monitoroimaan, onko kontti käynnissä.", + "optionGameDescription": "Monitoroi, onko tietty pelipalvelin verkossa.", + "optionGrpcDescription": "Monitoroi gRPC-palveluita standardin Health Checking Protocol -protokollan avulla.", + "optionWebSocketDescription": "Monitoroi WebSocket-päätepisteitä yhteyden kunnon ja vasteajan osalta.", + "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" + }, + "thresholds": { + "title": "Hälytysrajat", + "description": "Määritä kynnysarvot, joiden ylittyessä hälytykset laukaistaan tälle laitemonitorille.", + "option": { + "cpuThreshold": { + "label": "CPU-hälytysraja (%)" + }, + "memoryThreshold": { + "label": "Muistin hälytysraja (%)" + }, + "diskThreshold": { + "label": "Levyn hälytysraja (%)" + }, + "tempThreshold": { + "label": "Lämpötilan hälytysraja (°C)" + } + } + }, + "geoChecks": { + "title": "Maantieteellisesti hajautetut tarkistukset", + "description": "Suorita tarkistuksia useista maantieteellisistä sijainneista globaalin saatavuuden ja suorituskyvyn monitoroimiseksi.", + "option": { + "enabled": { + "label": "Ota käyttöön maantieteellisesti hajautetut tarkistukset" + }, + "locations": { + "label": "Sijainnit", + "placeholder": "Valitse sijainnit", + "options": { + "EU": "Eurooppa", + "NA": "Pohjois-Amerikka", + "AS": "Aasia", + "SA": "Etelä-Amerikka", + "AF": "Afrikka", + "OC": "Oseania" + } + }, + "interval": { + "label": "Tarkistusväli", + "value": { + "fiveMinutes": "5 minuuttia", + "tenMinutes": "10 minuuttia", + "fifteenMinutes": "15 minuuttia", + "thirtyMinutes": "30 minuuttia" + } + } + } + }, + "url": { + "title": "Monitorin IP/URL tilasivulla", + "description": "Näytä monitorin IP-osoite tai URL julkisella tilasivulla. Jos se on poistettu käytöstä, vain monitorin nimi näytetään arkaluonteisten tietojen suojaamiseksi.", + "option": { + "showURL": { + "label": "Näytä IP/URL tilasivulla", + "enabled": "Käytössä", + "disabled": "Pois käytöstä" + } + } + }, + "stats": { + "title": "Monitorointihistoria", + "description": "Tyhjennä kaikki tiimisi monitorointihistoria ja tilastot. Tätä toimintoa ei voi kumota.", + "buttonText": "Tyhjennä kaikki tilastot", + "dialog": { + "title": "Tyhjennetäänkö kaikki monitorointihistoria?", + "description": "Tämä poistaa pysyvästi kaikki tiimisi monitorointihistorian, tilastot ja tarkistustiedot. Tätä toimintoa ei voi kumota.", + "confirm": "Kyllä, tyhjennä kaikki tilastot" + } + } + } + }, + "editUser": { + "form": { + "roles": { + "title": "Roolit", + "description": "Määritä käyttäjälle roolit. Useita rooleja voi valita." + } + }, + "dialog": { + "removeUser": { + "title": "Poista käyttäjä", + "content": "Haluatko varmasti poistaa käyttäjän {{name}} tiimistäsi? Tätä toimintoa ei voi kumota." + } + } + }, + "incidents": { + "fallback": { + "title": "Ei tapauksia — kaikki järjestelmät ovat kunnossa", + "description": "Kun monitori kaatuu tai palautuu, tapaus tulee näkyviin ajoituksen, monitorin tietojen ja ratkaisuhistorian kanssa." + }, + "dialog": { + "resolveIncident": { + "title": "Ratkaise häiriö", + "option": { + "comment": { + "label": "Kommentti (valinnainen)", + "placeholder": "Lisää kommentti ratkaisusta..." + } + } + }, + "details": { + "analysis": "Häiriöanalyysi", + "comment": "Kommentti:", + "detailsLabel": "Tiedot", + "downtime": "Käyttökatko:", + "message": "Viesti:", + "monitor": "Monitori:", + "overview": "Yleiskatsaus", + "resolutionDetails": "Ratkaisun tiedot", + "resolutionType": "Tyyppi:", + "resolutionTypes": { + "automatic": "Automaattinen", + "manual": "Manuaalinen" + }, + "resolve": "Ratkaise häiriö", + "resolvedAt": "Ratkaistu:", + "resolvedBy": "Ratkaisija:", + "startedAt": "Alkanut:", + "status": "Tila:", + "statusCode": "Tilakoodi:", + "timeline": "Aikajana", + "title": "Häiriön tiedot", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "Kaikki monitorit", + "monitor": "Monitori", + "resolutionType": "Ratkaisun tyyppi", + "resolutionTypes": { + "manual": "Manuaalinen", + "automatic": "Automaattinen", + "all": "Kaikki" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "Aktiiviset häiriöt", + "active_zero": "Ei aktiivisia häiriöitä", + "active_one": "{{count}} aktiivinen häiriö", + "active_other": "{{count}} aktiivista häiriötä" + }, + "incidentStats": { + "avgResolutionTime": "Keskim. ratkaisuaika", + "mostAffectedMonitor": "Eniten häiriöitä kokenut monitori", + "title": "Häiriötilastot", + "totalIncidents": "Häiriöitä yhteensä" + }, + "latestIncidents": { + "title": "Viimeisimmät häiriöt" + } + }, + "table": { + "actions": { + "details": "Tiedot", + "goToMonitor": "Siirry monitoriin", + "resolveManually": "Ratkaise manuaalisesti" + }, + "activeIncidents": "Aktiiviset häiriöt", + "headers": { + "endTime": "Päättymisaika", + "resolutionType": "Ratkaisun tyyppi", + "startTime": "", + "statusCode": "Tilakoodi" + }, + "resolvedIncidents": "Ratkaistut häiriöt", + "status": { + "active": "Aktiivinen", + "resolved": "Ratkaistu" + } + }, + "header": { + "title": "Tapaukset", + "description": "Kun monitori kaatuu tai palautuu, tapaukset näkyvät täällä ajoituksen, monitorin tietojen ja ratkaisuhistorian kanssa." + } + }, + "infrastructure": { + "charts": { + "labels": { + "cpu": "CPU:n käyttö", + "disk": "Levyn käyttö", + "memory": "Muistin käyttö", + "netBytesRecv": "{{name}} - tavua vastaanotettu", + "netBytesSent": "{{name}} - tavua lähetetty", + "netNoTraffic": "Capture ilmoittaa nollaliikennettä kaikilla rajapinnoilla. Tämä on normaalia, kun Capture käynnistetään säiliössä, joka näkee vain loopback-rajapinnat — suorita Capture suoraan isännällä nähdäksesi todellisen verkkoaktiviteetin.", + "temp": "Lämpö" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "Enimmäistaajuus", + "title": "CPU:n käyttö", + "upperLabel": "Nykyinen taajuus" + }, + "disk": { + "lowerLabel": "Vapaa", + "title": "Levy {{idx}} käyttö", + "upperLabel": "Käytetty" + }, + "memory": { + "lowerLabel": "Vapaa", + "title": "Muistin käyttö", + "upperLabel": "Käytetty" + } + }, + "statBoxes": { + "avgCpuTemperature": "", + "cpuFrequency": "", + "cpuLogical": "", + "cpuPhysical": "", + "disk": "Levy", + "memory": "Muisti", + "os": "" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "Levy", + "memory": "Muisti" + } + }, + "tabs": { + "labels": { + "network": "", + "overview": "Yleiskatsaus" + } + }, + "fallback": { + "actionButton": "Luo monitori!", + "title": "Ei vielä infrastruktuurimonitoreita", + "description": "Seuraa palvelinten suorituskykyä, tunnista pullonkauloja ja varmista luotettavuus reaaliaikaisella valvonnalla." + }, + "header": { + "title": "Infrastruktuurimonitorit", + "description": "Seuraa palvelinten suorituskykyä, tunnista pullonkauloja ja varmista luotettavuus reaaliaikaisella valvonnalla." + } + }, + "logs": { + "fallback": { + "title": "Ei vielä lokeja", + "description": "Palvelimen toiminta, varoitukset ja virheet näkyvät täällä Checkmaten toimiessa." + }, + "tabs": { + "diagnostics": "Diagnostiikka", + "logs": "Lokit", + "queue": "Työjono" + }, + "logLevelSelect": { + "label": "Lokitaso" + }, + "jobQueue": "Työnojono", + "jobQueueExplainer": "Korostetut rivit ovat työt, joita työntekijä käsittelee parhaillaan.", + "failedJobs": "Epäonnistuneet työt", + "failedJobsExplainer": "Viimeisimmät epäonnistumiset valvojan mukaan — viimeisin epäonnistumisaika, epäonnistumismäärä ja syy.", + "noLogs": "Lokeja ei löytynyt", + "metrics": { + "jobs": "Työt", + "activeJobs": "Aktiiviset työt", + "failingJobs": "Epäonnistuvat työt", + "totalRuns": "Suoritukset yhteensä", + "totalFailures": "Epäonnistumiset yhteensä" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "Tapahtumasilmukan viive", + "uptime": "Käytettävyys", + "usedHeapSize": "Käytetty keon koko", + "totalHeapSize": "Keon kokonaiskoko", + "osMemoryLimit": "Käyttöjärjestelmän muistiraja" + }, + "gauges": { + "heapAllocation": "Keon varaus", + "heapUsage": "Keon käyttö", + "heapUtilization": "Keon hyödyntäminen", + "instantCpuUsage": "Hetkellinen CPU:n käyttö", + "availableMemoryPercentage": "% vapaasta muistista", + "allocatedPercentage": "% varattu", + "usedSPercentage": "% sekunnista CPU:n käytössä", + "total": "Yhteensä", + "used": "Käytetty" + }, + "sections": { + "runtimeStats": { + "title": "Suoritusajan tilastot", + "description": "Node.js-prosessin ja isäntäkoneen tilannekuva. Päivittyy 5 sekunnin välein." + }, + "memoryCpu": { + "title": "Muisti ja CPU", + "description": "Miten prosessi käyttää sille varattua muistia ja isännän CPU:ta." + } + } + }, + "table": { + "headers": { + "timestamp": "Aikaleima", + "level": "Taso", + "service": "Palvelu", + "method": "Metodi", + "monitorId": "Monitorin tunnus", + "runCount": "Suorituskerrat", + "failCount": "Epäonnistumiskerrat", + "lastRunAt": "Viimeisin suoritus", + "lockedAt": "Lukittu", + "lastFinishedAt": "Viimeisin valmistuminen", + "lastRunTook": "Viimeisin suoritus kesti", + "lastFailedAt": "Viimeisin epäonnistuminen", + "failReason": "Epäonnistumisen syy" + } + }, + "queueFallback": { + "title": "Jonossa ei ole töitä", + "description": "Aikataulutetut tai käynnissä olevat monitoritarkistukset näkyvät täällä. Tyhjä jono tarkoittaa, että kaikki on ajan tasalla." + }, + "header": { + "title": "Lokit ja jono", + "description": "Tarkasta palvelinlokit, monitorin työjono ja ajonaikaiset diagnostiikat yhdestä paikasta." + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "Luo huoltoikkuna!", + "title": "Ei suunniteltuja huoltokatkoja", + "description": "Aikatauluta suunnitellut katkot, jotta hälytykset vaimennetaan ja tiimisi sekä käyttäjät tietävät mitä odottaa." + }, + "table": { + "headers": { + "nextWindow": "", + "repeat": "" + } + }, + "form": { + "general": { + "title": "Yleiset asetukset", + "description": "Aseta nimi ja toistovalinta huoltoikkunallesi.", + "option": { + "name": { + "label": "Nimi", + "placeholder": "esim. Viikoittainen huolto" + }, + "repeat": { + "label": "Toista" + } + } + }, + "startDate": { + "title": "Alkamispäivä", + "description": "Valitse huoltoikkunan alkamispäivä.", + "option": { + "startDate": { + "label": "Alkamispäivä" + } + } + }, + "startTime": { + "title": "Alkamisaika", + "description": "Aseta huoltoikkunan alkamisaika ja kesto. Kaikki arvot ovat UTC-aikaa.", + "option": { + "duration": { + "label": "Kesto" + }, + "startTime": { + "label": "Alkamisaika" + } + }, + "monitors": { + "title": "Monitorit", + "description": "Monitorit, joihin huoltoikkuna kohdistuu", + "option": { + "addMonitors": { + "label": "Lisää monitorit" + } + } + } + } + }, + "header": { + "title": "Huoltoikkunat", + "description": "Aikatauluta suunnitellut katkot, jotta hälytykset vaimennetaan ja tiimisi sekä käyttäjät tietävät mitä odottaa." + } + }, + "notifications": { + "fallback": { + "actionButton": "Luo kanava", + "title": "Ei vielä ilmoituskanavia", + "description": "Yhdistä Slack, Discord, sähköposti, webhookit ja muut, jotta oikeat ihmiset kuulevat tapauksista heti." + }, + "form": { + "accessToken": { + "optionAccessToken": "Pääsytunniste", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "Osoite, johon ilmoitukset lähetetään.", + "optionAddress": "Osoite", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "Osoite" + }, + "homeServer": { + "optionHomeServer": "Kotipalvelin", + "placeholder": "example.com" + }, + "matrix": { + "description": "Määritä Matrix-kotipalvelimen yhteys ilmoituksia varten.", + "title": "Matrix-määritykset" + }, + "notificationName": { + "description": "Kuvaava nimi ilmoituskanavalle", + "optionName": "Kanavan nimi", + "placeholder": "esim. Tuotantohälytykset", + "title": "Kanavan nimi" + }, + "pagerDuty": { + "description": "PagerDuty-integrointiavaimesi hälytysten vastaanottamiseen.", + "optionIntegrationKey": "Integrointiavain", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "Integrointiavain" + }, + "roomId": { + "optionRoomId": "Huoneen tunnus", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "Valitse luotavan ilmoituskanavan tyyppi.", + "optionType": "Tyyppi", + "title": "Kanavan tyyppi" + }, + "telegram": { + "title": "Telegram-määritykset", + "description": "", + "optionBotToken": "Bottitunniste", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "Chat-tunnus", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Pushover-määritys", + "description": "Anna Pushover-sovellustunnus ja käyttäjäavain.", + "optionAppToken": "Sovellustunnus", + "placeholderAppToken": "Anna sovellustunnus", + "optionUserKey": "Käyttäjäavain", + "placeholderUserKey": "Anna käyttäjäavain" + }, + "twilio": { + "title": "Twilio-määritys", + "description": "Anna Twilio-tilin tunnistetiedot tekstiviestien lähettämistä varten.", + "optionAccountSid": "Tilin SID", + "placeholderAccountSid": "Anna tilin SID", + "optionAuthToken": "Todennustunnus", + "placeholderAuthToken": "Anna todennustunnus", + "optionFromNumber": "Lähettäjän numero", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "Vastaanottajan numero", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "Kohde" + } + }, + "header": { + "title": "Ilmoitukset", + "description": "Yhdistä Slack, Discord, sähköposti, webhookit ja muut, jotta oikeat ihmiset kuulevat tapauksista heti." + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "Kumulatiivinen asettelun muutos (CLS)", + "fcp": "Ensimmäinen sisältöpiirto (FCP)", + "lcp": "Suurin sisältöpiirto (LCP)", + "si": "Nopeusindeksi (SI)", + "tbt": "Kokonaisestymisaika (TBT)" + }, + "legend": { + "title": "PageSpeed-raportti", + "weight": "Painoarvo" + }, + "pie": { + "title": "Suorituskykyraportti" + } + }, + "table": { + "headers": { + "pageSpeedScore": "PageSpeed-pisteet" + } + }, + "fallback": { + "actionButton": "Luo monitori!", + "title": "Ei vielä PageSpeed-monitoreita", + "description": "Seuraa Core Web Vitals -mittareita ja Lighthouse-pisteitä ajan mittaan ja huomaa heikennykset ennen käyttäjiä." + }, + "header": { + "title": "PageSpeed-monitorit", + "description": "Seuraa Core Web Vitals -mittareita ja Lighthouse-pisteitä ajan mittaan ja huomaa heikennykset ennen käyttäjiä." + } + }, + "settings": { + "form": { + "timezone": { + "title": "Näytettävä aikavyöhyke", + "description": "Valitse aikavyöhyke, jota käytetään päivämäärien ja aikojen näyttämiseen sovelluksessa.", + "option": { + "timezone": { + "label": "Näytettävä aikavyöhyke" + } + } + }, + "ui": { + "title": "Ulkoasu", + "description": "Vaihda vaalean ja tumman tilan välillä, muuta kieltä tai muokkaa kaavion näyttötyyppiä.", + "option": { + "theme": { + "label": "Teematila", + "light": "Vaalea", + "dark": "Tumma" + }, + "language": { + "label": "Kieli" + }, + "chartType": { + "label": "Kaaviotyyppi", + "histogram": "Histogrammi", + "heatmap": "Lämpökartta" + } + } + }, + "pagespeed": { + "title": "Google PageSpeed API -avain", + "description": "Syötä Google PageSpeed API -avaimesi ottaaksesi käyttöön Google PageSpeed -monitoroinnin. Napsauta Palauta päivittääksesi avaimen.", + "option": { + "apiKey": { + "label": "PageSpeed API -avain", + "labelSet": "API-avain on asetettu. Napsauta Palauta muuttaaksesi sitä.", + "placeholder": "Syötä Google PageSpeed API -avaimesi" + } + } + }, + "url": { + "title": "Monitorin IP/URL tilasivulla", + "description": "Näytä monitorin IP-osoite tai URL julkisella tilasivulla. Jos se on poistettu käytöstä, vain monitorin nimi näytetään arkaluonteisten tietojen suojaamiseksi.", + "option": { + "showURL": { + "label": "Näytä IP/URL tilasivulla", + "enabled": "Käytössä", + "disabled": "Pois käytöstä" + } + } + }, + "stats": { + "title": "Monitorointihistoria", + "description": "Tyhjennä kaikki tiimisi monitorointihistoria ja tilastot. Tätä toimintoa ei voi kumota.", + "option": { + "clear": { + "label": "Tyhjennä kaikki tilastot. Tätä ei voi kumota." + } + }, + "dialog": { + "title": "Tyhjennetäänkö kaikki monitorointihistoria?", + "description": "Tätä ei voi kumota" + } + }, + "retention": { + "title": "Tarkistusten säilytys", + "description": "Aseta kuinka kauan tarkistustietoja säilytetään ennen automaattista poistoa.", + "option": { + "days": { + "label": "Säilytysaika (päiviä)", + "unlimited": "Rajoittamaton" + } + } + }, + "thresholds": { + "title": "Yleiset kynnysarvot", + "description": "Aseta yleiset CPU:n, muistin, levyn ja lämpötilan kynnysarvot infrastruktuurin monitorointiin. Nämä koskevat kaikkia laitemonitoreja, ellei niitä ohiteta.", + "option": { + "cpu": { + "label": "CPU-kynnysarvo (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "Muistin kynnysarvo (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "Levyn kynnysarvo (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "Lämpötilan kynnysarvo (°C)", + "placeholder": "1 - 150" + } + } + }, + "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": "", + "descriptionTransportLink": "Katso tekniset tiedot täältä", + "option": { + "host": { + "label": "Sähköpostin isäntä - yhdistettävä isäntänimi tai IP-osoite", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "Sähköpostin portti - yhdistettävä portti", + "placeholder": "587" + }, + "address": { + "label": "Sähköpostiosoite - käytetään tunnistautumiseen", + "placeholder": "you@example.com" + }, + "user": { + "label": "Sähköpostin käyttäjä - tunnistautumisen käyttäjänimi, ohittaa sähköpostiosoitteen jos määritelty", + "placeholder": "Jätä tyhjäksi, jos ei tarvita" + }, + "password": { + "label": "Sähköpostin salasana - tunnistautumisen salasana", + "labelSet": "Salasana on asetettu. Napsauta Palauta muuttaaksesi sitä.", + "placeholder": "Syötä salasanasi" + }, + "tlsServername": { + "label": "TLS-palvelinnimi - valinnainen isäntänimi TLS-vahvistukseen, kun isäntä on IP-osoite", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "Sähköpostiyhteyden isäntä - HELO/EHLO-tervehdyksessä käytettävä isäntänimi", + "placeholder": "localhost" + }, + "secure": { + "label": "Käytä SSL:ää (suositus): Salaa yhteys SSL/TLS:n avulla" + }, + "pool": { + "label": "Ota yhteyspoolaus käyttöön: Käytä olemassa olevia yhteyksiä suorituskyvyn parantamiseksi" + }, + "ignoreTLS": { + "label": "Poista STARTTLS käytöstä: Älä käytä TLS:ää, vaikka palvelin tukisi sitä" + }, + "requireTLS": { + "label": "Pakota STARTTLS: Vaadi TLS-päivitys, epäonnistu jos ei tueta" + }, + "rejectUnauthorized": { + "label": "Hylkää virheelliset varmenteet: Hylkää yhteydet itseallekirjoitetuilla tai ei-luotetuilla varmenteilla" + } + } + }, + "demoMonitors": { + "title": "Demomonitorit", + "description": "Lisää esimerkkimonitoreja esittelytarkoituksiin." + }, + "removeMonitors": { + "title": "Järjestelmän palautus", + "description": "Poista kaikki monitorit järjestelmästäsi.", + "dialog": { + "title": "Poistetaanko kaikki monitorit?", + "description": "Tätä toimintoa ei voi kumota.", + "paragraph": "Mukana poistuvat myös kaikki tarkistushistoria, häiriöt, ajoitetut huoltoikkunat sekä tilasivut, jotka käyttivät näitä monitoreja. Vie monitorit ensin, jos haluat säilyttää varmuuskopion niiden tiedoista." + } + }, + "exportMonitors": { + "title": "Vie monitorit" + }, + "importExportMonitors": { + "title": "Monitorien tuonti / vienti", + "description": "Tuo tai vie monitorien tiedot JSON-tiedostona varmuuskopiointia tai siirtoa varten." + }, + "about": { + "title": "Tietoja", + "developedBy": "Kehittäjä: Bluewave Labs" + }, + "validation": { + "errorMessage": "Korjaa seuraavat vahvistusvirheet:" + } + }, + "header": { + "title": "Asetukset", + "description": "Määritä SMTP, integraatiot, säilytys ja sovelluksen yleinen käyttäytyminen." + } + }, + "statusPages": { + "deleteSuccess": "Tilasivu poistettu onnistuneesti", + "fallback": { + "title": "Ei vielä tilasivuja", + "actionButton": "Luo tilasivu!", + "description": "Julkaise julkinen sivu, joka näyttää asiakkaille ja sidosryhmille reaaliaikaisen käytettävyyden ja tapaushistorian." + }, + "monitorsList": { + "chartTypeHeatmap": "Lämpökartta", + "chartTypeHistogram": "Histogrammi", + "noData": "Tietoja ei saatavilla", + "infrastructure": { + "title": "Infrastruktuuri", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "Muisti", + "disk": "Levy", + "usage": "Käyttö", + "used": "Käytetty", + "total": "Yhteensä" + }, + "uptime": { + "title": "Käytettävyys", + "responseTime": "Vasteaika" + } + }, + "statusBar": { + "allDown": "Kaikki järjestelmät ovat alhaalla", + "allUp": "Kaikki järjestelmät toiminnassa", + "degraded": "Joissakin järjestelmissä on ongelmia", + "unknown": "Järjestelmän tilaa ei voida määrittää", + "allBreached": "Kaikki järjestelmät ovat ylittäneet kynnysarvot", + "allMaintenance": "Kaikki järjestelmät ovat huollossa", + "allPaused": "Kaikki järjestelmät on keskeytetty", + "allInitializing": "Kaikki järjestelmät alustuvat", + "breachedAndDown": "Jotkin järjestelmät ovat ylittäneet kynnysarvot ja ovat alhaalla", + "breached": "Jotkin järjestelmät ovat ylittäneet kynnysarvot", + "maintenanceAndDown": "Jotkin järjestelmät ovat alhaalla huollon aikana", + "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" + }, + "table": { + "headers": { + "name": "Tilasivun nimi", + "url": "Julkinen URL" + }, + "published": "Julkaistu", + "unpublished": "Julkaisematon" + }, + "title": "Tilasivut", + "details": { + "empty": { + "title": "Täällä ei ole vielä mitään", + "addMonitor": "Lisää monitori aloittaaksesi" + } + }, + "form": { + "access": { + "title": "Käyttöoikeus", + "description": "Jos tilasivusi on valmis, voit merkitä sen julkaistuksi.", + "option": { + "published": { + "name": "Julkaistu ja julkisesti näkyvissä" + } + } + }, + "basicInfo": { + "title": "Perustiedot", + "description": "Määritä yrityksen nimi ja aliverkkotunnus, johon tilasivusi osoittaa.", + "option": { + "name": { + "label": "Yrityksen nimi", + "placeholder": "Acme Inc." + }, + "url": { + "label": "Tilasivusi osoite", + "placeholder": "my-status-page" + } + } + }, + "monitors": { + "title": "Monitorit", + "description": "Valitse tilasivullasi näytettävät monitorit.", + "noMonitors": "Monitoreja ei valittu", + "option": { + "monitors": { + "label": "Valitse monitorit", + "placeholder": "Hae ja valitse monitorit..." + } + } + }, + "timezone": { + "title": "Aikavyöhyke", + "description": "Valitse aikavyöhyke, jossa tilasivusi näytetään.", + "option": { + "timezone": { + "label": "Aikavyöhyke", + "placeholder": "Valitse aikavyöhyke..." + } + } + }, + "appearance": { + "title": "Ulkoasu", + "description": "Määritä julkisen tilasivusi oletusilme.", + "option": { + "logo": { + "label": "Logo" + }, + "color": { + "label": "Brändiväri" + } + } + }, + "features": { + "title": "Ominaisuudet", + "description": "Määritä mitä tietoja tilasivullasi näytetään.", + "option": { + "showCharts": { + "label": "Näytä vasteaikakaaviot" + }, + "showUptimePercentage": { + "label": "Näytä käytettävyysprosentti" + }, + "showAdminLoginLink": { + "label": "Näytä ylläpitäjän kirjautumislinkki" + }, + "showInfrastructure": { + "label": "Näytä infrastruktuurin mittarit" + } + } + } + }, + "header": { + "title": "Tilasivut", + "description": "Julkaise julkisia sivuja, jotka näyttävät asiakkaille ja sidosryhmille reaaliaikaisen käytettävyyden ja tapaushistorian." + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "Hae monitoreja..." + } + }, + "table": { + "headers": { + "responseTime": "Vasteaika" + } + }, + "fallback": { + "actionButton": "Luo monitori!", + "title": "Ei vielä käytettävyysmonitoreita", + "description": "Tarkkaile HTTP-päätepisteitä, pingejä, kontteja ja portteja — saat hälytyksen heti, kun jokin kaatuu." + }, + "header": { + "title": "Käytettävyysmonitorit", + "description": "Tarkkaile HTTP-päätepisteitä, pingejä, kontteja ja portteja — saat hälytyksen heti, kun jokin kaatuu." } } - }, - "createNotifications": { - "title": "", - "nameSettings": { - "title": "", - "description": "", - "nameLabel": "", - "namePlaceholder": "" - }, - "typeSettings": { - "title": "", - "description": "", - "typeLabel": "" - }, - "emailSettings": { - "title": "", - "description": "", - "emailLabel": "", - "emailPlaceholder": "" - }, - "slackSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "pagerdutySettings": { - "title": "", - "description": "", - "integrationKeyLabel": "", - "integrationKeyPlaceholder": "" - }, - "discordSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "webhookSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "testNotification": "", - "dialogDeleteTitle": "", - "dialogDeleteConfirm": "" - }, - "notificationConfig": { - "title": "", - "description": "" - }, - "monitorStatus": { - "checkingEvery": "", - "withCaptureAgent": "", - "up": "", - "down": "", - "paused": "" - }, - "advancedMatching": "", - "sendTestNotifications": "", - "selectAll": "", - "showAdminLoginLink": "", - "logsPage": { - "title": "", - "description": "", - "tabs": { - "queue": "", - "logs": "", - "diagnostics": "" - }, - "toast": { - "fetchLogsSuccess": "" - }, - "logLevelSelect": { - "title": "", - "values": { - "all": "", - "info": "", - "warn": "", - "error": "", - "debug": "" - } - } - }, - "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", - "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "", - "activeHeader": "", - "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "", - "lastRunTookHeader": "", - "intervalHeader": "" - }, - "metricsTable": { - "title": "", - "metricHeader": "", - "valueHeader": "" - }, - "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", - "failedAtHeader": "", - "failReasonHeader": "" - } - }, - "export": { - "title": "", - "success": "", - "failed": "" - }, - "monitorActions": { - "title": "", - "import": "", - "export": "", - "deleteSuccess": "", - "deleteFailed": "", - "details": "" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "" - }, - "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" - }, - "emailSettings": { - "buttonSendTestEmail": "", - "description": "", - "descriptionTransport": "", - "labelAddress": "", - "labelConnectionHost": "", - "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", - "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", - "labelRequireTLS": "", - "labelSecure": "", - "labelTLSServername": "", - "labelUser": "", - "linkTransport": "", - "placeholderUser": "", - "title": "", - "toastEmailRequiredFieldsError": "" - }, - "pageSpeedSettings": { - "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "" - }, - "saveButtonLabel": "", - "statsSettings": { - "clearAllStatsButton": "", - "clearAllStatsDescription": "", - "clearAllStatsDialogConfirm": "", - "clearAllStatsDialogDescription": "", - "clearAllStatsDialogTitle": "", - "description": "", - "labelTTL": "", - "labelTTLOptional": "", - "title": "" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", - "dialogDescription": "", - "dialogTitle": "", - "title": "" - }, - "timezoneSettings": { - "description": "", - "label": "", - "title": "" - }, - "title": "", - "uiSettings": { - "description": "", - "labelLanguage": "", - "labelTheme": "", - "title": "" - }, - "urlSettings": { - "description": "", - "label": "", - "selectDisabled": "", - "selectEnabled": "", - "title": "" - }, - "globalThresholds": { - "title": "", - "description": "" - } - }, - "statusPageCreate": { - "buttonSave": "" - }, - "incidentsOptionsHeaderFilterResolved": "", - "settingsSave": "", - "statusPageCreateAppearanceTitle": "", - "confirmPassword": "", - "monitorHooks": { - "failureAddDemoMonitors": "", - "successAddDemoMonitors": "" - }, - "settingsAppearance": "", - "settingsDisplayTimezone": "", - "settingsGeneralSettings": "", - "incidentsOptionsHeaderTotalIncidents": "", - "statusPage": { - "deleteSuccess": "", - "deleteFailed": "", - "createSuccess": "", - "updateSuccess": "", - "generalSettings": "", - "contents": "", - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "testNotificationsDisabled": "", - "incidentsTableResolvedAt": "", - "incidentsTableActionResolve": "", - "checkHooks": { - "failureResolveOne": "", - "failureResolveAll": "", - "failureResolveMonitor": "" - }, - "checkFormError": "", - "diagnosticsPage": { - "diagnosticDescription": "", - "statsDescription": "", - "gauges": { - "heapAllocationTitle": "", - "heapAllocationSubtitle": "", - "heapUsageTitle": "", - "heapUsageSubtitle": "", - "heapUtilizationTitle": "", - "heapUtilizationSubtitle": "", - "instantCpuUsageTitle": "", - "instantCpuUsageSubtitle": "" - }, - "stats": { - "eventLoopDelayTitle": "", - "uptimeTitle": "", - "usedHeapSizeTitle": "", - "totalHeapSizeTitle": "", - "osMemoryLimitTitle": "" - } - }, - "pageSpeedLighthouseAPI": "", - "time": { - "threeMinutes": "", - "fiveMinutes": "", - "tenMinutes": "", - "twentyMinutes": "", - "oneHour": "", - "oneDay": "", - "oneWeek": "", - "fourMinutes": "", - "oneMinute": "", - "twoMinutes": "", - "fifteenSeconds": "", - "thirtySeconds": "" - }, - "general": { - "noOptionsFound": "" - }, - "infrastructureMonitor": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "pageSpeed": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "editUserPage": { - "form": { - "email": "", - "firstName": "", - "lastName": "", - "role": "", - "save": "" - }, - "table": { - "actionHeader": "", - "roleHeader": "" - }, - "title": "", - "toast": { - "successUserUpdate": "", - "validationErrors": "" - } - }, - "incidentsPageActionResolveMonitor": "", - "incidentsPageActionResolveAll": "", - "matchMethodOptions": { - "equal": "", - "equalPlaceholder": "", - "include": "", - "includePlaceholder": "", - "regex": "", - "regexPlaceholder": "", - "text": "" - }, - "monitorType": { - "docker": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "http": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "ping": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "port": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "game": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "" - }, - "bytesPerSecond": "", - "bytesReceived": "", - "bytesSent": "", - "chooseGame": "", - "createMonitorPage": { - "incidentConfigDescription": "", - "incidentConfigStatusWindowLabel": "", - "incidentConfigStatusWindowThresholdLabel": "", - "incidentConfigTitle": "", - "incidentConfigDescriptionV2": "", - "incidentConfigStatusCheckNumber": "", - "intervalTitle": "", - "intervalDescription": "" - }, - "dataRate": "", - "dataReceived": "", - "dataSent": "", - "details": "", - "drops": "", - "errors": "", - "errorsIn": "", - "errorsOut": "", - "gameServerMonitoring": "", - "gameServerMonitoringDescription": "", - "network": "", - "networkDrops": "", - "networkErrors": "", - "networkInterface": "", - "noNetworkStatsAvailable": "", - "packetsPerSecond": "", - "packetsReceived": "", - "packetsReceivedRate": "", - "packetsSent": "", - "rate": "", - "selectInterface": "" + } } diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 9e76440f0..c35956b46 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -1,1140 +1,1447 @@ { - "submit": "Envoyer", - "title": "Titre", - "distributedStatusHeaderText": "Couverture en temps réel, sur des appareils en temps réel", - "distributedStatusSubHeaderText": "Alimenté par des millions d'appareils à travers le monde, consultez les performances du système par région, pays ou ville", - "settingsDisabled": "Désactivé", - "settingsSuccessSaved": "Les paramètres ont bien été sauvegardés", - "settingsFailedToSave": "Les paramètres n'ont pas pu être sauvegardés", - "settingsStatsCleared": "Les statistiques ont été réinitialisées avec succès", - "settingsFailedToClearStats": "Les statistiques n'ont pas pu être réinitialisées", - "settingsMonitorsDeleted": "Tous les moniteurs ont bien été supprimés", - "settingsFailedToDeleteMonitors": "La suppression de tous les moniteurs a échoué", - "starPromptTitle": "Ajouter Checkmate aux favoris", - "starPromptDescription": "Voir les dernières versions et aider la communauté à grandir sur Github", - "https": "HTTPS", - "http": "HTTP", - "monitor": "Moniteur", - "aboutus": "A propos de nous", - "now": "Maintenant", - "delete": "Supprimer", - "configure": "Configurer", - "responseTime": "Temps de réponse", - "ms": "ms", - "bar": "Graphique", - "area": "Zone", - "country": "PAYS", - "city": "VILLE", - "response": "Réponse", - "monitorStatusUp": "Moniteur {name} ({url}) est désormais EN LIGNE et répond aux requêtes", - "monitorStatusDown": "Moniteur {name} ({url}) est désormais HORS LIGNE et ne répond plus", - "webhookSendSuccess": "La notification webhook a bien été envoyée", - "webhookSendError": "Une erreur s'est produite lors de l'envoi de la notification webhook sur {platform}", - "webhookUnsupportedPlatform": "La plateforme {platform} n'est pas supportée actuellement", - "distributedRightCategoryTitle": "Moniteur", - "distributedStatusServerMonitors": "Monitoring des serveurs", - "distributedStatusServerMonitorsDescription": "Statut des moniteurs relatifs aux serveurs", - "distributedUptimeCreateSelectURL": "Ici vous pouvez sélectionner l'URL de l'hôte et le type de moniteur", - "distributedUptimeCreateChecks": "Vérifications à effectuer", - "distributedUptimeCreateChecksDescription": "Vous pourrez ajouter ou supprimer des vérifications après l'ajout de votre site", - "distributedUptimeCreateIncidentNotification": "Notifications d'incidents", - "distributedUptimeCreateIncidentDescription": "Quand il y a un incident, notifier les utilisateurs", - "distributedUptimeCreateAdvancedSettings": "Paramètres avancés", - "distributedUptimeDetailsNoMonitorHistory": "Il n'y a pas encore d'historique de vérification pour ce moniteur", - "distributedUptimeDetailsStatusHeaderUptime": "Temps en ligne :", - "distributedUptimeDetailsStatusHeaderLastUpdate": "Dernière mise à jour", - "notifications": { - "enableNotifications": "Activer les notifications sur {{platform}}", - "testNotification": "Notification de test", - "addOrEditNotifications": "Ajouter ou éditer des notifications", - "slack": { - "label": "Slack", - "description": "Pour activer les notifications Slack, créez une application Slack et activez les webhooks entrants. Ensuite, indiquer l'URL du webhook ici.", - "webhookLabel": "URL du webhook", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "L'URL du webhook Slack est requise" - }, - "discord": { - "label": "Discord", - "description": "Pour envoyer des données sur un canal Discord depuis Checkmate, utilisez l'intégration Webhook Discord.", - "webhookLabel": "URL du webhook Discord", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "L'URL du webhook Discord est requise" - }, - "telegram": { - "label": "Telegram", - "description": "Pour activer les notifications Telegram, créez un bot Telegram en utilisant BotFather, le robot officiel pour créer et gérer ses propres robots. Indiquez ensuite votre clé API et l'ID du chat ici.", - "tokenLabel": "Token du robot", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "Votre Chat ID", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "Le token Telegram et l'ID du chat sont requis." - }, - "webhook": { - "label": "Webhooks", - "description": "Vous pouvez indiquer un webhook personnalisé pour recevoir des notifications lorsqu'un incident se produit.", - "urlLabel": "URL du webhook", - "urlPlaceholder": "https://votre-serveur.fr/webhook", - "urlRequired": "L'URL du webhook est nécessaire" - }, - "testNotificationDevelop": "Notification de test 2", - "integrationButton": "Intégration de notification", - "testSuccess": "La notification de test a été envoyée avec succès !", - "testFailed": "Échec de l'envoi de la notification de test", - "unsupportedType": "Type de notification non supporté", - "networkError": "Une erreur réseau s'est produite", - "fallback": { - "title": "canal de notification", - "checks": [ - "Alertez les équipes en cas de temps d'arrêt ou de problèmes de performances", - "Informez les ingénieurs lorsque des incidents se produisent", - "Tenez les administrateurs informés des changements apportés au système" - ], - "actionButton": "Créez votre premier canal de notification !" - }, - "createButton": "Créer un canal de notification", - "createTitle": "Canal de notification", - "create": { - "success": "La notification a été créée avec succès", - "failed": "Une erreur s'est produite lors de la création de la notification" - }, - "fetch": { - "success": "Les notifications ont été récupérées avec succès", - "failed": "Une erreur s'est produite lors de la récupération des notifications" - }, - "delete": { - "success": "Notification supprimée avec succès", - "failed": "Une erreur s'est produite lors de la suppression de la notification" - }, - "edit": { - "success": "Notification modifiée avec succès", - "failed": "Une erreur s'est produite lors de la modification de la notification" - }, - "test": { - "success": "Notification de test envoyée avec succès", - "failed": "Une erreur s'est produite lors de l'envoi de la notification de test" - } - }, - "testLocale": "testLocale", - "add": "Ajouter", - "monitors": "moniteurs", - "distributedUptimeStatusCreateStatusPage": "page de statut", - "distributedUptimeStatusCreateStatusPageAccess": "Accès", - "distributedUptimeStatusCreateStatusPageReady": "Si votre page de statut est prête, vous pouvez l'indiquer en \"Publiée\".", - "distributedUptimeStatusBasicInfoHeader": "Informations de base", - "distributedUptimeStatusBasicInfoDescription": "Définir le nom de la société et le sous domaine sur lequel votre page de statut pointe.", - "distributedUptimeStatusLogoHeader": "Logo", - "distributedUptimeStatusLogoDescription": "Ajoutez un logo pour votre page de statut", - "distributedUptimeStatusLogoUploadButton": "Téléverser un logo", - "distributedUptimeStatusStandardMonitorsHeader": "Moniteurs standards", - "distributedUptimeStatusStandardMonitorsDescription": "Ajouter un moniteur standard à votre page de statut", - "distributedUptimeStatusCreateYour": "Créer votre", - "distributedUptimeStatusEditYour": "Editer votre", - "distributedUptimeStatusPublishedLabel": "Publiée et visible publiquement", - "distributedUptimeStatusCompanyNameLabel": "Nom de l'entreprise", - "distributedUptimeStatusPageAddressLabel": "Adresse de votre page de statut", - "distributedUptimeStatus30Days": "30 jours", - "distributedUptimeStatus60Days": "60 jours", - "distributedUptimeStatus90Days": "90 jours", - "distributedUptimeStatusPageNotSetUp": "Aucune page de statut n'est mise en place.", - "distributedUptimeStatusContactAdmin": "Veuillez contacter votre administrateur", - "distributedUptimeStatusPageNotPublic": "Cette page de statut n'est pas publique.", - "distributedUptimeStatusPageDeleteDialog": "Voulez-vous supprimer cette page de statut ?", - "distributedUptimeStatusPageDeleteConfirm": "Oui, supprimer la page de statut", - "distributedUptimeStatusPageDeleteDescription": "Supprimer votre page de statut est irréversible.", - "distributedUptimeStatusDevices": "Équipements", - "distributedUptimeStatusUpt": "UPT", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "Logo Upt", - "incidentsTableNoIncidents": "Aucun incident enregistrée", - "incidentsTablePaginationLabel": "incidents", - "incidentsTableMonitorName": "Nom du moniteur", - "incidentsTableStatus": "Statut", - "incidentsTableDateTime": "Date et heure", - "incidentsTableStatusCode": "Code de statut", - "incidentsTableMessage": "Message", - "incidentsOptionsHeader": "Incidents pour :", - "incidentsOptionsHeaderFilterBy": "Filtrer par :", - "incidentsOptionsHeaderFilterAll": "Tout", - "incidentsOptionsHeaderFilterDown": "Hors ligne", - "incidentsOptionsHeaderFilterCannotResolve": "Impossible de résoudre", - "incidentsOptionsHeaderShow": "Voir:", - "incidentsOptionsHeaderLastHour": "Dernière heure", - "incidentsOptionsHeaderLastDay": "Dernier jour", - "incidentsOptionsHeaderLastWeek": "Dernière semaine", - "incidentsOptionsPlaceholderAllServers": "Tous les serveurs", - "infrastructureCreateYour": "Créer votre", - "infrastructureCreateGeneralSettingsDescription": "Ici, vous pouvez sélectionner l'URL de l'hôte, ainsi que le nom familier et le secret d'autorisation pour vous connecter à l'agent sur le serveur.", - "infrastructureServerRequirement": "Le serveur que vous surveillez doit exécuter le", - "infrastructureCustomizeAlerts": "Personnaliser les alertes", - "infrastructureAlertNotificationDescription": "Envoyer une notification aux utilisateurs lorsque le seuil dépasse un pourcentage spécifié.", - "infrastructureCreateMonitor": "Créer un moniteur d'infrastructure", - "infrastructureProtocol": "Protocole", - "infrastructureServerUrlLabel": "URL du serveur", - "infrastructureDisplayNameLabel": "Nom d'affichage", - "infrastructureAuthorizationSecretLabel": "Secret d'autorisation", - "gb": "GB", - "mb": "MB", - "mem": "Mém", - "memoryUsage": "Utilisation de la mémoire", - "cpu": "CPU", - "cpuUsage": "Utilisation CPU", - "cpuTemperature": "Température CPU", - "diskUsage": "Utilisation du disque", - "used": "Utilisé", - "total": "Total", - "cores": "Coeurs", - "frequency": "Fréquence", - "status": "Statut", - "cpuPhysical": "CPU (Physique)", - "cpuLogical": "CPU (Logique)", - "cpuFrequency": "Fréquence CPU", - "avgCpuTemperature": "Température moyenne du CPU", - "memory": "Mémoire", - "disk": "Disque", - "uptime": "Temps en ligne", - "os": "OS", - "host": "Hôte", - "actions": "Actions", - "integrations": "Intégrations", - "integrationsPrism": "Connecter Prism à votre service préféré.", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "Connecter à Slack et voir les incidents dans un canal", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "Connecter à Discord et voir les incidents dans un canal", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "Envoyer tous les incidents à Zapier", - "commonSave": "Sauvegarder", - "createYour": "Créez votre", - "createMonitor": "Ajouter un moniteur", - "pause": "Mettre en pause", - "resume": "Reprendre", - "editing": "Edition...", - "url": "URL", - "access": "Accès", - "timezone": "Fuseau horaire", - "features": "Fonctionnalités", - "administrator": "Administrateur ?", - "loginHere": "Connexion", - "displayName": "Nom d'affichage", - "urlMonitor": "URL à suivre", - "portToMonitor": "Port du moniteur", - "websiteMonitoring": "Monitoring d'un site internet", - "websiteMonitoringDescription": "Utiliser HTTP(s) pour vérifier votre site ou votre API.", - "pingMonitoring": "Monitoring par ping", - "pingMonitoringDescription": "Vérifier si le serveur est disponible ou pas", - "dockerContainerMonitoring": "Monitoring d'un container Docker", - "dockerContainerMonitoringDescription": "Vérifier si votre container Docker fonctionne ou pas.", - "portMonitoring": "Monitoring d'un port", - "portMonitoringDescription": "Vérifier si votre port est ouvert ou non.", - "createMaintenanceWindow": "Créer une fenêtre de maintenance", - "createMaintenance": "Créer une maintenance", - "editMaintenance": "Modifier la maintenance", - "maintenanceWindowName": "Nom de la fenêtre de maintenance", - "friendlyNameInput": "Nom", - "friendlyNamePlaceholder": "Maintenance à __ : __ pendant ___ minutes", - "maintenanceRepeat": "Répéter la maintenance", - "maintenance": "maintenance", - "duration": "Durée", - "addMonitors": "Ajouter des moniteurs", - "window": "fenêtre", - "cancel": "Annuler", - "message": "Message", - "low": "bas", - "high": "haut", - "statusCode": "Code de statut", - "date&Time": "Date et heure", - "type": "Type", - "statusPageName": "Nom de la page de statut", - "publicURL": "URL publique", - "repeat": "Répéter", - "edit": "Modifier", - "createA": "Créer un", - "remove": "Supprimer", - "maintenanceWindowDescription": "Vos pings ne seront pas envoyés pendant cette fenêtre de temps.", - "startTime": "Date de démarrage", - "timeZoneInfo": "Toutes les dates et heures sont dans le fuseau GMT+0", - "monitorsToApply": "Moniteurs sur lesquels appliquer la fenêtre de maintenance", - "nextWindow": "Prochaine fenêtre de maintenance", - "notFoundButton": "Aller au tableau de bord principal", - "pageSpeedConfigureSettingsDescription": "Vous pouvez sélectionner l'URL de hôte et le type de moniteur.", - "monitorDisplayName": "Nom du moniteur", - "whenNewIncident": "Lors d'un nouvel incident,", - "notifySMS": "Notification par SMS (bientôt)", - "notifyEmails": "Envoyer également une notification par email à plusieurs adresses (bientôt).", - "seperateEmails": "Vous pouvez ajouter plusieurs adresses emails en les séparant par une virgule", - "checkFrequency": "Vérifier la fréquence", - "matchMethod": "Méthode de rapprochement", - "expectedValue": "Valeur attendue", - "deleteDialogTitle": "Voulez-vous vraiment supprimer ce moniteur ?", - "deleteDialogDescription": "Une fois supprimé, le moniteur ne peut pas être récupéré.", - "pageSpeedMonitor": "Moniteur PageSpeed", - "shown": "Visibles", - "ago": "depuis", - "companyName": "Nom de la société", - "pageSpeedDetailsPerformanceReport": "Les valeurs sont estimatives et peuvent varier.", - "pageSpeedDetailsPerformanceReportCalculator": "Voir le calculateur", - "checkingEvery": "Vérification tous les", - "statusPageCreateSettings": "Si votre page de statut est prête, vous pouvez la rendre publique.", - "basicInformation": "Informations de base", - "statusPageCreateBasicInfoDescription": "Définir le nom de la société et le sous-domaine vers lequel pointe votre page de statut.", - "statusPageCreateSelectTimeZoneDescription": "Sélectionnez le fuseau horaire qui sera utilisé pour votre page de statut", - "statusPageCreateAppearanceDescription": "Définir le style par défaut de votre page de statut.", - "statusPageCreateSettingsCheckboxLabel": "Publiée et visible au public", - "statusPageCreateBasicInfoStatusPageAddress": "L'adresse de votre page de statut", - "statusPageCreateTabsContent": "Serveurs de votre page de statut", - "statusPageCreateTabsContentDescription": "Vous pouvez ajouter autant de moniteurs à votre page de statut que vous le souhaitez. Vous pouvez également les réordonner pour améliorer l'expérience utilisateur.", - "statusPageCreateTabsContentFeaturesDescription": "Voir plus de détails sur la page de statut", - "showCharts": "Voir les graphiques", - "showUptimePercentage": "Voir le pourcentage de temps en ligne", - "removeLogo": "Supprimer le logo", - "statusPageStatus": "Aucune page de statut publique n'est déployée", - "statusPageStatusContactAdmin": "Merci de contacter votre administrateur", - "statusPageStatusNotPublic": "Cette page de statut n'est pas piblique", - "statusPageStatusNoPage": "Il n'y a aucune page de statut ici", - "statusPageStatusServiceStatus": "Statut des services", - "deleteStatusPage": "Voulez-vous supprimer cette page de statut ?", - "deleteStatusPageConfirm": "Oui, supprimer la page de statut", - "deleteStatusPageDescription": "Une fois supprimée, votre page de statut ne pourra plus être récupérée.", - "uptimeCreate": "La valeur attendue est utilisée pour comparer le résultat de la réponse et la correspondance détermine le statut du moniteur.", - "uptimeCreateJsonPath": "Cette expression sera évaluée par rapport aux données JSON de la réponse et le résultat sera utilisé pour la comparaison avec la valeur attendue. Voir", - "uptimeCreateJsonPathQuery": "pour la documentation sur le langage de requête.", - "maintenanceTableActionMenuDialogTitle": "Voulez-vous vraiment supprimer cette fenêtre de maintenance ?", - "infrastructureEditYour": "Modifier votre", - "infrastructureEditMonitor": "Sauvegarder le moniteur d'infrastructure", - "infrastructureMonitorCreated": "Le moniteur d'infrastructure a été créé avec succès !", - "infrastructureMonitorUpdated": "Le moniteur d'infrastructure a été modifié avec succès !", - "errorInvalidTypeId": "Le type de notification fourni est invalide", - "errorInvalidFieldId": "Le champ ID fourni est invalide", - "inviteNoTokenFound": "Aucun jeton d'invitation fourni", - "pageSpeedWarning": "Attention : vous n'avez pas encore ajouté de clé API Google PageSpeed. Sans celle-ci, le moniteur PageSpeed ne fonctionnera pas.", - "pageSpeedLearnMoreLink": "Cliquer ici", - "pageSpeedAddApiKey": "pour ajouter votre clé API.", - "update": "Mettre à jour", - "invalidFileFormat": "Format de fichier non supporté !", - "invalidFileSize": "Le fichier est trop volumineux !", - "ClickUpload": "Cliquez pour téléverser", - "DragandDrop": "glisser et déposer", - "MaxSize": "Taille maximum", - "SupportedFormats": "Formats supportés", - "FirstName": "Prénom", - "LastName": "Nom", - "EmailDescriptionText": "Il s'agit de votre adresse email actuelle : elle ne peut pas être changée.", - "YourPhoto": "Photo de profil", - "PhotoDescriptionText": "La photo sera affichée sur votre page de profil.", - "save": "Enregistrer", - "DeleteDescriptionText": "Cela supprimera le compte et toutes les données associées du serveur. Cette opération est irréversible.", - "DeleteAccountWarning": "Supprimer votre compte signifie que vous ne pourrez pas vous reconnecter et que toutes vos données seront supprimés. Ceci est irréversible.", - "DeleteWarningTitle": "Vous supprimez vraiment ce compte ?", - "bulkImport": { - "title": "Import en masse", - "selectFileTips": "Sélectionnez un fichier CSV pour l'upload", - "selectFileDescription": "Vous pouvez télécharger notre ou exemple.", - "selectFile": "Sélectionner un fichier", - "parsingFailed": "L'analyse du fichier a rencontré un problème", - "uploadSuccess": "Moniteurs créés avec succès !", - "validationFailed": "Echec de la validation", - "noFileSelected": "Aucun fichier sélectionné", - "fallbackPage": "Importer un fichier pour ajouter une liste de serveurs en masse", - "invalidFileType": "Type de fichier invalide", - "uploadFailed": "Le téléchargement a échoué" - }, - "DeleteAccountTitle": "Supprimer le compte", - "DeleteAccountButton": "Supprimer le compte", - "publicLink": "Lien public", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "reset": "Réinitialiser", - "ignoreTLSError": "Ignorer les erreurs TLS/SSL", - "tlsErrorIgnored": "Erreurs TLS/SSL ignorées", - "ignoreTLSErrorDescription": "Ignorer les erreurs TLS/SSL et continuer à vérifier l'accessibilité du site web", - "createNew": "Créer un nouveau", - "greeting": { - "prepend": "Salut !", - "append": "L'après-midi est votre libre, rendons-le épique !", - "overview": "Voici un aperçu de vos moniteurs {{type}}." - }, - "roles": { - "superAdmin": "Super admin", - "admin": "Admin", - "teamMember": "Membre de l'équipe", - "demoUser": "Utilisateur de démonstration" - }, - "teamPanel": { - "teamMembers": "Membres de l'équipe", - "filter": { - "all": "Tous", - "member": "Membre" - }, - "inviteTeamMember": "Inviter un membre de l'équipe", - "inviteNewTeamMember": "Inviter un nouveau membre d'équipe", - "inviteDescription": "Lorsque vous ajoutez un nouveau membre à l'équipe, il aura accès à tous les moniteurs.", - "email": "Email", - "selectRole": "Sélectionnez un rôle", - "inviteLink": "Lien d'invitation", - "cancel": "Annuler", - "noMembers": "Il n'y a aucun membre d'équipe avec ce rôle", - "getToken": "Récupérer un token", - "emailToken": "Token de l'email", - "table": { - "name": "Nom", - "email": "Email", - "role": "Rôle", - "created": "Créée" - }, - "addTeamMember": { - "addMemberMenu": "", - "title": "", - "description": "", - "addButton": "" - }, - "register": "", - "registerToast": { - "success": "", - "dbUserExists": "", - "unknownError": "" - }, - "registerTeamMember": { - "title": "", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "lastName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "email": { - "errors": { - "empty": "", - "invalid": "" - } - }, - "role": { - "errors": { - "empty": "" - } - } - } - } - } - }, - "role": "", - "changeTeamPassword": { - "changePasswordMenu": "", - "title": "", - "description": "", - "success": "" - } - }, - "monitorState": { - "paused": "Pause", - "resumed": "Reprendre", - "active": "Actif" - }, - "menu": { - "uptime": "Temps en ligne", - "pagespeed": "Vitesse", - "infrastructure": "Infrastructure", - "incidents": "Incidents", - "statusPages": "Pages de statut", - "maintenance": "Maintenance", - "integrations": "Intégrations", - "settings": "Paramètres", - "support": "Support", - "discussions": "Discussions", - "docs": "Documentation", - "changelog": "Changelog", - "profile": "Profil", - "password": "Mot de passe", - "team": "Équipe", - "logOut": "Déconnexion", - "notifications": "Notifications", - "logs": "Journaux" - }, - "settingsEmailUser": "Utilisateur SMTP - Utilisateur pour l'authentification, écrase l'adresse email si spécifiée", - "state": "État", - "statusBreadCrumbsStatusPages": "Pages de statut", - "statusBreadCrumbsDetails": "Détails", - "commonSaving": "Sauvegarde...", - "navControls": "Contrôle", - "incidentsPageTitle": "Incidents", - "passwordPanel": { - "passwordChangedSuccess": "Votre mot de passe a été modifié avec succès.", - "passwordInputIncorrect": "La saisie de votre mot de passe est incorrecte.", - "currentPassword": "Mot de passe actuel", - "enterCurrentPassword": "Entrez votre mot de passe actuel", - "newPassword": "Nouveau mot de passe", - "enterNewPassword": "Entrez votre nouveau mot de passe", - "confirmNewPassword": "Confirmer le nouveau mot de passe", - "passwordRequirements": "Le nouveau mot de passe doit contenir au moins 8 caractères et au moins une lettre majuscule, une lettre minuscule, un chiffre et un caractère spécial.", - "saving": "Sauvegarde..." - }, - "emailSent": "Email envoyé avec succès", - "failedToSendEmail": "Une erreur s'est produite lors de l'envoi de l'email", - "settingsTestEmailSuccess": "Email de test envoyé avec succès", - "settingsTestEmailFailed": "Une erreur s'est produite lors de l'envoi de l'email de test", - "settingsTestEmailFailedWithReason": "Une erreur s'est produite lors de l'envoi de l'email de test : {{reason}}", - "settingsTestEmailUnknownError": "Erreur inconnue", - "statusMsg": { - "paused": "Le moniteur est en pause.", - "up": "Votre site est en ligne.", - "down": "Votre site est hors-ligne.", - "pending": "En attente..." - }, - "uptimeGeneralInstructions": { - "http": "Entrez l'URL ou l'IP du moniteur (par exemple https://exemple.fr ou 192.168.1.100) et ajoutez un nom familier qui apparaîtra sur le tableau de bord.", - "ping": "Entrez l'adresse IP ou le nom d'hôte à tester (par exemple, 192.168.1.100 ou exemple.fr) et ajoutez un nom familier qui apparaîtra sur le tableau de bord.", - "docker": "Entrer l'ID Docker du container. Les identifiants Docker doivent être les 64 caractères de l'ID Docker. Vous pouvez utiliser la commande docker inspect pour avoir l'ID complet.", - "port": "Entrez l'URL ou l'adresse IP du serveur, le numéro de port et un nom d'affichage familier qui apparaîtra sur le tableau de bord.", - "game": "", - "https": "" - }, "common": { - "appName": "Checkmate", - "monitoringAgentName": "Capture", - "buttons": { - "toggleTheme": "Afficher le mode clair & sombre" + "auth": { + "roles": { + "admin": "Admin", + "demo": "Démo", + "superadmin": "Super admin", + "user": "Utilisateur" + } }, - "toasts": { - "networkError": "Erreur de réseau", - "checkConnection": "Merci de vérifier votre connexion", - "unknownError": "Erreur inconnue" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "Continuer", - "back": "Retour" + "alerts": { + "pageSpeedApiKey": { + "content": "Attention : vous n'avez pas encore ajouté de clé API Google PageSpeed. Rendez-vous dans Paramètres pour en ajouter une. Sans elle, le moniteur PageSpeed ne fonctionnera pas." + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "Détails", + "home": "Accueil" + }, + "buttons": { + "addMember": "Ajouter un membre", + "cancel": "Annuler", + "close": "Fermer", + "configure": "Configurer", + "confirm": "Confirmer", + "create": "Créer", + "delete": "Supprimer", + "generateToken": "Générer un jeton", + "incidents": "Incidents", + "inviteMember": "Inviter un membre", + "pause": "Pause", + "resume": "Reprendre", + "save": "Enregistrer", + "sendInvite": "Envoyer l'invitation", + "test": "Tester", + "testNotifications": "Tester les notifications", + "toggleTheme": "Afficher le mode clair & sombre", + "flushQueue": "Vider la file d'attente", + "notFound": "Aller au tableau de bord", + "resetPassword": "Réinitialiser le mot de passe", + "reset": "Réinitialiser", + "clear": "Effacer", + "addDemo": "Ajouter des moniteurs de démo", + "removeMonitors": "Supprimer les moniteurs", + "sendTestEmail": "Envoyer un email de test", + "exportToJSON": "Exporter en JSON", + "importFromJSON": "Importer depuis JSON", + "clearFilters": "Effacer les filtres", + "removeUser": "Supprimer l'utilisateur" + }, + "charts": { + "labels": { + "averageResponseTime": "Temps de réponse moyen", + "downtime": "Temps d'arrêt", + "high": "haut", + "low": "bas", + "uptime": "Disponibilité" }, - "inputs": { - "email": { - "label": "Email", - "placeholder": "jean.dupont@domaine.fr", - "errors": { - "empty": "Pour continuer, merci d'indiquer votre adresse email", - "invalid": "Merci de vérifier la validité de l'adresse email saisie" - } - }, - "password": { - "label": "Mot de passe", - "rules": { - "length": { - "beginning": "Doit contenir au moins", - "highlighted": "Longueur de 8 caractères" - }, - "special": { - "beginning": "Doit contenir au moins", - "highlighted": "un caractère spécial" - }, - "number": { - "beginning": "Doit contenir au moins", - "highlighted": "un nombre" - }, - "uppercase": { - "beginning": "Doit contenir au moins", - "highlighted": "un caractère majuscule" - }, - "lowercase": { - "beginning": "Doit contenir au moins", - "highlighted": "un caractère miniscule" - }, - "match": { - "beginning": "Confirmer le mot de passe et le mot de passe", - "highlighted": "doit valoir" - } - }, - "errors": { - "empty": "Merci d'entrer votre mot de passe", - "length": "Le mot de passe doit faire 8 caractères minimum", - "uppercase": "Le mot de passe doit contenir au minimum une lettre majuscule", - "lowercase": "Le mot de passe doit contenir au minimum une lettre minuscule", - "number": "Le mot de passe doit contenir au minimum un chiffre", - "special": "Le mot de passe doit contenir au minimum un caractère spécial", - "incorrect": "Le mot de passe fourni est erroné" - } - }, - "passwordConfirm": { - "label": "Confirmer le mot de passe", - "placeholder": "Entrez à nouveau le mot de passe pour confirmer", - "errors": { - "empty": "Merci d'entrer votre mot de passe à nouveau pour le confirmer (aide avec la typo)", - "different": "Les mots de passe indiqués ne sont pas identiques, l'un d'entre eux est probablement mal écrit" - } - }, - "firstName": { - "label": "Prénom", - "placeholder": "Jean", - "errors": { - "empty": "Merci d'entrer votre prénom", - "length": "Le prénom doit être inférieur à 50 caractères", - "pattern": "Le prénom ne peut contenir que des lettres, des espaces, des apostrophes ou des tirets" - } - }, - "lastName": { - "label": "Nom", - "placeholder": "Dupont", - "errors": { - "empty": "Merci d'entrer votre nom", - "length": "Le nom doit être inférieur à 50 caractères", - "pattern": "Le nom ne peut contenir que des lettres, des espaces, des apostrophes ou des tirets" + "histogram": { + "avg": "Moy : {{value}} ms", + "max": "Max : {{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "Cette action est irréversible.", + "title": "Êtes-vous sûr de vouloir supprimer ceci ?" + } + }, + "labels": { + "active": "Actif", + "paused": "en pause", + "na": "N/A", + "resolved": "Résolu", + "responseTime": "Temps de réponse" + }, + "form": { + "role": { + "option": { + "role": { + "label": "Rôles" } } }, - "errors": { - "validation": "Une erreur s'est produite lors de la validation des données." - }, - "fields": { - "password": { - "errors": { - "incorrect": "Le mot de passe fourni est erroné" + "name": { + "option": { + "firstName": { + "label": "Prénom", + "placeholder": "Jean" + }, + "lastName": { + "label": "Nom", + "placeholder": "Dupont" } - }, - "role": { - "errors": { - "min": "Au moins un rôle est requis" + } + }, + "email": { + "option": { + "email": { + "label": "Email", + "placeholder": "you@example.com" } } } }, - "login": { - "heading": "Connexion", - "subheadings": { - "stepOne": "Entrez votre adresse email", - "stepTwo": "Entrez votre mot de passe" - }, - "links": { - "forgotPassword": "Mot de passe oublié ?", - "register": "Pas encore de compte ?", - "forgotPasswordLink": "Réinitialiser le mot de passe", - "registerLink": "Inscrivez vous ici" - }, - "toasts": { - "success": "Bon retour ! Vous êtes connecté avec succès.", - "incorrectPassword": "Mot de passe incorrect" - }, + "table": { + "empty": "Rien à afficher", + "headers": { + "actions": "Actions", + "dateTime": "Date et heure", + "message": "Message", + "monitor": "Moniteur", + "monitorId": "ID du moniteur", + "name": "Nom", + "status": "Statut", + "type": "Type", + "url": "URL", + "interval": "Intervalle", + "active": "Actif", + "responseTime": "Temps de réponse" + } + } + }, + "components": { + "imageUpload": { + "clickToUpload": "Cliquer pour téléverser", + "dragAndDrop": "glisser et déposer", + "supportedFormats": "Formats supportés", + "maxSize": "Taille maximum", + "orDragAndDrop": "ou glisser et déposer", "errors": { - "password": { - "incorrect": "Le mot de passe fourni est erroné" - } - }, - "welcome": "Bon retour sur Checkmate !" + "invalidFileSize": "Le fichier est trop volumineux !", + "invalidFileFormat": "Format de fichier non supporté !" + } }, - "registration": { - "heading": { - "superAdmin": "Créer un super admin", - "user": "Inscription" - }, - "subheadings": { - "stepOne": "Entrez votre informations personnelles", - "stepTwo": "Entrez votre adresse email", - "stepThree": "Créez votre mot de passe" + "headerStatusPageControls": { + "publicLink": "Lien public" + }, + "headerTimeRange": { + "labels": { + "day": "Jour", + "month": "Mois", + "recent": "Récent", + "week": "Semaine" }, "description": { - "superAdmin": "Créer le compte \"SuperAdmin\" pour commencer", - "user": "inscription comme utilisateur et demander à l'administrateur principal l'accès aux moniteurs" + "recent": "Statistiques des 2 dernières heures.", + "day": "Statistiques des dernières 24 heures.", + "week": "Statistiques des 7 derniers jours.", + "month": "Statistiques des 30 derniers jours." + } + }, + "offlineBanner": { + "serverUnreachable": "Impossible de joindre le serveur", + "retry": "Réessayer", + "retrying": "Nouvelle tentative...", + "reconnected": "Connexion rétablie avec le serveur." + }, + "sidebar": { + "menu": { + "uptime": "Temps en ligne", + "pagespeed": "Vitesse", + "infrastructure": "Infrastructure", + "notifications": "Notifications", + "checks": "Vérifications", + "incidents": "Incidents", + "statusPages": "Pages de statut", + "maintenance": "Maintenance", + "logs": "Journaux", + "settings": "Paramètres" }, - "gettingStartedButton": { - "superAdmin": "Créer un compte super admin", - "user": "Inscription comme utilisateur normal" + "bottomMenu": { + "support": "Support", + "discussions": "Discussions", + "docs": "Documentation", + "changelog": "Journal des modifications" }, - "termsAndPolicies": "En créant un compte, vous comprenez et acceptez les Conditions d'Utilisation et la Politique de Confidentialité.", + "accountMenu": { + "profile": "Profil", + "password": "Mot de passe", + "team": "Équipe" + }, + "starPrompt": { + "title": "Star Checkmate", + "description": "Consultez les dernières versions et aidez la communauté à grandir sur GitHub" + }, + "authFooter": { + "navControls": "Contrôle", + "logOut": "Déconnexion", + "roles": { + "superAdmin": "Super admin", + "admin": "Admin", + "user": "Utilisateur", + "demoUser": "Utilisateur de démonstration" + } + } + }, + "starPrompt": { + "title": "Star Checkmate", + "description": "Consultez les dernières versions et aidez la communauté à grandir sur GitHub" + }, + "pageHeader": { "links": { - "login": "Déjà inscrit ? Connexion" - }, - "toasts": { - "success": "Bienvenue ! Votre compte a été créé avec succès." - }, - "welcome": "Bienvenue sur Checkmate !" - }, - "forgotPassword": { - "heading": "Mot de passe oublié ?", - "subheadings": { - "stepOne": "Pas d'inquiétude, nous allons vous envoyer des informations pour réinitialiser votre mot de passe.", - "stepTwo": "Nous avons envoyer un lien de réinitialisation du mot de passe à l'email ", - "stepThree": "Votre nouveau mot de passe doit être différent des précédents.", - "stepFour": "Votre mot de passe a été réinitialisé avec succès. Cliquez ci-dessous pour vous connecter." - }, - "buttons": { - "openEmail": "Ouvrir votre application email", - "resetPassword": "Réinitialiser le mot de passe" - }, - "imageAlts": { - "passwordKey": "Icône du mot de passe", - "email": "Icône de l'email", - "lock": "Icône de verrouillage", - "passwordConfirm": "Icône de confirmation du mot de passe" - }, - "links": { - "login": "Retourner à la page de connexion", - "resend": "Vous n'avez pas reçu l'email ? Cliquez ici pour le renvoyer" - }, - "toasts": { - "sent": "Instructions envoyées sur l'adresse email .", - "emailNotFound": "L'email n'a pas été trouvé.", - "redirect": "Redirection dans ...", - "success": "Votre mot de passe", - "error": "Une erreur s'est produite lors de la réinitialisation du mot de passe. Réessayez ultérieurement ou contactez le support." + "support": "Assistance", + "discussions": "Discussions", + "docs": "Documentation", + "changelog": "Journal des modifications" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "Connexion rétablie avec le serveur.", - "stillUnreachable": "Le serveur est toujours inaccessible. Réessayez ultérieurement." + "pages": { + "notFound": { + "title": "Oh non ! Vous avez fait tomber vos sushis !", + "subtitle": "Soit l'URL n'existe pas, soit vous n'y avez pas accès." + }, + "account": { + "tabs": { + "profile": "Profil", + "password": "Mot de passe", + "team": "Équipe" }, - "alertBox": "Erreur de connexion au serveur", - "description": "Nous ne parvenons pas à nous connecter au serveur. Veuillez vérifier votre connexion Internet ou vérifier votre configuration de déploiement si le problème persiste.", - "retryButton": { - "default": "Réessayer la connexion", - "processing": "Connexion..." + "form": { + "name": { + "title": "Nom", + "description": "Mettez à jour vos informations personnelles" + }, + "photo": { + "title": "Photo de profil", + "description": "Téléversez une photo de profil" + }, + "currentPassword": { + "title": "Mot de passe actuel", + "description": "Saisissez votre mot de passe actuel pour vérifier votre identité", + "option": { + "label": "Mot de passe actuel", + "placeholder": "Saisissez le mot de passe actuel" + } + }, + "newPassword": { + "title": "Nouveau mot de passe", + "description": "Choisissez un mot de passe fort d'au moins 8 caractères", + "option": { + "newPassword": { + "label": "Nouveau mot de passe", + "placeholder": "Saisissez le nouveau mot de passe" + }, + "confirm": { + "label": "Confirmer le mot de passe", + "placeholder": "Confirmez le nouveau mot de passe" + } + } + }, + "deleteAccount": { + "title": "Supprimer le compte", + "description": "Cette action est permanente et irréversible" + } + }, + "team": { + "filter": { + "placeholder": "Filtrer par rôle", + "all": "Tous", + "admin": "Admin", + "member": "Membre" + }, + "table": { + "headers": { + "email": "Email", + "role": "Rôle", + "created": "Créée" + } + }, + "addMember": { + "title": "Inscrire un nouveau membre", + "description": "Créez un nouveau compte utilisateur. Partagez les identifiants de manière sécurisée après la création." + }, + "invite": { + "title": "Inviter un membre", + "description": "Envoyez une invitation à rejoindre votre équipe", + "email": { + "label": "Adresse email", + "placeholder": "Saisissez l'adresse email" + }, + "role": { + "label": "Rôle", + "placeholder": "Sélectionnez un rôle" + }, + "linkLabel": "Lien d'invitation" + }, + "fallback": { + "title": "Aucun membre d'équipe pour le moment", + "description": "Invitez vos coéquipiers par e-mail ou ajoutez-les directement. Une fois qu'ils auront rejoint, ils apparaîtront ici avec leur rôle." + } + }, + "header": { + "title": "Compte", + "description": "Gérez votre profil, mot de passe, équipe et préférences d'espace de travail." } - } - }, - "createNotifications": { - "title": "Créer un canal de notification", - "nameSettings": { - "title": "Nom", - "description": "Une description pour votre intégration.", - "nameLabel": "Nom", - "namePlaceholder": "e.g. notifications Slack" }, - "typeSettings": { - "title": "Type", - "description": "Sélectionnez le type de canal de notification que vous souhaitez créer.", - "typeLabel": "Type" - }, - "emailSettings": { - "title": "Email", - "description": "Adresse email du destinataire", - "emailLabel": "Adresse email", - "emailPlaceholder": "e.g. jean@exemple.fr" - }, - "slackSettings": { - "title": "Slack", - "description": "Configurez votre webhook Slack ici", - "webhookLabel": "URL du webhook Slack", - "webhookPlaceholder": "https://hooks.slack.com/services/..." - }, - "pagerdutySettings": { - "title": "PagerDuty", - "description": "Configurez l'intégration PagerDuty ici", - "integrationKeyLabel": "Clé d'intégration", - "integrationKeyPlaceholder": "1234567890" - }, - "discordSettings": { - "title": "Discord", - "description": "Configurez votre webhook Discord ici", - "webhookLabel": "URL du webhook Discord", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "webhookSettings": { - "title": "Webhook", - "description": "Configurez votre webhook ici", - "webhookLabel": "URL du webhook", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "testNotification": "Notification de test", - "dialogDeleteTitle": "Voulez-vous vraiment supprimer cette notification ?", - "dialogDeleteConfirm": "Supprimer" - }, - "notificationConfig": { - "title": "Notifications", - "description": "Sélectionnez les canaux de notification que vous souhaitez utiliser" - }, - "monitorStatus": { - "checkingEvery": "Vérifier toutes les {{interval}}", - "withCaptureAgent": "avec l'agent Capture {{version}}", - "up": "en ligne", - "down": "hors ligne", - "paused": "en pause" - }, - "advancedMatching": "Filtrage avancé", - "sendTestNotifications": "Envoyer une notification de test", - "selectAll": "Sélectionner tout", - "showAdminLoginLink": "Voir le lien \"Administrateur ? Connectez-vous ici\" sur la page de statut", - "logsPage": { - "title": "Journaux", - "description": "Journaux systèmes - 1000 dernières lignes", - "tabs": { - "queue": "File d'attente des tâches", - "logs": "Journaux des serveurs", - "diagnostics": "Diagnostic" - }, - "toast": { - "fetchLogsSuccess": "Journaux récupérés avec succès" - }, - "logLevelSelect": { - "title": "Niveau de journalisation", - "values": { - "all": "Tout", - "info": "Info", - "warn": "Attention", - "error": "Erreur", - "debug": "Debug" + "auth": { + "brandPanel": { + "eyebrow": "Surveillance d'infrastructure open source", + "tagline": "Surveillez chaque mouvement de vos serveurs.", + "description": "Suivi en temps réel de la disponibilité, du temps de réponse et des incidents - auto-hébergé et à vous." + }, + "common": { + "passwordRules": { + "length": { + "beginning": "Doit contenir au moins", + "highlighted": "Longueur de 8 caractères" + }, + "lowercase": { + "beginning": "Doit contenir au moins", + "highlighted": "un caractère miniscule" + }, + "match": { + "beginning": "Confirmer le mot de passe et le mot de passe", + "highlighted": "doit valoir" + }, + "number": { + "beginning": "Doit contenir au moins", + "highlighted": "un nombre" + }, + "special": { + "beginning": "Doit contenir au moins", + "highlighted": "un caractère spécial" + }, + "uppercase": { + "beginning": "Doit contenir au moins", + "highlighted": "un caractère majuscule" + } + }, + "form": { + "option": { + "email": { + "label": "Email", + "placeholder": "me@example.com" + }, + "password": { + "label": "Mot de passe", + "placeholder": "********" + }, + "confirmPassword": { + "label": "Confirmer le mot de passe" + } + } + } + }, + "login": { + "title": "Bienvenue sur Checkmate !", + "subtitle": "Connectez-vous pour continuer", + "submit": "Connexion", + "links": { + "forgotPassword": { + "text": "Mot de passe oublié ?", + "linkText": "Réinitialiser le mot de passe" + }, + "register": { + "text": "Vous n'avez pas de compte ?", + "linkText": "Inscrivez-vous ici" + } + } + }, + "register": { + "title": "Bienvenue sur Checkmate !", + "subtitle": "Inscrivez-vous pour commencer", + "submit": "S'inscrire", + "setupNotice": "Cette page n'est disponible que lors de la configuration initiale. Une fois qu'un super administrateur est enregistré, des utilisateurs supplémentaires peuvent être invités depuis les paramètres de l'équipe." + }, + "forgotPassword": { + "title": "Mot de passe oublié ?", + "subtitle": "Pas de souci, nous vous enverrons les instructions de réinitialisation.", + "submit": "Demander la récupération", + "links": { + "login": { + "text": "Retour à la", + "linkText": "connexion" + } + } + }, + "setNewPassword": { + "title": "Réinitialisez votre mot de passe", + "subtitle": "Votre nouveau mot de passe doit être différent des mots de passe utilisés précédemment." } - } - }, - "queuePage": { - "title": "File d'attente", - "refreshButton": "Rafraîchir", - "flushButton": "Vider la file d'attente", - "jobTable": { - "title": "Tâches actuellement dans la file d'attente", - "idHeader": "ID du moniteur", - "urlHeader": "URL", - "typeHeader": "Type", - "activeHeader": "Actif", - "lockedAtHeader": "Verrouillé le", - "runCountHeader": "Nombre de tentatives", - "failCountHeader": "Nombre d'échecs", - "lastRunHeader": "Dernière tentative le", - "lastFinishedAtHeader": "Dernière finalisation à", - "lastRunTookHeader": "Dernière exécution effectuée", - "intervalHeader": "" }, - "metricsTable": { - "title": "Métriques des files", - "metricHeader": "Métrique", - "valueHeader": "Valeur" + "checks": { + "selects": { + "monitor": { + "all": "Tous les moniteurs" + }, + "status": { + "all": "Tous", + "down": "Hors ligne", + "up": "En ligne" + } + }, + "table": { + "empty": "Aucune vérification en échec sur cette période", + "headers": { + "statusCode": "Code de statut", + "location": "Emplacement" + } + }, + "fallback": { + "title": "Aucune vérification pour le moment", + "description": "Une fois que vous aurez créé un moniteur, chaque sonde d'état et temps de réponse apparaîtra ici sous forme de vérification." + }, + "header": { + "title": "Vérifications", + "description": "Chaque sonde que Checkmate exécute contre vos moniteurs apparaît ici avec le code d'état et le temps de réponse." + } }, - "failedJobTable": { - "title": "Tâches en échec", - "monitorIdHeader": "ID du moniteur", - "monitorUrlHeader": "URL du moniteur", - "failCountHeader": "Nombre de tentatives échouées", - "failedAtHeader": "Dernier échec à", - "failReasonHeader": "Raison de l'échec" - } - }, - "export": { - "title": "Export des moniteurs", - "success": "Les moniteurs ont été exportés avec succès !", - "failed": "Une erreur s'est produite lors de l'export des moniteurs" - }, - "monitorActions": { - "title": "Export/Import", - "import": "Importer des moniteurs", - "export": "Exporter des moniteurs", - "deleteSuccess": "Le moniteur a été supprimé avec succès", - "deleteFailed": "Une erreur s'est produite lors de la suppression du moniteur", - "details": "Détails" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "Développé par Bluewave Labs", - "labelVersion": "Version", - "title": "A propos" + "common": { + "monitors": { + "actions": { + "configure": "Configurer", + "delete": "Supprimer", + "generateToken": "Générer un jeton", + "details": "Détails", + "incidents": "Incidents", + "inviteMember": "Inviter un membre", + "openSite": "Ouvrir le site", + "pause": "Pause", + "resume": "Reprendre", + "selectAll": "Sélectionner tous les moniteurs", + "selectMonitor": "Sélectionner {{name}}", + "bulkSelected_one": "{{count}} moniteur sélectionné", + "bulkSelected_other": "{{count}} moniteurs sélectionnés" + }, + "statBoxes": { + "activeFor": "Actif depuis", + "certificateExpiry": "Expiration du certificat", + "lastCheck": "Dernière vérification", + "lastResponseTime": "Dernier temps de réponse", + "serviceIsDown": "Le service est indisponible" + }, + "status": { + "down": "hors ligne", + "breached": "seuil dépassé", + "initializing": "initialisation", + "maintenance": "maintenance", + "paused": "en pause", + "total": "total", + "up": "en ligne" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "Jeu", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "Port", + "optionPagespeed": "PageSpeed", + "optionHardware": "Infrastructure", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} moniteur en pause", + "paused_other": "{{count}} moniteurs en pause", + "resumed_one": "{{count}} moniteur repris", + "resumed_other": "{{count}} moniteurs repris", + "alreadyPaused_one": "Le moniteur sélectionné est déjà en pause", + "alreadyPaused_other": "Tous les moniteurs sélectionnés sont déjà en pause", + "alreadyRunning_one": "Le moniteur sélectionné est déjà en cours d'exécution", + "alreadyRunning_other": "Tous les moniteurs sélectionnés sont déjà en cours d'exécution" + } + } }, - "demoMonitorsSettings": { - "buttonAddMonitors": "Ajouter un moniteur de démonstration", - "description": "Ajouter un moniteur à des fins de démonstration.", - "title": "Moniteurs de démonstration" + "createMonitor": { + "form": { + "advanced": { + "description": "Paramètres optionnels pour les cas d'utilisation avancés", + "option": { + "advancedMatching": { + "label": "Utiliser la correspondance avancée" + }, + "expectedValue": { + "label": "Valeur attendue" + }, + "jsonPath": { + "description": "Cette expression sera évaluée sur les données JSON de la réponse et le résultat sera utilisé pour la comparaison avec la valeur attendue. Consultez jmespath.org pour la documentation du langage de requête.", + "label": "Expression JSONPath" + }, + "matchMethod": { + "label": "Méthode de correspondance", + "equal": "Égal", + "include": "Inclure", + "regex": "Regex" + } + }, + "title": "Paramètres avancés" + }, + "frequency": { + "description": "À quelle fréquence souhaitez-vous vérifier le statut de ce moniteur ?", + "option": { + "frequency": { + "label": "Fréquence de vérification", + "value": { + "fifteenMinutes": "15 minutes", + "fifteenSeconds": "15 secondes", + "fiveMinutes": "5 minutes", + "fourMinutes": "4 minutes", + "oneMinute": "1 minute", + "tenMinutes": "10 minutes", + "thirtyMinutes": "30 minutes", + "thirtySeconds": "30 secondes", + "threeMinutes": "3 minutes", + "twoMinutes": "2 minutes" + } + } + }, + "title": "Fréquence de vérification" + }, + "general": { + "option": { + "container": { + "label": "Nom/ID du conteneur", + "placeholder": "my-app ou abcd1234" + }, + "host": { + "label": "Hôte", + "placeholder": "192.168.1.100 ou example.com" + }, + "name": { + "label": "Nom d'affichage", + "placeholder": "ex. Mon site web" + }, + "secret": { + "label": "Secret d'autorisation", + "placeholder": "Saisissez votre clé secrète" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "", + "placeholder": "" + }, + "port": { + "label": "Port à surveiller", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "Nom du service", + "placeholder": "ex. my.service.v1 (laisser vide pour la santé globale)" + }, + "wsUrl": { + "label": "URL WebSocket", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Point de terminaison Capture", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "Paramètres généraux", + "description": { + "http": "Saisissez l'URL ou l'IP à surveiller (ex. https://example.com/ ou 192.168.1.100) et ajoutez un nom d'affichage clair visible sur le tableau de bord.", + "ping": "Entrez l'adresse IP ou le nom d'hôte à tester (par exemple, 192.168.1.100 ou exemple.fr) et ajoutez un nom familier qui apparaîtra sur le tableau de bord.", + "port": "Entrez l'URL ou l'adresse IP du serveur, le numéro de port et un nom d'affichage familier qui apparaîtra sur le tableau de bord.", + "docker": "Entrer l'ID Docker du container. Les identifiants Docker doivent être les 64 caractères de l'ID Docker. Vous pouvez utiliser la commande docker inspect pour avoir l'ID complet.", + "game": "Saisissez l'adresse IP ou le nom d'hôte et le numéro de port à interroger (par ex. 192.168.1.100 ou example.com) et choisissez le type de jeu. Certains jeux nécessitent l'activation des requêtes serveur dans leur fichier de configuration — consultez la documentation de votre serveur de jeu. Voir la liste des jeux pris en charge pour les détails du protocole.", + "pagespeed": "Suivez les performances de chargement, les Core Web Vitals et les scores d'optimisation de votre site web.", + "grpc": "Saisissez le nom d'hôte et le port du serveur gRPC, spécifiez éventuellement un nom de service pour le bilan de santé, et ajoutez un nom d'affichage.", + "websocket": "Saisissez l'URL WebSocket à surveiller (ex. wss://example.com/socket) et ajoutez un nom d'affichage.", + "hardware": "Surveillez l'utilisation du processeur, de la mémoire, du disque et la température de votre infrastructure. L'URL du point de terminaison Capture doit inclure le chemin complet /api/v1/metrics (par ex. http://192.168.1.10:5001/api/v1/metrics). Pour le secret d'autorisation, collez uniquement la chaîne du jeton — n'incluez pas 'Bearer'." + } + }, + "ignoreTls": { + "description": "Configurez la validation des certificats TLS/SSL pour les connexions HTTPS.", + "option": { + "tls": { + "label": "Ignorer les erreurs TLS/SSL" + } + }, + "title": "Paramètres TLS/SSL" + }, + "incidents": { + "description": "Une fenêtre glissante est utilisée pour déterminer quand un moniteur tombe en panne. Le statut d'un moniteur ne changera que lorsque le pourcentage de vérifications dans la fenêtre glissante atteindra la valeur spécifiée.", + "option": { + "checks": { + "label": "Nombre de vérifications dans la fenêtre glissante" + }, + "percentage": { + "label": "Quel pourcentage de vérifications dans la fenêtre glissante doit échouer/réussir avant que le statut du moniteur ne change ?" + } + }, + "title": "Incidents" + }, + "notifications": { + "description": "Sélectionnez les canaux de notification que vous souhaitez utiliser", + "title": "Notifications" + }, + "type": { + "description": "Sélectionnez le type de vérification à effectuer", + "optionDockerDescription": "Utilisez Docker pour surveiller si un conteneur est en cours d'exécution.", + "optionGameDescription": "Surveillez si un serveur de jeu spécifique est en ligne.", + "optionGrpcDescription": "Surveillez les services gRPC en utilisant le protocole standard de vérification de santé.", + "optionWebSocketDescription": "Surveillez les points de terminaison WebSocket pour la santé de connexion et le temps de réponse.", + "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" + }, + "thresholds": { + "title": "Seuils d'alerte", + "description": "Définissez les seuils auxquels les alertes doivent être déclenchées pour ce moniteur d'infrastructure.", + "option": { + "cpuThreshold": { + "label": "Seuil d'alerte CPU (%)" + }, + "memoryThreshold": { + "label": "Seuil d'alerte mémoire (%)" + }, + "diskThreshold": { + "label": "Seuil d'alerte disque (%)" + }, + "tempThreshold": { + "label": "Seuil d'alerte température (°C)" + } + } + }, + "geoChecks": { + "title": "Vérifications géo-distribuées", + "description": "Exécutez des vérifications depuis plusieurs emplacements géographiques pour surveiller la disponibilité et les performances globales.", + "option": { + "enabled": { + "label": "Activer les vérifications géo-distribuées" + }, + "locations": { + "label": "Emplacements", + "placeholder": "Sélectionner les emplacements", + "options": { + "EU": "Europe", + "NA": "Amérique du Nord", + "AS": "Asie", + "SA": "Amérique du Sud", + "AF": "Afrique", + "OC": "Océanie" + } + }, + "interval": { + "label": "Intervalle de vérification", + "value": { + "fiveMinutes": "5 minutes", + "tenMinutes": "10 minutes", + "fifteenMinutes": "15 minutes", + "thirtyMinutes": "30 minutes" + } + } + } + }, + "url": { + "title": "IP/URL du moniteur sur la page de statut", + "description": "Affichez l'adresse IP ou l'URL du moniteur sur la page de statut publique. Si désactivé, seul le nom du moniteur sera affiché pour protéger les informations sensibles.", + "option": { + "showURL": { + "label": "Afficher l'IP/URL sur la page de statut", + "enabled": "Activé", + "disabled": "Désactivé" + } + } + }, + "stats": { + "title": "Historique du moniteur", + "description": "Effacez tout l'historique de surveillance et les statistiques de votre équipe. Cette action est irréversible.", + "buttonText": "Effacer toutes les statistiques", + "dialog": { + "title": "Effacer tout l'historique de surveillance ?", + "description": "Ceci supprimera définitivement tout l'historique de surveillance, les statistiques et les données de vérification de votre équipe. Cette action est irréversible.", + "confirm": "Oui, tout effacer" + } + } + } }, - "emailSettings": { - "buttonSendTestEmail": "Envoyer un mail de test", - "description": "Configurez les paramètres emails pour le système. Il s'agit du système utilisé pour les notifications & alertes.", - "descriptionTransport": "Ceci créé un transport SMTP pour NodeMailer", - "labelAddress": "Adresse email - utilisée pour la connexion", - "labelConnectionHost": "Hôte SMTP - Nom d'hôte à utiliser dans l'introduction HELO/EHLO", - "labelHost": "Hôte SMTP - Nom d'hôte ou IP sur lequel se connecter", - "labelIgnoreTLS": "Désactiver STARTTLS : ne pas utiliser TLS même si le serveur le supporte", - "labelPassword": "Mot de passe SMTP - Mot de passe requis pour l'authentification", - "labelPasswordSet": "Le mot de passe est renseigné. Réinitialisez le pour le changer.", - "labelPool": "Activer le pool de connexion SMTP afin de réutiliser les connexions existantes pour améliorer la performance", - "labelPort": "Port SMTP - Port auquel se connecter", - "labelRejectUnauthorized": "Rejeter les certificats non valides : rejeter les connexions avec des certificats auto-signés ou non fiables", - "labelRequireTLS": "Forcer STARTTLS : nécessite la mise à niveau TLS, échouer si ce n'est pas supporté", - "labelSecure": "Utiliser le SSL (recommandé) : chiffrer la connexion SSL/TLS", - "labelTLSServername": "Nom du serveur TLS - Nom d'hôte facultatif pour la validation TLS lorsque l'hôte est une adresse IP", - "labelUser": "Utilisateur SMTP - Nom d'utilisateur pour l'authentification, remplace l'adresse email si spécifié", - "linkTransport": "Voir les spécifications ici", - "placeholderUser": "Laisser vide si non requis", - "title": "Email", - "toastEmailRequiredFieldsError": "L'adresse email, l'hôte, le port et le mot de passe sont obligatoires." + "editUser": { + "form": { + "roles": { + "title": "Rôles", + "description": "Attribuez des rôles à l'utilisateur. Plusieurs rôles peuvent être sélectionnés." + } + }, + "dialog": { + "removeUser": { + "title": "Supprimer l'utilisateur", + "content": "Êtes-vous sûr de vouloir supprimer {{name}} de votre équipe ? Cette action est irréversible." + } + } }, - "pageSpeedSettings": { - "description": "Entrez votre clé API Google PageSpeed pour activer la surveillance Google PageSpeed. Cliquez sur Réinitialiser pour mettre à jour la clé.", - "labelApiKeySet": "La clé API est générée. Cliquez sur \"Réinitialiser\" pour la modifier.", - "labelApiKey": "Clé API PageSpeed", - "title": "Clé API Google PageSpeed" + "incidents": { + "fallback": { + "title": "Aucun incident — tous les systèmes sont sains", + "description": "Lorsqu'un moniteur tombe en panne ou se rétablit, l'incident apparaîtra ici avec l'horodatage, les détails du moniteur et l'historique de résolution." + }, + "dialog": { + "resolveIncident": { + "title": "Résoudre l'incident", + "option": { + "comment": { + "label": "Commentaire (optionnel)", + "placeholder": "Ajoutez un commentaire sur la résolution..." + } + } + }, + "details": { + "analysis": "Analyse de l'incident", + "comment": "Commentaire :", + "detailsLabel": "Détails", + "downtime": "Temps d'arrêt :", + "message": "Message :", + "monitor": "Moniteur :", + "overview": "Aperçu", + "resolutionDetails": "Détails de la résolution", + "resolutionType": "Type :", + "resolutionTypes": { + "automatic": "Automatique", + "manual": "Manuelle" + }, + "resolve": "Résoudre l'incident", + "resolvedAt": "Résolu le :", + "resolvedBy": "Résolu par :", + "startedAt": "Démarré le :", + "status": "Statut :", + "statusCode": "Code de statut :", + "timeline": "Chronologie", + "title": "Détails de l'incident", + "url": "URL :" + } + }, + "filters": { + "allMonitors": "Tous les moniteurs", + "monitor": "Moniteur", + "resolutionType": "Type de résolution", + "resolutionTypes": { + "manual": "Manuelle", + "automatic": "Automatique", + "all": "Tous" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "Incidents actifs", + "active_zero": "Aucun incident actif", + "active_one": "{{count}} incident actif", + "active_other": "{{count}} incidents actifs" + }, + "incidentStats": { + "avgResolutionTime": "Temps de résolution moyen", + "mostAffectedMonitor": "Moniteur le plus affecté", + "title": "Statistiques des incidents", + "totalIncidents": "Total des incidents" + }, + "latestIncidents": { + "title": "Derniers incidents" + } + }, + "table": { + "actions": { + "details": "Détails", + "goToMonitor": "Aller au moniteur", + "resolveManually": "Résoudre manuellement" + }, + "activeIncidents": "Incidents actifs", + "headers": { + "endTime": "Heure de fin", + "resolutionType": "Type de résolution", + "startTime": "Date de démarrage", + "statusCode": "Code de statut" + }, + "resolvedIncidents": "Incidents résolus", + "status": { + "active": "Actif", + "resolved": "Résolu" + } + }, + "header": { + "title": "Incidents", + "description": "Lorsqu'un moniteur tombe en panne ou se rétablit, les incidents apparaissent ici avec l'horodatage, les détails du moniteur et l'historique de résolution." + } }, - "saveButtonLabel": "Sauvegarder", - "statsSettings": { - "clearAllStatsButton": "Supprimer toutes les statistiques", - "clearAllStatsDescription": "Supprimer toutes les statistiques. Cette action est irréversible.", - "clearAllStatsDialogConfirm": "Oui, supprimer toutes les statistiques", - "clearAllStatsDialogDescription": "Une fois supprimés, l'historique et les statistiques du moniteur ne peuvent plus être récupérés.", - "clearAllStatsDialogTitle": "Voulez-vous supprimer toutes les statistiques ?", - "description": "Définissez la durée pendant laquelle vous souhaitez conserver les données historiques. Vous pouvez également effacer toutes les données existantes.", - "labelTTL": "Le nombre de jours dont vous souhaitez garder l'historique.", - "labelTTLOptional": "0 pour une durée infinie", - "title": "Historique du moniteur" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "Supprimer tous les moniteurs", - "description": "Supprimer toutes les moniteurs du système.", - "dialogConfirm": "Oui, supprimer tous les moniteurs", - "dialogDescription": "Une fois supprimés, les moniteurs ne peuvent plus être récupérés.", - "dialogTitle": "Voulez-vous supprimer tous les moniteurs ?", - "title": "Réinitialisation du système" - }, - "timezoneSettings": { - "description": "Sélectionnez le fuseau horaire utilisé pour afficher les dates et les heures dans l'application.", - "label": "Fuseau horaire d'affichage", - "title": "Fuseau horaire d'affichage" - }, - "title": "Paramètres", - "uiSettings": { - "description": "Changer entre le mode clair et sombre, ou changer le langage de l'interface utilisateur.", - "labelLanguage": "Langage", - "labelTheme": "Thème", - "title": "Apparence" - }, - "urlSettings": { - "description": "Affichez l'adresse IP ou l'URL du moniteur sur la page de statut publique. Si cette option est désactivée, seul le nom du moniteur sera affiché afin de protéger les informations sensibles.", - "label": "Afficher l'IP/URL sur la page de statut", - "selectDisabled": "Désactivé", - "selectEnabled": "Activé", - "title": "IP/URL du moniteur sur la page de statut" - }, - "globalThresholds": { - "title": "Seuils globaux", - "description": "Configurez des seuils globaux pour le CPU, la mémoire, le disque et la température. Si une valeur est fournie, elle sera automatiquement activée pour la surveillance." - } - }, - "statusPageCreate": { - "buttonSave": "Sauvegarder" - }, - "incidentsOptionsHeaderFilterResolved": "Résolu", - "settingsSave": "Sauvegarder", - "statusPageCreateAppearanceTitle": "Apparence", - "confirmPassword": "Confirmer le mot de passe", - "monitorHooks": { - "failureAddDemoMonitors": "Une erreur s'est produite lors de l'ajout des moniteurs de démonstration", - "successAddDemoMonitors": "Les moniteurs de démonstration ont bien été ajoutés" - }, - "settingsAppearance": "Apparance", - "settingsDisplayTimezone": "Fuseau horaire d'affichage", - "settingsGeneralSettings": "Paramètres généraux", - "incidentsOptionsHeaderTotalIncidents": "Nombre d'incidents", - "statusPage": { - "deleteSuccess": "La page de statut a été supprimée avec succès", - "deleteFailed": "Une erreur s'est produite lors de l'ajout de la page de statut", - "createSuccess": "La page de statut a été ajoutée avec succès", - "updateSuccess": "La page de statut a été modifiée avec succès", - "generalSettings": "Paramètres généraux", - "contents": "Contenus", - "fallback": { - "checks": [ - "Surveillez et affichez l’état de santé de vos services en temps réel", - "Suivez plusieurs services et partagez leur statut", - "Tenez les utilisateurs informés des pannes et des performances" - ], - "title": "Une page de status est utilisée pour :", - "actionButton": "Créer votre première page de status !" - } - }, - "testNotificationsDisabled": "Il n'y aucune notification paramétrée pour ce moniteur. Vous pouvez en ajouter une en cliquant sur le bouton 'Configurer'", - "incidentsTableResolvedAt": "Résolu le", - "incidentsTableActionResolve": "Résoudre", - "checkHooks": { - "failureResolveOne": "Une erreur s'est produite lors de la résolution de l'incident.", - "failureResolveAll": "Une erreur s'est produite lors de la résolution des incidents.", - "failureResolveMonitor": "" - }, - "checkFormError": "Merci de vérifier les erreurs du formulaire", - "diagnosticsPage": { - "diagnosticDescription": "Diagnostic du système", - "statsDescription": "Statistiques du système", - "gauges": { - "heapAllocationTitle": "Allocation du tas", - "heapAllocationSubtitle": "% de mémoire disponible", - "heapUsageTitle": "Utilisation du tas", - "heapUsageSubtitle": "% de mémoire disponible", - "heapUtilizationTitle": "Utilisation du tas", - "heapUtilizationSubtitle": "% d'allocation", - "instantCpuUsageTitle": "Utilisation instantanée du CPU", - "instantCpuUsageSubtitle": "% de CPU utilisé" - }, - "stats": { - "eventLoopDelayTitle": "Délai de la boucle d'évènements", - "uptimeTitle": "Temps en ligne", - "usedHeapSizeTitle": "Taille du tas utilisé", - "totalHeapSizeTitle": "Taille total du tas", - "osMemoryLimitTitle": "Limite de mémoire de l'OS" - } - }, - "pageSpeedLighthouseAPI": "Utilisez l'API Lighthouse PageSpeed pour suivre votre site web", - "time": { - "threeMinutes": "3 minutes", - "fiveMinutes": "5 minutes", - "tenMinutes": "10 minutes", - "twentyMinutes": "20 minutes", - "oneHour": "1 heure", - "oneDay": "1 jour", - "oneWeek": "1 semaine", - "fourMinutes": "4 minutes", - "oneMinute": "1 minute", - "twoMinutes": "2 minutes", - "fifteenSeconds": "15 secondes", - "thirtySeconds": "30 secondes" - }, - "general": { - "noOptionsFound": "Aucune {{unit}} trouvée" - }, - "infrastructureMonitor": { - "fallback": { - "checks": [ - "Suivez les performances de vos serveurs", - "Identifiez les points de blocage et optimisez l’utilisation", - "Assurez la fiabilité grâce à la surveillance en temps réel" - ], - "title": "Une surveillance d'infrastructure est utilisé pour :", - "actionButton": "Créer votre premier moniteur d'infrastructure !" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [ - "Marquez vos périodes de maintenance", - "Éliminez tout malentendu", - "Arrêtez d’envoyer des alertes pendant les fenêtres de maintenance" - ], - "title": "Une fenêtre de maintenance est utilisé pour :", - "actionButton": "Créer votre première fenêtre de maintenance !" - } - }, - "pageSpeed": { - "fallback": { - "checks": [ - "Rapport sur l’expérience utilisateur d’une page", - "Aider à analyser la vitesse de la page web", - "Donner des suggestions sur la façon dont la page peut être améliorée" - ], - "title": "", - "actionButton": "" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [ - "Vérifiez si les sites web ou serveurs sont en ligne et réactifs", - "Alertez les équipes en cas de panne ou de problème de performance", - "Surveillez les points de terminaison HTTP, les pings, les conteneurs et les ports", - "Suivez l’historique de la disponibilité et les tendances de fiabilité" - ], - "title": "", - "actionButton": "" - } - }, - "editUserPage": { - "form": { - "email": "Email", - "firstName": "Prénom", - "lastName": "Nom", - "role": "Rôles", - "save": "Enregistrer" - }, - "table": { - "actionHeader": "Action", - "roleHeader": "Rôle" - }, - "title": "Modifier l'utilisateur", - "toast": { - "successUserUpdate": "L'utilisateur à bien été modifié", - "validationErrors": "" - } - }, - "incidentsPageActionResolveMonitor": "Résoudres les incidents de surveillance", - "incidentsPageActionResolveAll": "Résoudre tous les incidents", - "matchMethodOptions": { - "equal": "Égal", - "equalPlaceholder": "", - "include": "Inclure", - "includePlaceholder": "ok", - "regex": "Regex", - "regexPlaceholder": "", - "text": "" - }, - "monitorType": { - "docker": { - "label": "ID du conteneur", - "namePlaceholder": "Mon conteneur", - "placeholder": "abcd1234" - }, - "http": { - "label": "URL à surveiller", - "namePlaceholder": "Google", - "placeholder": "google.com" - }, - "ping": { - "label": "Adresse IP à surveiller", - "namePlaceholder": "Google", - "placeholder": "1.1.1.1" - }, - "port": { - "label": "URL à surveiller", - "namePlaceholder": "", - "placeholder": "localhost" - }, - "game": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "Chemin JSON" - }, - "bytesPerSecond": "", - "bytesReceived": "", - "bytesSent": "", - "chooseGame": "", - "createMonitorPage": { - "incidentConfigDescription": "", - "incidentConfigStatusWindowLabel": "", - "incidentConfigStatusWindowThresholdLabel": "", - "incidentConfigTitle": "", - "incidentConfigDescriptionV2": "", - "incidentConfigStatusCheckNumber": "", - "intervalTitle": "", - "intervalDescription": "" - }, - "dataRate": "", - "dataReceived": "", - "dataSent": "", - "details": "", - "drops": "", - "errors": "", - "errorsIn": "", - "errorsOut": "", - "gameServerMonitoring": "", - "gameServerMonitoringDescription": "", - "network": "", - "networkDrops": "", - "networkErrors": "", - "networkInterface": "", - "noNetworkStatsAvailable": "", - "packetsPerSecond": "", - "packetsReceived": "", - "packetsReceivedRate": "", - "packetsSent": "", - "rate": "", - "selectInterface": "", - "v1": { "infrastructure": { - "disk_selection_title": "Sélection des disques", - "disk_selection_info": "Aucun disque détecté pour le moment.", - "disk_selection_description": "Sélectionnez les disques spécifiques que vous souhaitez surveiller." + "charts": { + "labels": { + "cpu": "Utilisation CPU", + "disk": "Utilisation disque", + "memory": "Utilisation mémoire", + "netBytesRecv": "{{name}} - Octets reçus", + "netBytesSent": "{{name}} - Octets envoyés", + "netNoTraffic": "Capture ne signale aucun trafic sur toutes les interfaces. C'est normal lorsque Capture s'exécute dans un conteneur qui ne voit que les interfaces de loopback — exécutez Capture directement sur l'hôte pour voir l'activité réseau réelle.", + "temp": "Temp." + } + }, + "gauges": { + "cpu": { + "lowerLabel": "Fréquence max.", + "title": "Utilisation CPU", + "upperLabel": "Fréquence actuelle" + }, + "disk": { + "lowerLabel": "Libre", + "title": "Utilisation disque {{idx}}", + "upperLabel": "Utilisé" + }, + "memory": { + "lowerLabel": "Libre", + "title": "Utilisation mémoire", + "upperLabel": "Utilisé" + } + }, + "statBoxes": { + "avgCpuTemperature": "Température moyenne du CPU", + "cpuFrequency": "Fréquence CPU", + "cpuLogical": "CPU (Logique)", + "cpuPhysical": "CPU (Physique)", + "disk": "Disque", + "memory": "Mémoire", + "os": "OS" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "Disque", + "memory": "Mémoire" + } + }, + "tabs": { + "labels": { + "network": "", + "overview": "Aperçu" + } + }, + "fallback": { + "actionButton": "Créer un moniteur !", + "title": "Aucun moniteur d'infrastructure", + "description": "Suivez les performances des serveurs, identifiez les goulots d'étranglement et garantissez la fiabilité avec une surveillance en temps réel." + }, + "header": { + "title": "Moniteurs d'infrastructure", + "description": "Suivez les performances des serveurs, identifiez les goulots d'étranglement et garantissez la fiabilité avec une surveillance en temps réel." + } + }, + "logs": { + "fallback": { + "title": "Aucun journal pour le moment", + "description": "L'activité du serveur, les avertissements et les erreurs apparaîtront ici pendant l'exécution de Checkmate." + }, + "tabs": { + "diagnostics": "Diagnostic", + "logs": "Journaux des serveurs", + "queue": "File d'attente des tâches" + }, + "logLevelSelect": { + "label": "Niveau de log" + }, + "jobQueue": "File d'attente des tâches", + "jobQueueExplainer": "Les lignes mises en évidence sont les tâches actuellement traitées par un worker.", + "failedJobs": "Tâches échouées", + "failedJobsExplainer": "Échecs récents par moniteur — heure du dernier échec, nombre d'échecs et raison.", + "noLogs": "Aucun log trouvé", + "metrics": { + "jobs": "Tâches", + "activeJobs": "Tâches actives", + "failingJobs": "Tâches en échec", + "totalRuns": "Exécutions totales", + "totalFailures": "Échecs totaux" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "Délai de boucle d'événements", + "uptime": "Temps de fonctionnement", + "usedHeapSize": "Taille du tas utilisée", + "totalHeapSize": "Taille totale du tas", + "osMemoryLimit": "Limite mémoire de l'OS" + }, + "gauges": { + "heapAllocation": "Allocation du tas", + "heapUsage": "Utilisation du tas", + "heapUtilization": "Utilisation du tas", + "instantCpuUsage": "Utilisation CPU instantanée", + "availableMemoryPercentage": "% de mémoire disponible", + "allocatedPercentage": "% alloué", + "usedSPercentage": "% de 1s utilisé par le CPU", + "total": "Total", + "used": "Utilisé" + }, + "sections": { + "runtimeStats": { + "title": "Statistiques d'exécution", + "description": "Aperçu du processus Node.js et de la machine hôte. Mis à jour toutes les 5 secondes." + }, + "memoryCpu": { + "title": "Mémoire et CPU", + "description": "Comment le processus utilise sa mémoire allouée et le CPU de l'hôte." + } + } + }, + "table": { + "headers": { + "timestamp": "Horodatage", + "level": "Niveau", + "service": "Service", + "method": "Méthode", + "monitorId": "ID du moniteur", + "runCount": "Nombre d'exécutions", + "failCount": "Nombre d'échecs", + "lastRunAt": "Dernière exécution", + "lockedAt": "Verrouillé le", + "lastFinishedAt": "Dernière fin", + "lastRunTook": "Durée dernière exécution", + "lastFailedAt": "Dernier échec", + "failReason": "Raison de l'échec" + } + }, + "queueFallback": { + "title": "Aucun travail dans la file", + "description": "Les vérifications de moniteurs planifiées ou en cours apparaîtront ici. Une file vide signifie que tout est à jour." + }, + "header": { + "title": "Journaux et file", + "description": "Inspectez les journaux serveur, la file des tâches de moniteur et les diagnostics d'exécution au même endroit." + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "Créer une fenêtre de maintenance !", + "title": "Aucune fenêtre de maintenance planifiée", + "description": "Planifiez les interruptions afin que les alertes soient supprimées et que votre équipe et vos utilisateurs sachent à quoi s'attendre." + }, + "table": { + "headers": { + "nextWindow": "Prochaine fenêtre de maintenance", + "repeat": "Répéter" + } + }, + "form": { + "general": { + "title": "Paramètres généraux", + "description": "Définissez un nom et une option de répétition pour votre fenêtre de maintenance.", + "option": { + "name": { + "label": "Nom", + "placeholder": "ex. Maintenance hebdomadaire" + }, + "repeat": { + "label": "Répéter" + } + } + }, + "startDate": { + "title": "Date de début", + "description": "Sélectionnez la date de début de votre fenêtre de maintenance.", + "option": { + "startDate": { + "label": "Date de début" + } + } + }, + "startTime": { + "title": "Heure de début", + "description": "Définissez l'heure de début et la durée de votre fenêtre de maintenance. Toutes les valeurs sont en UTC", + "option": { + "duration": { + "label": "Durée" + }, + "startTime": { + "label": "Heure de début" + } + }, + "monitors": { + "title": "Moniteurs", + "description": "Moniteurs auxquels la fenêtre de maintenance doit s'appliquer", + "option": { + "addMonitors": { + "label": "Ajouter des moniteurs" + } + } + } + } + }, + "header": { + "title": "Fenêtres de maintenance", + "description": "Planifiez les interruptions afin que les alertes soient supprimées et que votre équipe et vos utilisateurs sachent à quoi s'attendre." + } + }, + "notifications": { + "fallback": { + "actionButton": "Créer un canal", + "title": "Aucun canal de notification", + "description": "Connectez Slack, Discord, e-mail, webhooks et plus encore pour que les bonnes personnes soient prévenues dès qu'un incident survient." + }, + "form": { + "accessToken": { + "optionAccessToken": "Jeton d'accès", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "L'adresse où les notifications seront envoyées.", + "optionAddress": "Adresse", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "Adresse" + }, + "homeServer": { + "optionHomeServer": "Serveur d'accueil", + "placeholder": "example.com" + }, + "matrix": { + "description": "Configurez la connexion de votre serveur Matrix pour les notifications.", + "title": "Configuration Matrix" + }, + "notificationName": { + "description": "Un nom descriptif pour le canal de notification", + "optionName": "Nom du canal", + "placeholder": "ex. Alertes de production", + "title": "Nom du canal" + }, + "pagerDuty": { + "description": "Votre clé d'intégration PagerDuty pour recevoir les alertes.", + "optionIntegrationKey": "Clé d'intégration", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "Clé d'intégration" + }, + "roomId": { + "optionRoomId": "ID de la salle", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "Sélectionnez le type de canal de notification à créer.", + "optionType": "Type", + "title": "Type de canal" + }, + "telegram": { + "title": "Configuration Telegram", + "description": "Pour activer les notifications Telegram, créez un bot Telegram en utilisant BotFather, le robot officiel pour créer et gérer ses propres robots. Indiquez ensuite votre clé API et l'ID du chat ici.", + "optionBotToken": "Jeton du bot", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "ID du chat", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Configuration Pushover", + "description": "Entrez votre jeton d'application et votre clé utilisateur Pushover.", + "optionAppToken": "Jeton d'application", + "placeholderAppToken": "Entrez le jeton d'application", + "optionUserKey": "Clé utilisateur", + "placeholderUserKey": "Entrez la clé utilisateur" + }, + "twilio": { + "title": "Configuration Twilio", + "description": "Entrez les identifiants de votre compte Twilio pour envoyer des SMS.", + "optionAccountSid": "SID du compte", + "placeholderAccountSid": "Entrez le SID du compte", + "optionAuthToken": "Jeton d'authentification", + "placeholderAuthToken": "Entrez le jeton d'authentification", + "optionFromNumber": "Numéro d'origine", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "Numéro de destination", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "Destination" + } + }, + "header": { + "title": "Notifications", + "description": "Connectez Slack, Discord, e-mail, webhooks et plus encore pour que les bonnes personnes soient prévenues dès qu'un incident survient." + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "Décalage cumulatif de mise en page (CLS)", + "fcp": "Premier affichage de contenu (FCP)", + "lcp": "Plus grand affichage de contenu (LCP)", + "si": "Indice de vitesse (SI)", + "tbt": "Temps de blocage total (TBT)" + }, + "legend": { + "title": "Rapport PageSpeed", + "weight": "Poids" + }, + "pie": { + "title": "Rapport de performance" + } + }, + "table": { + "headers": { + "pageSpeedScore": "Score PageSpeed" + } + }, + "fallback": { + "actionButton": "Créer un moniteur !", + "title": "Aucun moniteur PageSpeed", + "description": "Suivez les Core Web Vitals et les scores Lighthouse au fil du temps pour détecter les régressions avant vos utilisateurs." + }, + "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." + } + }, + "settings": { + "form": { + "timezone": { + "title": "Fuseau horaire d'affichage", + "description": "Sélectionnez le fuseau horaire utilisé pour afficher les dates et heures dans l'application.", + "option": { + "timezone": { + "label": "Fuseau horaire d'affichage" + } + } + }, + "ui": { + "title": "Apparence", + "description": "Basculez entre le mode clair et sombre, changez la langue ou personnalisez le type de graphique.", + "option": { + "theme": { + "label": "Mode du thème", + "light": "Clair", + "dark": "Sombre" + }, + "language": { + "label": "Langue" + }, + "chartType": { + "label": "Type de graphique", + "histogram": "Histogramme", + "heatmap": "Carte thermique" + } + } + }, + "pagespeed": { + "title": "Clé API Google PageSpeed", + "description": "Saisissez votre clé API Google PageSpeed pour activer la surveillance PageSpeed. Cliquez sur Réinitialiser pour mettre à jour la clé.", + "option": { + "apiKey": { + "label": "Clé API PageSpeed", + "labelSet": "La clé API est définie. Cliquez sur Réinitialiser pour la modifier.", + "placeholder": "Saisissez votre clé API Google PageSpeed" + } + } + }, + "url": { + "title": "IP/URL du moniteur sur la page de statut", + "description": "Affichez l'adresse IP ou l'URL du moniteur sur la page de statut publique. Si désactivé, seul le nom du moniteur sera affiché pour protéger les informations sensibles.", + "option": { + "showURL": { + "label": "Afficher l'IP/URL sur la page de statut", + "enabled": "Activé", + "disabled": "Désactivé" + } + } + }, + "stats": { + "title": "Historique du moniteur", + "description": "Effacez tout l'historique de surveillance et les statistiques de votre équipe. Cette action est irréversible.", + "option": { + "clear": { + "label": "Effacer toutes les statistiques. Cette action est irréversible." + } + }, + "dialog": { + "title": "Effacer tout l'historique de surveillance ?", + "description": "Cette action est irréversible" + } + }, + "retention": { + "title": "Rétention des vérifications", + "description": "Définissez la durée de conservation des données de vérification avant leur nettoyage automatique.", + "option": { + "days": { + "label": "Période de rétention (jours)", + "unlimited": "Illimité" + } + } + }, + "thresholds": { + "title": "Seuils globaux", + "description": "Définissez les seuils globaux de CPU, mémoire, disque et température pour la surveillance d'infrastructure. Ils s'appliquent à tous les moniteurs d'infrastructure sauf remplacement.", + "option": { + "cpu": { + "label": "Seuil CPU (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "Seuil mémoire (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "Seuil disque (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "Seuil température (°C)", + "placeholder": "1 - 150" + } + } + }, + "email": { + "title": "Paramètres email", + "description": "Configurez les paramètres email de votre système. Ils sont utilisés pour envoyer des notifications et des alertes.", + "descriptionTransport": "Ceci créé un transport SMTP pour NodeMailer", + "descriptionTransportLink": "Voir les spécifications ici", + "option": { + "host": { + "label": "Hôte email - Nom d'hôte ou adresse IP de connexion", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "Port email - Port de connexion", + "placeholder": "587" + }, + "address": { + "label": "Adresse email - Utilisée pour l'authentification", + "placeholder": "you@example.com" + }, + "user": { + "label": "Utilisateur email - Nom d'utilisateur pour l'authentification, remplace l'adresse email si spécifié", + "placeholder": "Laisser vide si non requis" + }, + "password": { + "label": "Mot de passe email - Mot de passe pour l'authentification", + "labelSet": "Le mot de passe est défini. Cliquez sur Réinitialiser pour le modifier.", + "placeholder": "Saisissez votre mot de passe" + }, + "tlsServername": { + "label": "Nom de serveur TLS - Nom d'hôte optionnel pour la validation TLS quand l'hôte est une IP", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "Hôte de connexion email - Nom d'hôte à utiliser dans le message HELO/EHLO", + "placeholder": "localhost" + }, + "secure": { + "label": "Utiliser SSL (recommandé) : chiffrer la connexion avec SSL/TLS" + }, + "pool": { + "label": "Activer le pool de connexions : réutiliser les connexions existantes pour améliorer les performances" + }, + "ignoreTLS": { + "label": "Désactiver STARTTLS : ne pas utiliser TLS même si le serveur le supporte" + }, + "requireTLS": { + "label": "Forcer STARTTLS : exiger la mise à niveau TLS, échouer si non supporté" + }, + "rejectUnauthorized": { + "label": "Rejeter les certificats invalides : refuser les connexions avec des certificats auto-signés ou non fiables" + } + } + }, + "demoMonitors": { + "title": "Moniteurs de démonstration", + "description": "Ajoutez des moniteurs d'exemple à des fins de démonstration." + }, + "removeMonitors": { + "title": "Réinitialiser le système", + "description": "Supprimer tous les moniteurs de votre système.", + "dialog": { + "title": "Supprimer tous les moniteurs ?", + "description": "Cette action est irréversible.", + "paragraph": "L'historique des vérifications, les incidents, les fenêtres de maintenance planifiées et les pages de statut qui utilisaient ces moniteurs seront également supprimés. Exportez d'abord les moniteurs si vous souhaitez conserver une sauvegarde de leurs données." + } + }, + "exportMonitors": { + "title": "Exporter les moniteurs" + }, + "importExportMonitors": { + "title": "Importer / Exporter les moniteurs", + "description": "Importez ou exportez vos données de moniteurs sous forme de fichier JSON pour sauvegarde ou transfert." + }, + "about": { + "title": "À propos", + "developedBy": "Développé par Bluewave Labs" + }, + "validation": { + "errorMessage": "Veuillez corriger les erreurs de validation suivantes :" + } + }, + "header": { + "title": "Paramètres", + "description": "Configurez SMTP, intégrations, rétention et comportement global de l'application." + } + }, + "statusPages": { + "deleteSuccess": "Page de statut supprimée avec succès", + "fallback": { + "title": "Aucune page de statut", + "actionButton": "Créer une page de statut !", + "description": "Publiez une page publique qui montre à vos clients et parties prenantes la disponibilité en temps réel et l'historique des incidents." + }, + "monitorsList": { + "chartTypeHeatmap": "Carte thermique", + "chartTypeHistogram": "Histogramme", + "noData": "Aucune donnée disponible", + "infrastructure": { + "title": "Infrastructure", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "Mémoire", + "disk": "Disque", + "usage": "Utilisation", + "used": "Utilisé", + "total": "Total" + }, + "uptime": { + "title": "Disponibilité", + "responseTime": "Temps de réponse" + } + }, + "statusBar": { + "allDown": "Tous les systèmes sont hors service", + "allUp": "Tous les systèmes sont opérationnels", + "degraded": "Certains systèmes rencontrent des problèmes", + "unknown": "Impossible de déterminer le statut du système", + "allBreached": "Tous les systèmes ont dépassé les seuils", + "allMaintenance": "Tous les systèmes sont en maintenance", + "allPaused": "Tous les systèmes sont en pause", + "allInitializing": "Tous les systèmes sont en cours d'initialisation", + "breachedAndDown": "Certains systèmes ont dépassé les seuils et sont hors ligne", + "breached": "Certains systèmes ont dépassé les seuils", + "maintenanceAndDown": "Certains systèmes sont hors ligne pendant la maintenance", + "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é" + }, + "table": { + "headers": { + "name": "Nom de la page de statut", + "url": "URL publique" + }, + "published": "Publiée", + "unpublished": "Non publiée" + }, + "title": "Pages de statut", + "details": { + "empty": { + "title": "Il n'y a rien ici pour le moment", + "addMonitor": "Ajoutez un moniteur pour commencer" + } + }, + "form": { + "access": { + "title": "Accès", + "description": "Si votre page de statut est prête, vous pouvez la marquer comme publiée.", + "option": { + "published": { + "name": "Publiée et visible par le public" + } + } + }, + "basicInfo": { + "title": "Informations de base", + "description": "Définissez le nom de l'entreprise et le sous-domaine vers lequel pointe votre page de statut.", + "option": { + "name": { + "label": "Nom de l'entreprise", + "placeholder": "Acme Inc." + }, + "url": { + "label": "Adresse de votre page de statut", + "placeholder": "ma-page-de-statut" + } + } + }, + "monitors": { + "title": "Moniteurs", + "description": "Sélectionnez les moniteurs à afficher sur votre page de statut.", + "noMonitors": "Aucun moniteur sélectionné", + "option": { + "monitors": { + "label": "Sélectionner les moniteurs", + "placeholder": "Rechercher et sélectionner des moniteurs..." + } + } + }, + "timezone": { + "title": "Fuseau horaire", + "description": "Sélectionnez le fuseau horaire d'affichage de votre page de statut.", + "option": { + "timezone": { + "label": "Fuseau horaire", + "placeholder": "Sélectionner un fuseau horaire..." + } + } + }, + "appearance": { + "title": "Apparence", + "description": "Définissez l'apparence par défaut de votre page de statut publique.", + "option": { + "logo": { + "label": "Logo" + }, + "color": { + "label": "Couleur de marque" + } + } + }, + "features": { + "title": "Fonctionnalités", + "description": "Configurez les informations affichées sur votre page de statut.", + "option": { + "showCharts": { + "label": "Afficher les graphiques de temps de réponse" + }, + "showUptimePercentage": { + "label": "Afficher le pourcentage de disponibilité" + }, + "showAdminLoginLink": { + "label": "Afficher le lien de connexion administrateur" + }, + "showInfrastructure": { + "label": "Afficher les métriques d'infrastructure" + } + } + } + }, + "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." + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "Rechercher des moniteurs..." + } + }, + "table": { + "headers": { + "responseTime": "Temps de réponse" + } + }, + "fallback": { + "actionButton": "Créer un moniteur !", + "title": "Aucun moniteur de disponibilité", + "description": "Surveillez les points de terminaison HTTP, les pings, les conteneurs et les ports — soyez alerté dès qu'une panne survient." + }, + "header": { + "title": "Moniteurs de disponibilité", + "description": "Surveillez les points de terminaison HTTP, les pings, les conteneurs et les ports — soyez alerté dès qu'une panne survient." + } } } } diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 68d94139f..ecd1b7e87 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -1,1119 +1,1447 @@ { - "submit": "送信", - "title": "タイトル", - "distributedStatusHeaderText": "リアルタイム、実デバイス対応", - "distributedStatusSubHeaderText": "世界中の数百万台のデバイスを活用し、グローバル地域、国、都市別にシステムパフォーマンスを表示", - "settingsDisabled": "無効", - "settingsSuccessSaved": "設定が正常に保存されました", - "settingsFailedToSave": "設定の保存に失敗しました", - "settingsStatsCleared": "統計が正常にクリアされました", - "settingsFailedToClearStats": "統計のクリアに失敗しました", - "settingsMonitorsDeleted": "すべてのモニターが正常に削除されました", - "settingsFailedToDeleteMonitors": "すべてのモニターの削除に失敗しました", - "starPromptTitle": "Checkmateにスター", - "starPromptDescription": "GitHubで最新リリースを確認し、コミュニティの成長を支援", - "https": "HTTPS", - "http": "HTTP", - "monitor": "モニター", - "aboutus": "私たちについて", - "now": "今", - "delete": "削除", - "configure": "設定", - "responseTime": "レスポンス時間", - "ms": "ミリ秒", - "bar": "バー", - "area": "エリア", - "country": "国", - "city": "都市", - "response": "レスポンス", - "monitorStatusUp": "モニター {name} ({url}) がアップして応答中です", - "monitorStatusDown": "モニター {name} ({url}) がダウンして応答していません", - "webhookSendSuccess": "Webhook通知が正常に送信されました", - "webhookSendError": "{platform}へのWebhook通知送信エラー", - "webhookUnsupportedPlatform": "サポートされていないプラットフォーム: {platform}", - "distributedRightCategoryTitle": "モニター", - "distributedStatusServerMonitors": "サーバーモニター", - "distributedStatusServerMonitorsDescription": "関連サーバーのステータスを監視", - "distributedUptimeCreateSelectURL": "ここでホストのURL、モニタータイプを選択できます。", - "distributedUptimeCreateChecks": "実行するチェック", - "distributedUptimeCreateChecksDescription": "サイト追加後はいつでもチェックを追加・削除できます。", - "distributedUptimeCreateIncidentNotification": "インシデント通知", - "distributedUptimeCreateIncidentDescription": "インシデント発生時、ユーザーに通知。", - "distributedUptimeCreateAdvancedSettings": "詳細設定", - "distributedUptimeDetailsNoMonitorHistory": "このモニターのチェック履歴はまだありません。", - "distributedUptimeDetailsStatusHeaderUptime": "稼働時間:", - "distributedUptimeDetailsStatusHeaderLastUpdate": "最終更新", - "notifications": { - "enableNotifications": "{{platform}}通知を有効化", - "testNotification": "テスト通知", - "addOrEditNotifications": "通知を追加または編集", - "slack": { - "label": "Slack", - "description": "Slack通知を有効にするには、Slackアプリを作成し、incoming webhookを有効にします。その後、ここでWebhook URLを提供するだけです。", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "Slack Webhook URLが必要です" - }, - "discord": { - "label": "Discord", - "description": "Webhookを使用してDiscord通知経由でCheckmateからDiscordチャンネルにデータを送信するには、DiscordのIncoming Webhook機能を使用できます。", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "Discord Webhook URLが必要です" - }, - "telegram": { - "label": "Telegram", - "description": "Telegram通知を有効にするには、Telegramボットを作成・管理するための公式ボットであるBotFatherを使用してTelegramボットを作成します。次に、APIトークンとチャットIDを取得してここに記入します。", - "tokenLabel": "ボットトークン", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "チャットID", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "TelegramトークンとチャットIDが必要です" - }, - "webhook": { - "label": "Webhook", - "description": "インシデント発生時に通知を受信するためのカスタムWebhookを設定できます。", - "urlLabel": "Webhook URL", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "Webhook URLが必要です" - }, - "testNotificationDevelop": "テスト通知2", - "integrationButton": "通知統合", - "testSuccess": "テスト通知が正常に送信されました!", - "testFailed": "テスト通知の送信に失敗しました", - "unsupportedType": "サポートされていない通知タイプ", - "networkError": "ネットワークエラーが発生しました", - "fallback": { - "title": "通知チャンネル", - "checks": [ - "ダウンタイムやパフォーマンス問題についてチームにアラート", - "インシデント発生時にエンジニアに通知", - "システム変更について管理者に情報提供" - ], - "actionButton": "" - }, - "createButton": "通知チャンネルを作成", - "createTitle": "通知チャンネル", - "create": { - "success": "通知が正常に作成されました", - "failed": "通知の作成に失敗しました" - }, - "fetch": { - "success": "通知が正常に取得されました", - "failed": "通知の取得に失敗しました" - }, - "delete": { - "success": "通知が正常に削除されました", - "failed": "通知の削除に失敗しました" - }, - "edit": { - "success": "通知が正常に更新されました", - "failed": "通知の更新に失敗しました" - }, - "test": { - "success": "テスト通知が正常に送信されました", - "failed": "テスト通知の送信に失敗しました" - } - }, - "testLocale": "テストロケール", - "add": "追加", - "monitors": "モニター", - "distributedUptimeStatusCreateStatusPage": "ステータスページ", - "distributedUptimeStatusCreateStatusPageAccess": "アクセス", - "distributedUptimeStatusCreateStatusPageReady": "ステータスページの準備ができたら、公開済みとしてマークできます。", - "distributedUptimeStatusBasicInfoHeader": "基本情報", - "distributedUptimeStatusBasicInfoDescription": "会社名とステータスページが指すサブドメインを定義。", - "distributedUptimeStatusLogoHeader": "ロゴ", - "distributedUptimeStatusLogoDescription": "ステータスページ用のロゴをアップロード", - "distributedUptimeStatusLogoUploadButton": "ロゴをアップロード", - "distributedUptimeStatusStandardMonitorsHeader": "標準モニター", - "distributedUptimeStatusStandardMonitorsDescription": "標準モニターをステータスページに添付。", - "distributedUptimeStatusCreateYour": "作成する", - "distributedUptimeStatusEditYour": "編集する", - "distributedUptimeStatusPublishedLabel": "公開済みで一般に表示", - "distributedUptimeStatusCompanyNameLabel": "会社名", - "distributedUptimeStatusPageAddressLabel": "ステータスページアドレス", - "distributedUptimeStatus30Days": "30日", - "distributedUptimeStatus60Days": "60日", - "distributedUptimeStatus90Days": "90日", - "distributedUptimeStatusPageNotSetUp": "ステータスページが設定されていません。", - "distributedUptimeStatusContactAdmin": "管理者にお問い合わせください", - "distributedUptimeStatusPageNotPublic": "このステータスページは非公開です。", - "distributedUptimeStatusPageDeleteDialog": "このステータスページを削除しますか?", - "distributedUptimeStatusPageDeleteConfirm": "はい、ステータスページを削除", - "distributedUptimeStatusPageDeleteDescription": "削除されると、ステータスページは復元できません。", - "distributedUptimeStatusDevices": "デバイス", - "distributedUptimeStatusUpt": "UPT", - "distributedUptimeStatusUptBurned": "UPT消費", - "distributedUptimeStatusUptLogo": "Uptロゴ", - "incidentsTableNoIncidents": "記録されたインシデントはありません", - "incidentsTablePaginationLabel": "インシデント", - "incidentsTableMonitorName": "モニター名", - "incidentsTableStatus": "ステータス", - "incidentsTableDateTime": "日付と時刻", - "incidentsTableStatusCode": "ステータスコード", - "incidentsTableMessage": "メッセージ", - "incidentsOptionsHeader": "インシデント対象:", - "incidentsOptionsHeaderFilterBy": "フィルター:", - "incidentsOptionsHeaderFilterAll": "すべて", - "incidentsOptionsHeaderFilterDown": "ダウン", - "incidentsOptionsHeaderFilterCannotResolve": "解決不可", - "incidentsOptionsHeaderShow": "表示:", - "incidentsOptionsHeaderLastHour": "最終時間", - "incidentsOptionsHeaderLastDay": "最終日", - "incidentsOptionsHeaderLastWeek": "最終週", - "incidentsOptionsPlaceholderAllServers": "すべてのサーバー", - "infrastructureCreateYour": "作成する", - "infrastructureCreateGeneralSettingsDescription": "ここでホストのURL、フレンドリーネーム、サーバーエージェントに接続するための認証シークレットを選択できます。", - "infrastructureServerRequirement": "監視中のサーバーで実行されている必要があります", - "infrastructureCustomizeAlerts": "アラートをカスタマイズ", - "infrastructureAlertNotificationDescription": "しきい値が指定された割合を超えた場合にユーザーに通知を送信。", - "infrastructureCreateMonitor": "インフラストラクチャモニターを作成", - "infrastructureProtocol": "プロトコル", - "infrastructureServerUrlLabel": "サーバーURL", - "infrastructureDisplayNameLabel": "表示名", - "infrastructureAuthorizationSecretLabel": "認証シークレット", - "gb": "GB", - "mb": "MB", - "mem": "メモリ", - "memoryUsage": "メモリ使用率", - "cpu": "CPU", - "cpuUsage": "CPU使用率", - "cpuTemperature": "CPU温度", - "diskUsage": "ディスク使用率", - "used": "使用済み", - "total": "合計", - "cores": "コア", - "frequency": "頻度", - "status": "ステータス", - "cpuPhysical": "CPU(物理)", - "cpuLogical": "CPU(論理)", - "cpuFrequency": "CPU周波数", - "avgCpuTemperature": "平均CPU温度", - "memory": "メモリ", - "disk": "ディスク", - "uptime": "稼働時間", - "os": "OS", - "host": "ホスト", - "actions": "アクション", - "integrations": "統合", - "integrationsPrism": "Prismをお気に入りのサービスに接続。", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "Slackと接続してチャンネルでインシデントを表示", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "Discordと接続してチャンネルでインシデントを直接表示", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "すべてのインシデントをZapierに送信し、どこでも表示", - "commonSave": "保存", - "createYour": "作成する", - "createMonitor": "モニターを作成", - "pause": "一時停止", - "resume": "再開", - "editing": "編集中...", - "url": "URL", - "access": "アクセス", - "timezone": "タイムゾーン", - "features": "機能", - "administrator": "管理者?", - "loginHere": "こちらでログイン", - "displayName": "表示名", - "urlMonitor": "監視するURL", - "portToMonitor": "監視するポート", - "websiteMonitoring": "ウェブサイト監視", - "websiteMonitoringDescription": "HTTP(s)を使用してウェブサイトやAPIエンドポイントを監視。", - "pingMonitoring": "Ping監視", - "pingMonitoringDescription": "サーバーが利用可能かどうかをチェック。", - "dockerContainerMonitoring": "Dockerコンテナ監視", - "dockerContainerMonitoringDescription": "Dockerコンテナが実行中かどうかをチェック。", - "portMonitoring": "ポート監視", - "portMonitoringDescription": "ポートが開いているかどうかをチェック。", - "createMaintenanceWindow": "メンテナンスウィンドウを作成", - "createMaintenance": "メンテナンスを作成", - "editMaintenance": "メンテナンスを編集", - "maintenanceWindowName": "メンテナンスウィンドウ名", - "friendlyNameInput": "フレンドリーネーム", - "friendlyNamePlaceholder": "__:__で___分間のメンテナンス", - "maintenanceRepeat": "メンテナンス繰り返し", - "maintenance": "メンテナンス", - "duration": "期間", - "addMonitors": "モニターを追加", - "window": "ウィンドウ", - "cancel": "キャンセル", - "message": "メッセージ", - "low": "低", - "high": "高", - "statusCode": "ステータスコード", - "date&Time": "日付と時刻", - "type": "タイプ", - "statusPageName": "ステータスページ名", - "publicURL": "公開URL", - "repeat": "繰り返し", - "edit": "編集", - "createA": "作成する", - "remove": "削除", - "maintenanceWindowDescription": "この時間帯はpingが送信されません", - "startTime": "開始時刻", - "timeZoneInfo": "すべての日付と時刻はGMT+0タイムゾーンです。", - "monitorsToApply": "メンテナンスウィンドウを適用するモニター", - "nextWindow": "次のウィンドウ", - "notFoundButton": "メインダッシュボードに移動", - "pageSpeedConfigureSettingsDescription": "ここでホストのURL、モニタータイプを選択できます。", - "monitorDisplayName": "モニター表示名", - "whenNewIncident": "新しいインシデントが発生した場合、", - "notifySMS": "SMS経由で通知(近日公開)", - "notifyEmails": "複数のアドレスにメールでも通知(近日公開)", - "seperateEmails": "複数のメールはカンマで区切ることができます", - "checkFrequency": "チェック頻度", - "matchMethod": "マッチ方法", - "expectedValue": "期待値", - "deleteDialogTitle": "本当にこのモニターを削除しますか?", - "deleteDialogDescription": "削除されると、このモニターは復元できません。", - "pageSpeedMonitor": "PageSpeedモニター", - "shown": "表示", - "ago": "前", - "companyName": "会社名", - "pageSpeedDetailsPerformanceReport": "値は推定値で変動する可能性があります。", - "pageSpeedDetailsPerformanceReportCalculator": "計算機を見る", - "checkingEvery": "チェック間隔", - "statusPageCreateSettings": "ステータスページの準備ができたら、公開済みとしてマークできます。", - "basicInformation": "基本情報", - "statusPageCreateBasicInfoDescription": "会社名とステータスページが指すサブドメインを定義。", - "statusPageCreateSelectTimeZoneDescription": "ステータスページが表示されるタイムゾーンを選択。", - "statusPageCreateAppearanceDescription": "公開ステータスページのデフォルトの外観を定義。", - "statusPageCreateSettingsCheckboxLabel": "公開済みで一般に表示", - "statusPageCreateBasicInfoStatusPageAddress": "ステータスページアドレス", - "statusPageCreateTabsContent": "ステータスページサーバー", - "statusPageCreateTabsContentDescription": "監視している任意の数のサーバーをステータスページに追加できます。最適な表示体験のために並び替えることもできます。", - "statusPageCreateTabsContentFeaturesDescription": "ステータスページでより多くの詳細を表示", - "showCharts": "チャートを表示", - "showUptimePercentage": "稼働率を表示", - "removeLogo": "ロゴを削除", - "statusPageStatus": "公開ステータスページが設定されていません。", - "statusPageStatusContactAdmin": "管理者にお問い合わせください", - "statusPageStatusNotPublic": "このステータスページは非公開です。", - "statusPageStatusNoPage": "ここにはステータスページがありません。", - "statusPageStatusServiceStatus": "サービスステータス", - "deleteStatusPage": "このステータスページを削除しますか?", - "deleteStatusPageConfirm": "はい、ステータスページを削除", - "deleteStatusPageDescription": "削除されると、ステータスページは復元できません。", - "uptimeCreate": "期待値はレスポンス結果と照合するために使用され、マッチがステータスを決定します。", - "uptimeCreateJsonPath": "この式はレスポンスJSONデータに対して評価され、結果は期待値との照合に使用されます。参照", - "uptimeCreateJsonPathQuery": "クエリ言語ドキュメント。", - "maintenanceTableActionMenuDialogTitle": "本当にこのメンテナンスウィンドウを削除しますか?", - "infrastructureEditYour": "編集する", - "infrastructureEditMonitor": "インフラストラクチャモニターを保存", - "infrastructureMonitorCreated": "インフラストラクチャモニターが正常に作成されました!", - "infrastructureMonitorUpdated": "インフラストラクチャモニターが正常に更新されました!", - "errorInvalidTypeId": "無効な通知タイプが提供されました", - "errorInvalidFieldId": "無効なフィールドIDが提供されました", - "inviteNoTokenFound": "招待トークンが見つかりません", - "pageSpeedWarning": "警告: Google PageSpeed APIキーがまだ追加されていません。これがないとPageSpeedモニターは機能しません。", - "pageSpeedLearnMoreLink": "こちらをクリック", - "pageSpeedAddApiKey": "APIキーを追加する。", - "update": "更新", - "invalidFileFormat": "サポートされていないファイル形式!", - "invalidFileSize": "ファイルサイズが大きすぎます!", - "ClickUpload": "クリックしてアップロード", - "DragandDrop": "ドラッグアンドドロップ", - "MaxSize": "最大サイズ", - "SupportedFormats": "対応フォーマット", - "FirstName": "名", - "LastName": "姓", - "EmailDescriptionText": "こちらは現在ご使用のメールアドレスです — 変更することはできません。", - "YourPhoto": "プロフィール写真", - "PhotoDescriptionText": "この写真はプロフィールページに表示されます。", - "save": "保存", - "DeleteDescriptionText": "これにより、アカウントと関連するすべてのデータがサーバーから削除されます。この操作は元に戻せません。", - "DeleteAccountWarning": "アカウントを削除すると、再度サインインできなくなり、すべてのデータが削除されます。この操作は元に戻せません。", - "DeleteWarningTitle": "本当にこのアカウントを削除しますか?", - "bulkImport": { - "title": "一括インポート", - "selectFileTips": "アップロードするCSVファイルを選択", - "selectFileDescription": "またはサンプルをダウンロードできます", - "selectFile": "ファイルを選択", - "parsingFailed": "解析に失敗しました", - "uploadSuccess": "モニターが正常に作成されました!", - "validationFailed": "検証に失敗しました", - "noFileSelected": "ファイルが選択されていません", - "fallbackPage": "サーバーのリストを一括アップロードするためのファイルをインポート", - "invalidFileType": "無効なファイルタイプ", - "uploadFailed": "アップロードに失敗しました" - }, - "DeleteAccountTitle": "アカウントを削除", - "DeleteAccountButton": "アカウントを削除", - "publicLink": "公開リンク", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "reset": "リセット", - "ignoreTLSError": "TLS/SSLエラーを無視", - "tlsErrorIgnored": "TLS/SSLエラーが無視されました", - "ignoreTLSErrorDescription": "TLS/SSLエラーを無視してウェブサイトの可用性をチェック続行", - "createNew": "新規作成", - "greeting": { - "prepend": "こんにちは", - "append": "午後はあなたの遊び場です—壮大なものにしましょう!", - "overview": "{{type}}モニターの概要です。" - }, - "roles": { - "superAdmin": "スーパー管理者", - "admin": "管理者", - "teamMember": "チームメンバー", - "demoUser": "デモユーザー" - }, - "teamPanel": { - "teamMembers": "チームメンバー", - "filter": { - "all": "すべて", - "member": "メンバー" - }, - "inviteTeamMember": "チームメンバーを招待", - "inviteNewTeamMember": "新しいチームメンバーを招待", - "inviteDescription": "新しいチームメンバーを追加すると、すべてのモニターへのアクセス権が付与されます。", - "email": "メール", - "selectRole": "役割を選択", - "inviteLink": "招待リンク", - "cancel": "キャンセル", - "noMembers": "この役割のチームメンバーはいません", - "getToken": "トークンを取得", - "emailToken": "メールトークン", - "table": { - "name": "名前", - "email": "メール", - "role": "役割", - "created": "作成日" - }, - "addTeamMember": { - "addMemberMenu": "", - "title": "", - "description": "", - "addButton": "" - }, - "register": "", - "registerToast": { - "success": "", - "dbUserExists": "", - "unknownError": "" - }, - "registerTeamMember": { - "title": "", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "lastName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "email": { - "errors": { - "empty": "", - "invalid": "" - } - }, - "role": { - "errors": { - "empty": "" - } - } - } - } - } - }, - "role": "", - "changeTeamPassword": { - "changePasswordMenu": "", - "title": "", - "description": "", - "success": "" - } - }, - "monitorState": { - "paused": "一時停止", - "resumed": "再開", - "active": "アクティブ" - }, - "menu": { - "uptime": "稼働時間", - "pagespeed": "ページ速度", - "infrastructure": "インフラストラクチャ", - "incidents": "インシデント", - "statusPages": "ステータスページ", - "maintenance": "メンテナンス", - "integrations": "統合", - "settings": "設定", - "support": "サポート", - "discussions": "ディスカッション", - "docs": "ドキュメント", - "changelog": "変更履歴", - "profile": "プロフィール", - "password": "パスワード", - "team": "チーム", - "logOut": "ログアウト", - "notifications": "通知", - "logs": "ログ" - }, - "settingsEmailUser": "メールユーザー - 認証用ユーザー名、指定されている場合はメールアドレスを上書き", - "state": "状態", - "statusBreadCrumbsStatusPages": "ステータスページ", - "statusBreadCrumbsDetails": "詳細", - "commonSaving": "保存中...", - "navControls": "コントロール", - "incidentsPageTitle": "インシデント", - "passwordPanel": { - "passwordChangedSuccess": "パスワードが正常に変更されました。", - "passwordInputIncorrect": "パスワード入力が間違っています。", - "currentPassword": "現在のパスワード", - "enterCurrentPassword": "現在のパスワードを入力", - "newPassword": "新しいパスワード", - "enterNewPassword": "新しいパスワードを入力", - "confirmNewPassword": "新しいパスワードの確認", - "passwordRequirements": "新しいパスワードは少なくとも8文字で、少なくとも1つの大文字、1つの小文字、1つの数字、1つの特殊文字を含む必要があります。", - "saving": "保存中..." - }, - "emailSent": "メールが正常に送信されました", - "failedToSendEmail": "メールの送信に失敗しました", - "settingsTestEmailSuccess": "テストメールが正常に送信されました", - "settingsTestEmailFailed": "テストメールの送信に失敗しました", - "settingsTestEmailFailedWithReason": "テストメールの送信に失敗しました: {{reason}}", - "settingsTestEmailUnknownError": "不明なエラー", - "statusMsg": { - "paused": "監視が一時停止されています。", - "up": "サイトが稼働中です。", - "down": "サイトがダウンしています。", - "pending": "保留中..." - }, - "uptimeGeneralInstructions": { - "http": "監視するURLまたはIPを入力(例: https://example.com/ または 192.168.1.100)し、ダッシュボードに表示される明確な表示名を追加。", - "ping": "pingするIPアドレスまたはホスト名を入力(例: 192.168.1.100 または example.com)し、ダッシュボードに表示される明確な表示名を追加。", - "docker": "コンテナのDocker IDを入力。Docker IDは64文字のフルDocker IDである必要があります。docker inspect を実行してフルコンテナIDを取得できます。", - "port": "サーバーのURLまたはIP、ポート番号、ダッシュボードに表示される明確な表示名を入力。", - "game": "", - "https": "" - }, "common": { - "appName": "Checkmate", - "monitoringAgentName": "Capture", - "buttons": { - "toggleTheme": "ライト&ダークを切り替え" + "auth": { + "roles": { + "admin": "管理者", + "demo": "デモ", + "superadmin": "スーパー管理者", + "user": "ユーザー" + } }, - "toasts": { - "networkError": "ネットワークエラー", - "checkConnection": "接続を確認してください", - "unknownError": "不明なエラー" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "続行", - "back": "戻る" + "alerts": { + "pageSpeedApiKey": { + "content": "警告: Google PageSpeed APIキーがまだ追加されていません。設定からキーを追加してください。キーがないとPageSpeedモニターは機能しません。" + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "詳細", + "home": "ホーム" + }, + "buttons": { + "addMember": "メンバーを追加", + "cancel": "キャンセル", + "close": "閉じる", + "configure": "設定", + "confirm": "確認", + "create": "作成", + "delete": "削除", + "generateToken": "トークンを生成", + "incidents": "インシデント", + "inviteMember": "メンバーを招待", + "pause": "一時停止", + "resume": "再開", + "save": "保存", + "sendInvite": "招待を送信", + "test": "テスト", + "testNotifications": "通知テスト", + "toggleTheme": "ライト&ダークを切り替え", + "flushQueue": "キューをフラッシュ", + "notFound": "メインダッシュボードへ移動", + "resetPassword": "パスワードをリセット", + "reset": "リセット", + "clear": "クリア", + "addDemo": "デモモニターを追加", + "removeMonitors": "モニターを削除", + "sendTestEmail": "テストメールを送信", + "exportToJSON": "JSONにエクスポート", + "importFromJSON": "JSONからインポート", + "clearFilters": "フィルターをクリア", + "removeUser": "ユーザーを削除" + }, + "charts": { + "labels": { + "averageResponseTime": "平均応答時間", + "downtime": "ダウンタイム", + "high": "高", + "low": "低", + "uptime": "稼働時間" }, - "inputs": { - "email": { - "label": "メール", - "placeholder": "jordan.ellis@domain.com", - "errors": { - "empty": "続行するには、メールアドレスを入力してください", - "invalid": "入力されたメールアドレスの有効性を再確認してください" - } - }, - "password": { - "label": "パスワード", - "rules": { - "length": { - "beginning": "少なくとも", - "highlighted": "8文字である必要があります" - }, - "special": { - "beginning": "少なくとも", - "highlighted": "1つの特殊文字を含む必要があります" - }, - "number": { - "beginning": "少なくとも", - "highlighted": "1つの数字を含む必要があります" - }, - "uppercase": { - "beginning": "少なくとも", - "highlighted": "1つの大文字を含む必要があります" - }, - "lowercase": { - "beginning": "少なくとも", - "highlighted": "1つの小文字を含む必要があります" - }, - "match": { - "beginning": "パスワードの確認とパスワードは", - "highlighted": "一致する必要があります" - } - }, - "errors": { - "empty": "パスワードを入力してください", - "length": "パスワードは少なくとも8文字である必要があります", - "uppercase": "パスワードは少なくとも1つの大文字を含む必要があります", - "lowercase": "パスワードは少なくとも1つの小文字を含む必要があります", - "number": "パスワードは少なくとも1つの数字を含む必要があります", - "special": "パスワードは少なくとも1つの特殊文字を含む必要があります", - "incorrect": "入力されたパスワードが記録と一致しません" - } - }, - "passwordConfirm": { - "label": "パスワードの確認", - "placeholder": "確認のためにパスワードを再入力", - "errors": { - "empty": "確認のためにパスワードを再度入力してください(タイプミスを防ぐため)", - "different": "入力されたパスワードが一致しないため、どちらかが間違っています" - } - }, - "firstName": { - "label": "名前", - "placeholder": "太郎", - "errors": { - "empty": "名前を入力してください", - "length": "名前は50文字未満である必要があります", - "pattern": "名前は文字、スペース、アポストロフィ、またはハイフンのみを含む必要があります" - } - }, - "lastName": { - "label": "姓", - "placeholder": "田中", - "errors": { - "empty": "姓を入力してください", - "length": "姓は50文字未満である必要があります", - "pattern": "姓は文字、スペース、アポストロフィ、またはハイフンのみを含む必要があります" + "histogram": { + "avg": "平均: {{value}} ms", + "max": "最大: {{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "この操作は元に戻せません。", + "title": "本当に削除しますか?" + } + }, + "labels": { + "active": "アクティブ", + "paused": "一時停止中", + "na": "N/A", + "resolved": "解決済み", + "responseTime": "応答時間" + }, + "form": { + "role": { + "option": { + "role": { + "label": "ロール" } } }, - "errors": { - "validation": "データの検証エラー。" - }, - "fields": { - "password": { - "errors": { - "incorrect": "入力されたパスワードが記録と一致しません" + "name": { + "option": { + "firstName": { + "label": "名前", + "placeholder": "太郎" + }, + "lastName": { + "label": "姓", + "placeholder": "田中" } - }, - "role": { - "errors": { - "min": "" + } + }, + "email": { + "option": { + "email": { + "label": "メールアドレス", + "placeholder": "you@example.com" } } } }, - "login": { - "heading": "ログイン", - "subheadings": { - "stepOne": "メールアドレスを入力", - "stepTwo": "パスワードを入力" - }, - "links": { - "forgotPassword": "パスワードを忘れましたか?", - "register": "アカウントをお持ちでないですか?", - "forgotPasswordLink": "パスワードをリセット", - "registerLink": "こちらで登録" - }, - "toasts": { - "success": "おかえりなさい!正常にログインしました。", - "incorrectPassword": "パスワードが間違っています" - }, + "table": { + "empty": "データがありません", + "headers": { + "actions": "アクション", + "dateTime": "日時", + "message": "メッセージ", + "monitor": "モニター", + "monitorId": "モニターID", + "name": "名前", + "status": "ステータス", + "type": "タイプ", + "url": "URL", + "interval": "間隔", + "active": "アクティブ", + "responseTime": "応答時間" + } + } + }, + "components": { + "imageUpload": { + "clickToUpload": "クリックしてアップロード", + "dragAndDrop": "ドラッグアンドドロップ", + "supportedFormats": "対応フォーマット", + "maxSize": "最大サイズ", + "orDragAndDrop": "またはドラッグアンドドロップ", "errors": { - "password": { - "incorrect": "入力されたパスワードが記録と一致しません" - } - }, - "welcome": "" + "invalidFileSize": "ファイルサイズが大きすぎます!", + "invalidFileFormat": "サポートされていないファイル形式!" + } }, - "registration": { - "heading": { - "superAdmin": "スーパー管理者を作成", - "user": "サインアップ" - }, - "subheadings": { - "stepOne": "個人情報を入力", - "stepTwo": "メールアドレスを入力", - "stepThree": "パスワードを作成" + "headerStatusPageControls": { + "publicLink": "公開リンク" + }, + "headerTimeRange": { + "labels": { + "day": "日", + "month": "月", + "recent": "最近", + "week": "週" }, "description": { - "superAdmin": "スーパー管理者アカウントを作成して開始", - "user": "ユーザーとしてサインアップし、スーパー管理者にモニターへのアクセスを求める" + "recent": "過去2時間の統計を表示しています。", + "day": "過去24時間の統計を表示しています。", + "week": "過去7日間の統計を表示しています。", + "month": "過去30日間の統計を表示しています。" + } + }, + "offlineBanner": { + "serverUnreachable": "サーバーに接続できません", + "retry": "再試行", + "retrying": "再試行中...", + "reconnected": "サーバーに正常に再接続しました。" + }, + "sidebar": { + "menu": { + "uptime": "稼働時間", + "pagespeed": "ページ速度", + "infrastructure": "インフラストラクチャ", + "notifications": "通知", + "checks": "チェック", + "incidents": "インシデント", + "statusPages": "ステータスページ", + "maintenance": "メンテナンス", + "logs": "ログ", + "settings": "設定" }, - "gettingStartedButton": { - "superAdmin": "スーパー管理者アカウントを作成", - "user": "一般ユーザーでサインアップ" + "bottomMenu": { + "support": "サポート", + "discussions": "ディスカッション", + "docs": "ドキュメント", + "changelog": "変更履歴" }, - "termsAndPolicies": "アカウントを作成することで、利用規約とプライバシーポリシーに同意したことになります。", + "accountMenu": { + "profile": "プロフィール", + "password": "パスワード", + "team": "チーム" + }, + "starPrompt": { + "title": "Checkmateにスターを付ける", + "description": "GitHubで最新リリースを確認し、コミュニティの成長に貢献しましょう" + }, + "authFooter": { + "navControls": "コントロール", + "logOut": "ログアウト", + "roles": { + "superAdmin": "スーパー管理者", + "admin": "管理者", + "user": "ユーザー", + "demoUser": "デモユーザー" + } + } + }, + "starPrompt": { + "title": "Checkmateにスターを付ける", + "description": "GitHubで最新リリースを確認し、コミュニティの成長に貢献しましょう" + }, + "pageHeader": { "links": { - "login": "既にアカウントをお持ちですか? ログイン" - }, - "toasts": { - "success": "ようこそ!アカウントが正常に作成されました。" - }, - "welcome": "" - }, - "forgotPassword": { - "heading": "パスワードを忘れましたか?", - "subheadings": { - "stepOne": "ご心配なく、リセット手順をお送りします。", - "stepTwo": "パスワードリセットリンクを に送信しました", - "stepThree": "新しいパスワードは以前に使用したパスワードとは異なる必要があります。", - "stepFour": "パスワードが正常にリセットされました。下記をクリックして魔法のようにログインしてください。" - }, - "buttons": { - "openEmail": "メールアプリを開く", - "resetPassword": "パスワードをリセット" - }, - "imageAlts": { - "passwordKey": "パスワードキーアイコン", - "email": "メールアイコン", - "lock": "ロックアイコン", - "passwordConfirm": "パスワード確認アイコン" - }, - "links": { - "login": "ログインに戻る", - "resend": "メールが届きませんか? クリックして再送信" - }, - "toasts": { - "sent": "手順を に送信しました。", - "emailNotFound": "メールが見つかりません。", - "redirect": "秒後にリダイレクトします...", - "success": "パスワードが正常にリセットされました。", - "error": "パスワードをリセットできません。後でもう一度試すか、サポートにお問い合わせください。" + "support": "サポート", + "discussions": "ディスカッション", + "docs": "ドキュメント", + "changelog": "変更履歴" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "サーバーに正常に再接続しました。", - "stillUnreachable": "サーバーにまだ到達できません。後でもう一度お試しください。" + "pages": { + "notFound": { + "title": "おっと!お寿司を落としてしまいました!", + "subtitle": "URLが存在しないか、アクセス権限がありません。" + }, + "account": { + "tabs": { + "profile": "プロフィール", + "password": "パスワード", + "team": "チーム" }, - "alertBox": "サーバー接続エラー", - "description": "サーバーに接続できません。インターネット接続を確認するか、問題が続く場合はデプロイ設定を確認してください。", - "retryButton": { - "default": "接続を再試行", - "processing": "接続中..." + "form": { + "name": { + "title": "名前", + "description": "個人情報を更新" + }, + "photo": { + "title": "プロフィール写真", + "description": "プロフィール画像をアップロード" + }, + "currentPassword": { + "title": "現在のパスワード", + "description": "本人確認のために現在のパスワードを入力してください", + "option": { + "label": "現在のパスワード", + "placeholder": "現在のパスワードを入力" + } + }, + "newPassword": { + "title": "新しいパスワード", + "description": "8文字以上の強力なパスワードを選択してください", + "option": { + "newPassword": { + "label": "新しいパスワード", + "placeholder": "新しいパスワードを入力" + }, + "confirm": { + "label": "パスワードを確認", + "placeholder": "新しいパスワードを確認" + } + } + }, + "deleteAccount": { + "title": "アカウントを削除", + "description": "この操作は永久的で元に戻せません" + } + }, + "team": { + "filter": { + "placeholder": "ロールでフィルター", + "all": "すべて", + "admin": "管理者", + "member": "メンバー" + }, + "table": { + "headers": { + "email": "メールアドレス", + "role": "ロール", + "created": "作成日" + } + }, + "addMember": { + "title": "新しいチームメンバーを登録", + "description": "新しいユーザーアカウントを作成します。作成後、認証情報を安全に共有してください。" + }, + "invite": { + "title": "チームメンバーを招待", + "description": "チームへの招待を送信", + "email": { + "label": "メールアドレス", + "placeholder": "メールアドレスを入力" + }, + "role": { + "label": "ロール", + "placeholder": "ロールを選択" + }, + "linkLabel": "招待リンク" + }, + "fallback": { + "title": "チームメンバーがまだいません", + "description": "メールでチームメイトを招待するか、直接追加してください。参加すると、役割とともにここに表示されます。" + } + }, + "header": { + "title": "アカウント", + "description": "プロファイル、パスワード、チーム、ワークスペースの設定を管理します。" } - } - }, - "createNotifications": { - "title": "通知チャンネルを作成", - "nameSettings": { - "title": "名前", - "description": "統合の説明的な名前。", - "nameLabel": "名前", - "namePlaceholder": "例: Slack通知" }, - "typeSettings": { - "title": "タイプ", - "description": "作成したい通知チャンネルのタイプを選択。", - "typeLabel": "タイプ" - }, - "emailSettings": { - "title": "メール", - "description": "送信先メールアドレス。", - "emailLabel": "メールアドレス", - "emailPlaceholder": "例: john@example.com" - }, - "slackSettings": { - "title": "Slack", - "description": "Slack Webhookをここで設定", - "webhookLabel": "Slack Webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/..." - }, - "pagerdutySettings": { - "title": "PagerDuty", - "description": "PagerDuty統合をここで設定", - "integrationKeyLabel": "統合キー", - "integrationKeyPlaceholder": "1234567890" - }, - "discordSettings": { - "title": "Discord", - "description": "Discord Webhookをここで設定", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "webhookSettings": { - "title": "Webhook", - "description": "Webhookをここで設定", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "testNotification": "テスト通知", - "dialogDeleteTitle": "この通知を削除してもよろしいですか?", - "dialogDeleteConfirm": "削除" - }, - "notificationConfig": { - "title": "通知", - "description": "使用したい通知チャンネルを選択" - }, - "monitorStatus": { - "checkingEvery": "{{interval}}ごとにチェック", - "withCaptureAgent": "Captureエージェント{{version}}で", - "up": "アップ", - "down": "ダウン", - "paused": "一時停止" - }, - "advancedMatching": "高度なマッチング", - "sendTestNotifications": "テスト通知を送信", - "selectAll": "すべて選択", - "showAdminLoginLink": "ステータスページに「管理者?こちらでログイン」リンクを表示", - "logsPage": { - "title": "ログ", - "description": "システムログ - 最新1000行", - "tabs": { - "queue": "ジョブキュー", - "logs": "サーバーログ", - "diagnostics": "診断" - }, - "toast": { - "fetchLogsSuccess": "ログが正常に取得されました" - }, - "logLevelSelect": { - "title": "ログレベル", - "values": { - "all": "すべて", - "info": "情報", - "warn": "警告", - "error": "エラー", - "debug": "デバッグ" + "auth": { + "brandPanel": { + "eyebrow": "オープンソースのインフラ監視", + "tagline": "サーバーのすべての動きを見守ろう。", + "description": "稼働状況、応答時間、インシデントをリアルタイムで追跡 - セルフホスト型で完全にあなたのもの。" + }, + "common": { + "passwordRules": { + "length": { + "beginning": "少なくとも", + "highlighted": "8文字である必要があります" + }, + "lowercase": { + "beginning": "少なくとも", + "highlighted": "1つの小文字を含む必要があります" + }, + "match": { + "beginning": "パスワードの確認とパスワードは", + "highlighted": "一致する必要があります" + }, + "number": { + "beginning": "少なくとも", + "highlighted": "1つの数字を含む必要があります" + }, + "special": { + "beginning": "少なくとも", + "highlighted": "1つの特殊文字を含む必要があります" + }, + "uppercase": { + "beginning": "少なくとも", + "highlighted": "1つの大文字を含む必要があります" + } + }, + "form": { + "option": { + "email": { + "label": "メールアドレス", + "placeholder": "me@example.com" + }, + "password": { + "label": "パスワード", + "placeholder": "********" + }, + "confirmPassword": { + "label": "パスワードを確認" + } + } + } + }, + "login": { + "title": "Checkmateへようこそ!", + "subtitle": "ログインして続行", + "submit": "ログイン", + "links": { + "forgotPassword": { + "text": "パスワードをお忘れですか?", + "linkText": "パスワードをリセット" + }, + "register": { + "text": "アカウントをお持ちでないですか?", + "linkText": "こちらから登録" + } + } + }, + "register": { + "title": "Checkmateへようこそ!", + "subtitle": "サインアップして始めましょう", + "submit": "登録", + "setupNotice": "このページは初期セットアップ中のみ利用できます。スーパー管理者が登録されると、追加のユーザーはチーム設定から招待できます。" + }, + "forgotPassword": { + "title": "パスワードをお忘れですか?", + "subtitle": "ご安心ください。リセット手順をお送りします。", + "submit": "リカバリーをリクエスト", + "links": { + "login": { + "text": "戻る:", + "linkText": "ログイン" + } + } + }, + "setNewPassword": { + "title": "パスワードをリセット", + "subtitle": "新しいパスワードは以前使用したパスワードと異なる必要があります。" } - } - }, - "queuePage": { - "title": "キュー", - "refreshButton": "更新", - "flushButton": "キューをフラッシュ", - "jobTable": { - "title": "現在キューにあるジョブ", - "idHeader": "モニターID", - "urlHeader": "URL", - "typeHeader": "タイプ", - "activeHeader": "アクティブ", - "lockedAtHeader": "ロック日時", - "runCountHeader": "実行回数", - "failCountHeader": "失敗回数", - "lastRunHeader": "最終実行日時", - "lastFinishedAtHeader": "最終完了日時", - "lastRunTookHeader": "最終実行時間", - "intervalHeader": "" }, - "metricsTable": { - "title": "キューメトリック", - "metricHeader": "メトリック", - "valueHeader": "値" + "checks": { + "selects": { + "monitor": { + "all": "すべてのモニター" + }, + "status": { + "all": "すべて", + "down": "ダウン", + "up": "アップ" + } + }, + "table": { + "empty": "この期間にダウンチェックはありません", + "headers": { + "statusCode": "ステータスコード", + "location": "ロケーション" + } + }, + "fallback": { + "title": "チェックがまだありません", + "description": "モニターを作成すると、すべてのステータスプローブと応答時間がここにチェックとして表示されます。" + }, + "header": { + "title": "チェック", + "description": "Checkmateがモニターに対して実行するすべてのプローブが、ステータスコードと応答時間とともにここに表示されます。" + } }, - "failedJobTable": { - "title": "失敗したジョブ", - "monitorIdHeader": "モニターID", - "monitorUrlHeader": "モニターURL", - "failCountHeader": "失敗回数", - "failedAtHeader": "最終失敗日時", - "failReasonHeader": "失敗理由" - } - }, - "export": { - "title": "モニターをエクスポート", - "success": "モニターが正常にエクスポートされました!", - "failed": "モニターのエクスポートに失敗しました" - }, - "monitorActions": { - "title": "エクスポート/インポート", - "import": "モニターをインポート", - "export": "モニターをエクスポート", - "deleteSuccess": "モニターが正常に削除されました", - "deleteFailed": "モニターの削除に失敗しました", - "details": "詳細" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "Bluewave Labsによる開発", - "labelVersion": "バージョン", - "title": "について" + "common": { + "monitors": { + "actions": { + "configure": "設定", + "delete": "削除", + "generateToken": "トークンを生成", + "details": "詳細", + "incidents": "インシデント", + "inviteMember": "メンバーを招待", + "openSite": "サイトを開く", + "pause": "一時停止", + "resume": "再開", + "selectAll": "すべてのモニターを選択", + "selectMonitor": "{{name}} を選択", + "bulkSelected_one": "{{count}} 個のモニターが選択されました", + "bulkSelected_other": "{{count}} 個のモニターが選択されました" + }, + "statBoxes": { + "activeFor": "稼働期間", + "certificateExpiry": "証明書の有効期限", + "lastCheck": "最終チェック", + "lastResponseTime": "最終応答時間", + "serviceIsDown": "サービスがダウンしています" + }, + "status": { + "down": "ダウン", + "breached": "しきい値超過", + "initializing": "初期化中", + "maintenance": "メンテナンス中", + "paused": "一時停止中", + "total": "合計", + "up": "稼働中" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "ゲーム", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "ポート", + "optionPagespeed": "PageSpeed", + "optionHardware": "インフラストラクチャ", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} 個のモニターを一時停止しました", + "paused_other": "{{count}} 個のモニターを一時停止しました", + "resumed_one": "{{count}} 個のモニターを再開しました", + "resumed_other": "{{count}} 個のモニターを再開しました", + "alreadyPaused_one": "選択したモニターはすでに一時停止されています", + "alreadyPaused_other": "選択したすべてのモニターはすでに一時停止されています", + "alreadyRunning_one": "選択したモニターはすでに実行中です", + "alreadyRunning_other": "選択したすべてのモニターはすでに実行中です" + } + } }, - "demoMonitorsSettings": { - "buttonAddMonitors": "デモモニターを追加", - "description": "デモンストレーション目的でサンプルモニターを追加。", - "title": "デモモニター" + "createMonitor": { + "form": { + "advanced": { + "description": "高度なユースケース向けのオプション設定", + "option": { + "advancedMatching": { + "label": "高度なマッチングを使用" + }, + "expectedValue": { + "label": "期待値" + }, + "jsonPath": { + "description": "この式はレスポンスのJSONデータに対して評価され、結果は期待値とのマッチングに使用されます。クエリ言語のドキュメントはjmespath.orgをご参照ください。", + "label": "JSONPath式" + }, + "matchMethod": { + "label": "マッチ方法", + "equal": "", + "include": "", + "regex": "" + } + }, + "title": "詳細設定" + }, + "frequency": { + "description": "このモニターのステータスをどのくらいの頻度で確認しますか?", + "option": { + "frequency": { + "label": "チェック頻度", + "value": { + "fifteenMinutes": "15分", + "fifteenSeconds": "", + "fiveMinutes": "5分", + "fourMinutes": "", + "oneMinute": "", + "tenMinutes": "10分", + "thirtyMinutes": "30分", + "thirtySeconds": "", + "threeMinutes": "3分", + "twoMinutes": "" + } + } + }, + "title": "チェック頻度" + }, + "general": { + "option": { + "container": { + "label": "コンテナ名/ID", + "placeholder": "my-app または abcd1234" + }, + "host": { + "label": "ホスト", + "placeholder": "192.168.1.100 または example.com" + }, + "name": { + "label": "表示名", + "placeholder": "例: My Website" + }, + "secret": { + "label": "認証シークレット", + "placeholder": "シークレットキーを入力" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "", + "placeholder": "" + }, + "port": { + "label": "監視するポート", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "サービス名", + "placeholder": "例: my.service.v1(全体のヘルスチェックの場合は空欄)" + }, + "wsUrl": { + "label": "WebSocket URL", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture エンドポイント", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "一般設定", + "description": { + "http": "監視するURLまたはIPを入力し(例: https://example.com/ または 192.168.1.100)、ダッシュボードに表示される表示名を追加してください。", + "ping": "pingするIPアドレスまたはホスト名を入力(例: 192.168.1.100 または example.com)し、ダッシュボードに表示される明確な表示名を追加。", + "port": "サーバーのURLまたはIP、ポート番号、ダッシュボードに表示される明確な表示名を入力。", + "docker": "コンテナのDocker IDを入力。Docker IDは64文字のフルDocker IDである必要があります。docker inspect を実行してフルコンテナIDを取得できます。", + "game": "Ping を実行する IP アドレスまたはホスト名とポート番号(例: 192.168.1.100 や example.com)を入力し、ゲームタイプを選択してください。一部のゲームでは設定ファイルでサーバークエリを有効にする必要があります — ゲームサーバーのドキュメントを確認してください。プロトコルの詳細はサポートされているゲームの一覧をご覧ください。", + "pagespeed": "Webサイトのページ読み込みパフォーマンス、Core Web Vitals、最適化スコアを追跡します。", + "grpc": "gRPCサーバーのホスト名とポートを入力し、必要に応じてヘルスチェック用のサービス名を指定し、表示名を追加してください。", + "websocket": "監視するWebSocket URLを入力し(例: wss://example.com/socket)、表示名を追加してください。", + "hardware": "インフラの CPU、メモリ、ディスク使用率、温度を監視します。Capture エンドポイント URL には完全なパス /api/v1/metrics を含める必要があります (例: http://192.168.1.10:5001/api/v1/metrics)。認証シークレットにはトークン文字列のみを貼り付けてください — 'Bearer' は含めないでください。" + } + }, + "ignoreTls": { + "description": "HTTPS接続のTLS/SSL証明書検証を設定します。", + "option": { + "tls": { + "label": "TLS/SSLエラーを無視" + } + }, + "title": "TLS/SSL設定" + }, + "incidents": { + "description": "スライディングウィンドウを使用してモニターのダウンを判定します。スライディングウィンドウ内のチェックの割合が指定値を満たした場合にのみ、モニターのステータスが変更されます。", + "option": { + "checks": { + "label": "スライディングウィンドウ内のチェック数" + }, + "percentage": { + "label": "モニターのステータスが変更される前に、スライディングウィンドウ内のチェックの何パーセントが失敗/成功する必要がありますか?" + } + }, + "title": "インシデント" + }, + "notifications": { + "description": "使用する通知チャンネルを選択してください", + "title": "通知" + }, + "type": { + "description": "実行するチェックの種類を選択", + "optionDockerDescription": "Dockerを使用してコンテナが稼働しているか監視します。", + "optionGameDescription": "特定のゲームサーバーがオンラインかどうかを監視します。", + "optionGrpcDescription": "標準のHealth Checking Protocolを使用してgRPCサービスを監視します。", + "optionWebSocketDescription": "WebSocketエンドポイントの接続状態と応答時間を監視します。", + "optionHttpDescription": "HTTP(S)を使用してWebサイトまたはAPIエンドポイントを監視します。", + "optionPingDescription": "ICMP Pingを使用してサーバーがオンラインかどうかを監視します。", + "optionPortDescription": "サーバーの特定のポートが開いているかどうかを監視します。", + "title": "タイプ" + }, + "thresholds": { + "title": "アラートしきい値", + "description": "このハードウェアモニターでアラートがトリガーされるしきい値を定義します。", + "option": { + "cpuThreshold": { + "label": "CPUアラートしきい値(%)" + }, + "memoryThreshold": { + "label": "メモリアラートしきい値(%)" + }, + "diskThreshold": { + "label": "ディスクアラートしきい値(%)" + }, + "tempThreshold": { + "label": "温度アラートしきい値(°C)" + } + } + }, + "geoChecks": { + "title": "地理分散チェック", + "description": "複数の地理的ロケーションからチェックを実行し、グローバルな可用性とパフォーマンスを監視します。", + "option": { + "enabled": { + "label": "地理分散チェックを有効化" + }, + "locations": { + "label": "ロケーション", + "placeholder": "ロケーションを選択", + "options": { + "EU": "ヨーロッパ", + "NA": "北米", + "AS": "アジア", + "SA": "南米", + "AF": "アフリカ", + "OC": "オセアニア" + } + }, + "interval": { + "label": "チェック間隔", + "value": { + "fiveMinutes": "5分", + "tenMinutes": "10分", + "fifteenMinutes": "15分", + "thirtyMinutes": "30分" + } + } + } + }, + "url": { + "title": "ステータスページにモニターのIP/URLを表示", + "description": "公開ステータスページにモニターのIPアドレスまたはURLを表示します。無効の場合、機密情報を保護するためにモニター名のみが表示されます。", + "option": { + "showURL": { + "label": "ステータスページにIP/URLを表示", + "enabled": "有効", + "disabled": "無効" + } + } + }, + "stats": { + "title": "モニター履歴", + "description": "チームのすべての監視履歴と統計をクリアします。この操作は元に戻せません。", + "buttonText": "すべての統計をクリア", + "dialog": { + "title": "すべての監視履歴をクリアしますか?", + "description": "チームのすべての監視履歴、統計、チェックデータが完全に削除されます。この操作は元に戻せません。", + "confirm": "はい、すべての統計をクリア" + } + } + } }, - "emailSettings": { - "buttonSendTestEmail": "テストメールを送信", - "description": "システムのメール設定を構成。これは通知とアラートの送信に使用されます。", - "descriptionTransport": "これはNodeMailer用のSMTPトランスポートを構築します", - "labelAddress": "メールアドレス - 認証に使用", - "labelConnectionHost": "メール接続ホスト - HELO/EHLO挨拶で使用するホスト名", - "labelHost": "メールホスト - 接続するホスト名またはIPアドレス", - "labelIgnoreTLS": "STARTTLSを無効化: サーバーがサポートしていてもTLSを使用しない", - "labelPassword": "メールパスワード - 認証用パスワード", - "labelPasswordSet": "パスワードが設定されています。変更するにはリセットをクリック。", - "labelPool": "接続プーリングを有効化: パフォーマンス向上のため既存接続を再利用", - "labelPort": "メールポート - 接続するポート", - "labelRejectUnauthorized": "無効な証明書を拒否: 自己署名または信頼されていない証明書での接続を拒否", - "labelRequireTLS": "STARTTLSを強制: TLSアップグレードを必須とし、サポートされていない場合は失敗", - "labelSecure": "SSL使用(推奨): SSL/TLSを使用して接続を暗号化", - "labelTLSServername": "TLS Servername - ホストがIPの場合のTLS検証用オプションホスト名", - "labelUser": "メールユーザー - 認証用ユーザー名、指定されている場合はメールアドレスを上書き", - "linkTransport": "仕様はこちらを参照", - "placeholderUser": "不要な場合は空白のまま", - "title": "メール", - "toastEmailRequiredFieldsError": "メールアドレス、ホスト、ポート、パスワードが必要です" + "editUser": { + "form": { + "roles": { + "title": "ロール", + "description": "ユーザーにロールを割り当てます。複数のロールを選択できます。" + } + }, + "dialog": { + "removeUser": { + "title": "ユーザーを削除", + "content": "{{name}}をチームから削除してもよろしいですか?この操作は元に戻せません。" + } + } }, - "pageSpeedSettings": { - "description": "Google PageSpeed APIキーを入力してGoogle PageSpeed監視を有効化。キーを更新するにはリセットをクリック。", - "labelApiKeySet": "APIキーが設定されています。変更するにはリセットをクリック。", - "labelApiKey": "PageSpeed APIキー", - "title": "Google PageSpeed APIキー" + "incidents": { + "fallback": { + "title": "インシデントなし — すべてのシステムが正常です", + "description": "モニターがダウンまたは復旧すると、タイミング、モニターの詳細、解決履歴とともにここに表示されます。" + }, + "dialog": { + "resolveIncident": { + "title": "インシデントを解決", + "option": { + "comment": { + "label": "コメント(任意)", + "placeholder": "解決に関するコメントを追加..." + } + } + }, + "details": { + "analysis": "インシデント分析", + "comment": "コメント:", + "detailsLabel": "詳細", + "downtime": "ダウンタイム:", + "message": "メッセージ:", + "monitor": "モニター:", + "overview": "概要", + "resolutionDetails": "解決の詳細", + "resolutionType": "タイプ:", + "resolutionTypes": { + "automatic": "自動", + "manual": "手動" + }, + "resolve": "インシデントを解決", + "resolvedAt": "解決日時:", + "resolvedBy": "解決者:", + "startedAt": "開始日時:", + "status": "ステータス:", + "statusCode": "ステータスコード:", + "timeline": "タイムライン", + "title": "インシデント詳細", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "すべてのモニター", + "monitor": "モニター", + "resolutionType": "解決タイプ", + "resolutionTypes": { + "manual": "手動", + "automatic": "自動", + "all": "すべて" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "アクティブなインシデント", + "active_zero": "アクティブなインシデントはありません", + "active_one": "{{count}}件のアクティブなインシデント", + "active_other": "{{count}}件のアクティブなインシデント" + }, + "incidentStats": { + "avgResolutionTime": "平均解決時間", + "mostAffectedMonitor": "最も影響を受けたモニター", + "title": "インシデント統計", + "totalIncidents": "インシデント合計" + }, + "latestIncidents": { + "title": "最新のインシデント" + } + }, + "table": { + "actions": { + "details": "詳細", + "goToMonitor": "モニターへ移動", + "resolveManually": "手動で解決" + }, + "activeIncidents": "アクティブなインシデント", + "headers": { + "endTime": "終了時刻", + "resolutionType": "解決タイプ", + "startTime": "開始時刻", + "statusCode": "ステータスコード" + }, + "resolvedIncidents": "解決済みインシデント", + "status": { + "active": "アクティブ", + "resolved": "解決済み" + } + }, + "header": { + "title": "インシデント", + "description": "モニターがダウンまたは復旧すると、インシデントがタイミング、モニターの詳細、解決履歴とともにここに表示されます。" + } }, - "saveButtonLabel": "保存", - "statsSettings": { - "clearAllStatsButton": "すべての統計をクリア", - "clearAllStatsDescription": "すべての統計をクリア。この操作は元に戻せません。", - "clearAllStatsDialogConfirm": "はい、すべての統計をクリア", - "clearAllStatsDialogDescription": "削除されると、監視履歴と統計は復元できません。", - "clearAllStatsDialogTitle": "すべての統計をクリアしますか?", - "description": "履歴データを保持する期間を定義。既存のデータをすべてクリアすることもできます。", - "labelTTL": "監視履歴を保持したい日数。", - "labelTTLOptional": "0で無限", - "title": "モニター履歴" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "すべてのモニターを削除", - "description": "システムからすべてのモニターを削除。", - "dialogConfirm": "はい、すべてのモニターを削除", - "dialogDescription": "削除されると、モニターは復元できません。", - "dialogTitle": "すべてのモニターを削除しますか?", - "title": "システムリセット" - }, - "timezoneSettings": { - "description": "アプリケーション全体で日付と時刻を表示するために使用するタイムゾーンを選択。", - "label": "表示タイムゾーン", - "title": "表示タイムゾーン" - }, - "title": "設定", - "uiSettings": { - "description": "ライトモードとダークモードを切り替えるか、ユーザーインターフェース言語を変更。", - "labelLanguage": "言語", - "labelTheme": "テーマモード", - "title": "外観" - }, - "urlSettings": { - "description": "公開ステータスページでモニターのIPアドレスまたはURLを表示。無効にすると、機密情報を保護するためモニター名のみが表示されます。", - "label": "ステータスページでIP/URLを表示", - "selectDisabled": "無効", - "selectEnabled": "有効", - "title": "ステータスページのモニターIP/URL" - }, - "globalThresholds": { - "title": "", - "description": "" - } - }, - "statusPageCreate": { - "buttonSave": "保存" - }, - "incidentsOptionsHeaderFilterResolved": "解決済み", - "settingsSave": "保存", - "statusPageCreateAppearanceTitle": "外観", - "confirmPassword": "パスワードの確認", - "monitorHooks": { - "failureAddDemoMonitors": "デモモニターの追加に失敗しました", - "successAddDemoMonitors": "デモモニターが正常に追加されました" - }, - "settingsAppearance": "外観", - "settingsDisplayTimezone": "表示タイムゾーン", - "settingsGeneralSettings": "一般設定", - "incidentsOptionsHeaderTotalIncidents": "総インシデント", - "statusPage": { - "deleteSuccess": "ステータスページが正常に削除されました", - "deleteFailed": "ステータスページの削除に失敗しました", - "createSuccess": "ステータスページが正常に作成されました", - "updateSuccess": "ステータスページが正常に更新されました", - "generalSettings": "一般設定", - "contents": "コンテンツ", - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "testNotificationsDisabled": "このモニターには通知が設定されていません。「設定」ボタンをクリックして追加する必要があります", - "incidentsTableResolvedAt": "解決日時", - "incidentsTableActionResolve": "解決", - "checkHooks": { - "failureResolveOne": "インシデントの解決に失敗しました。", - "failureResolveAll": "すべてのインシデントの解決に失敗しました。", - "failureResolveMonitor": "" - }, - "checkFormError": "フォームでエラーを確認してください。", - "diagnosticsPage": { - "diagnosticDescription": "システム診断", - "statsDescription": "システム統計", - "gauges": { - "heapAllocationTitle": "ヒープ割り当て", - "heapAllocationSubtitle": "利用可能メモリの%", - "heapUsageTitle": "ヒープ使用量", - "heapUsageSubtitle": "利用可能メモリの%", - "heapUtilizationTitle": "ヒープ利用率", - "heapUtilizationSubtitle": "割り当て済みの%", - "instantCpuUsageTitle": "瞬間CPU使用率", - "instantCpuUsageSubtitle": "CPUが使用した1秒の%" - }, - "stats": { - "eventLoopDelayTitle": "イベントループ遅延", - "uptimeTitle": "稼働時間", - "usedHeapSizeTitle": "使用ヒープサイズ", - "totalHeapSizeTitle": "総ヒープサイズ", - "osMemoryLimitTitle": "OSメモリ制限" - } - }, - "pageSpeedLighthouseAPI": "Lighthouse PageSpeed APIを使用してウェブサイトを監視", - "time": { - "threeMinutes": "3分", - "fiveMinutes": "5分", - "tenMinutes": "10分", - "twentyMinutes": "20分", - "oneHour": "1時間", - "oneDay": "1日", - "oneWeek": "1週間", - "fourMinutes": "", - "oneMinute": "", - "twoMinutes": "", - "fifteenSeconds": "", - "thirtySeconds": "" - }, - "general": { - "noOptionsFound": "" - }, - "infrastructureMonitor": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "pageSpeed": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "editUserPage": { - "form": { - "email": "", - "firstName": "", - "lastName": "", - "role": "", - "save": "" - }, - "table": { - "actionHeader": "", - "roleHeader": "" - }, - "title": "", - "toast": { - "successUserUpdate": "", - "validationErrors": "" - } - }, - "incidentsPageActionResolveMonitor": "", - "incidentsPageActionResolveAll": "", - "matchMethodOptions": { - "equal": "", - "equalPlaceholder": "", - "include": "", - "includePlaceholder": "", - "regex": "", - "regexPlaceholder": "", - "text": "" - }, - "monitorType": { - "docker": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "http": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "ping": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "port": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "game": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "" - }, - "bytesPerSecond": "", - "bytesReceived": "", - "bytesSent": "", - "chooseGame": "", - "createMonitorPage": { - "incidentConfigDescription": "", - "incidentConfigStatusWindowLabel": "", - "incidentConfigStatusWindowThresholdLabel": "", - "incidentConfigTitle": "", - "incidentConfigDescriptionV2": "", - "incidentConfigStatusCheckNumber": "", - "intervalTitle": "", - "intervalDescription": "" - }, - "dataRate": "", - "dataReceived": "", - "dataSent": "", - "details": "", - "drops": "", - "errors": "", - "errorsIn": "", - "errorsOut": "", - "gameServerMonitoring": "", - "gameServerMonitoringDescription": "", - "network": "", - "networkDrops": "", - "networkErrors": "", - "networkInterface": "", - "noNetworkStatsAvailable": "", - "packetsPerSecond": "", - "packetsReceived": "", - "packetsReceivedRate": "", - "packetsSent": "", - "rate": "", - "selectInterface": "", - "v1": { "infrastructure": { - "disk_selection_title": "ディスク選択", - "disk_selection_info": "現在ディスクが検出されていません。", - "disk_selection_description": "監視したい特定のディスクを選択してください。" + "charts": { + "labels": { + "cpu": "CPU使用率", + "disk": "ディスク使用率", + "memory": "メモリ使用率", + "netBytesRecv": "{{name}} - 受信バイト数", + "netBytesSent": "{{name}} - 送信バイト数", + "netNoTraffic": "Capture はすべてのインターフェイスでゼロのトラフィックを報告しています。これは Capture がループバックインターフェイスのみを認識できるコンテナ内で実行されている場合に正常です — 実際のネットワークアクティビティを表示するには、ホスト上で Capture を直接実行してください。", + "temp": "温度" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "最大周波数", + "title": "CPU使用率", + "upperLabel": "現在の周波数" + }, + "disk": { + "lowerLabel": "空き", + "title": "ディスク{{idx}}使用率", + "upperLabel": "使用済み" + }, + "memory": { + "lowerLabel": "空き", + "title": "メモリ使用率", + "upperLabel": "使用済み" + } + }, + "statBoxes": { + "avgCpuTemperature": "平均CPU温度", + "cpuFrequency": "CPU周波数", + "cpuLogical": "CPU(論理)", + "cpuPhysical": "CPU(物理)", + "disk": "ディスク", + "memory": "メモリ", + "os": "OS" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "ディスク", + "memory": "メモリ" + } + }, + "tabs": { + "labels": { + "network": "", + "overview": "概要" + } + }, + "fallback": { + "actionButton": "モニターを作成!", + "title": "インフラモニターがまだありません", + "description": "サーバーのパフォーマンスを追跡し、ボトルネックを特定し、リアルタイムのインフラ監視で信頼性を確保します。" + }, + "header": { + "title": "インフラ監視", + "description": "サーバーのパフォーマンスを追跡し、ボトルネックを特定し、リアルタイムのインフラ監視で信頼性を確保します。" + } + }, + "logs": { + "fallback": { + "title": "ログがまだありません", + "description": "サーバーのアクティビティ、警告、エラーがCheckmateの実行中にここに表示されます。" + }, + "tabs": { + "diagnostics": "診断", + "logs": "サーバーログ", + "queue": "ジョブキュー" + }, + "logLevelSelect": { + "label": "ログレベル" + }, + "jobQueue": "ジョブキュー", + "jobQueueExplainer": "ハイライトされた行は、現在ワーカーによって処理中のジョブです。", + "failedJobs": "失敗したジョブ", + "failedJobsExplainer": "モニター別の最近の失敗 — 最終失敗時刻、失敗回数、および理由。", + "noLogs": "ログが見つかりません", + "metrics": { + "jobs": "ジョブ", + "activeJobs": "アクティブなジョブ", + "failingJobs": "失敗中のジョブ", + "totalRuns": "合計実行数", + "totalFailures": "合計失敗数" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "イベントループ遅延", + "uptime": "稼働時間", + "usedHeapSize": "使用済みヒープサイズ", + "totalHeapSize": "合計ヒープサイズ", + "osMemoryLimit": "OSメモリ制限" + }, + "gauges": { + "heapAllocation": "ヒープ割り当て", + "heapUsage": "ヒープ使用率", + "heapUtilization": "ヒープ使用効率", + "instantCpuUsage": "瞬時CPU使用率", + "availableMemoryPercentage": "利用可能メモリの%", + "allocatedPercentage": "割り当て済み%", + "usedSPercentage": "CPUによる1秒あたりの使用%", + "total": "合計", + "used": "使用済み" + }, + "sections": { + "runtimeStats": { + "title": "ランタイム統計", + "description": "Node.jsプロセスとホストマシンのスナップショット。5秒ごとに更新されます。" + }, + "memoryCpu": { + "title": "メモリとCPU", + "description": "プロセスが割り当てられたメモリとホストCPUをどのように使用しているか。" + } + } + }, + "table": { + "headers": { + "timestamp": "タイムスタンプ", + "level": "レベル", + "service": "サービス", + "method": "メソッド", + "monitorId": "モニターID", + "runCount": "実行回数", + "failCount": "失敗回数", + "lastRunAt": "最終実行日時", + "lockedAt": "ロック日時", + "lastFinishedAt": "最終完了日時", + "lastRunTook": "最終実行時間", + "lastFailedAt": "最終失敗日時", + "failReason": "失敗理由" + } + }, + "queueFallback": { + "title": "キューにジョブがありません", + "description": "スケジュールされたモニターチェックや実行中のチェックがここに表示されます。空のキューはすべてが最新であることを意味します。" + }, + "header": { + "title": "ログとキュー", + "description": "サーバーログ、モニタージョブキュー、ランタイム診断を一か所で確認できます。" + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "メンテナンスウィンドウを作成!", + "title": "予定されているメンテナンスウィンドウはありません", + "description": "計画されたダウンタイムをスケジュールしてアラートを抑制し、チームとユーザーに事前に知らせます。" + }, + "table": { + "headers": { + "nextWindow": "次のウィンドウ", + "repeat": "繰り返し" + } + }, + "form": { + "general": { + "title": "一般設定", + "description": "メンテナンスウィンドウの名前と繰り返しオプションを設定します。", + "option": { + "name": { + "label": "名前", + "placeholder": "例: 週次メンテナンス" + }, + "repeat": { + "label": "繰り返し" + } + } + }, + "startDate": { + "title": "開始日", + "description": "メンテナンスウィンドウの開始日を選択してください。", + "option": { + "startDate": { + "label": "開始日" + } + } + }, + "startTime": { + "title": "開始時刻", + "description": "メンテナンスウィンドウの開始時刻と期間を設定します。すべての値はUTCです", + "option": { + "duration": { + "label": "期間" + }, + "startTime": { + "label": "開始時刻" + } + }, + "monitors": { + "title": "モニター", + "description": "メンテナンスウィンドウを適用するモニター", + "option": { + "addMonitors": { + "label": "モニターを追加" + } + } + } + } + }, + "header": { + "title": "メンテナンスウィンドウ", + "description": "計画されたダウンタイムをスケジュールしてアラートを抑制し、チームとユーザーに事前に知らせます。" + } + }, + "notifications": { + "fallback": { + "actionButton": "チャンネルを作成", + "title": "通知チャンネルがまだありません", + "description": "Slack、Discord、メール、Webhookなどを接続して、インシデント発生時に適切な担当者に通知します。" + }, + "form": { + "accessToken": { + "optionAccessToken": "アクセストークン", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "通知の送信先アドレスです。", + "optionAddress": "アドレス", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "アドレス" + }, + "homeServer": { + "optionHomeServer": "ホームサーバー", + "placeholder": "example.com" + }, + "matrix": { + "description": "通知用のMatrixホームサーバー接続を設定します。", + "title": "Matrix設定" + }, + "notificationName": { + "description": "通知チャンネルの説明的な名前", + "optionName": "チャンネル名", + "placeholder": "例: 本番アラート", + "title": "チャンネル名" + }, + "pagerDuty": { + "description": "アラート受信用のPagerDutyインテグレーションキーです。", + "optionIntegrationKey": "インテグレーションキー", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "インテグレーションキー" + }, + "roomId": { + "optionRoomId": "ルームID", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "作成する通知チャンネルの種類を選択してください。", + "optionType": "タイプ", + "title": "チャンネルタイプ" + }, + "telegram": { + "title": "Telegram設定", + "description": "Telegram通知を有効にするには、Telegramボットを作成・管理するための公式ボットであるBotFatherを使用してTelegramボットを作成します。次に、APIトークンとチャットIDを取得してここに記入します。", + "optionBotToken": "ボットトークン", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "チャットID", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Pushover の設定", + "description": "Pushover のアプリケーショントークンとユーザーキーを入力してください。", + "optionAppToken": "アプリトークン", + "placeholderAppToken": "アプリトークンを入力", + "optionUserKey": "ユーザーキー", + "placeholderUserKey": "ユーザーキーを入力" + }, + "twilio": { + "title": "Twilio の設定", + "description": "SMS を送信するための Twilio アカウント認証情報を入力してください。", + "optionAccountSid": "アカウント SID", + "placeholderAccountSid": "アカウント SID を入力", + "optionAuthToken": "認証トークン", + "placeholderAuthToken": "認証トークンを入力", + "optionFromNumber": "送信元番号", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "送信先番号", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "送信先" + } + }, + "header": { + "title": "通知", + "description": "Slack、Discord、メール、Webhookなどを接続して、インシデント発生時に適切な担当者に通知します。" + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "Cumulative Layout Shift (CLS)", + "fcp": "First Contentful Paint (FCP)", + "lcp": "Largest Contentful Paint (LCP)", + "si": "Speed Index (SI)", + "tbt": "Total Blocking Time (TBT)" + }, + "legend": { + "title": "PageSpeedレポート", + "weight": "重み" + }, + "pie": { + "title": "パフォーマンスレポート" + } + }, + "table": { + "headers": { + "pageSpeedScore": "PageSpeedスコア" + } + }, + "fallback": { + "actionButton": "モニターを作成!", + "title": "PageSpeedモニターがまだありません", + "description": "Core Web VitalsとLighthouseスコアを継続的に追跡し、ユーザーが気付く前に劣化を発見します。" + }, + "header": { + "title": "PageSpeed監視", + "description": "Core Web VitalsとLighthouseスコアを継続的に追跡し、ユーザーが気付く前に劣化を発見します。" + } + }, + "settings": { + "form": { + "timezone": { + "title": "表示タイムゾーン", + "description": "アプリケーション全体で日時の表示に使用するタイムゾーンを選択してください。", + "option": { + "timezone": { + "label": "表示タイムゾーン" + } + } + }, + "ui": { + "title": "外観", + "description": "ライトモードとダークモードの切り替え、言語の変更、またはチャート表示タイプのカスタマイズができます。", + "option": { + "theme": { + "label": "テーマモード", + "light": "ライト", + "dark": "ダーク" + }, + "language": { + "label": "言語" + }, + "chartType": { + "label": "チャートタイプ", + "histogram": "ヒストグラム", + "heatmap": "ヒートマップ" + } + } + }, + "pagespeed": { + "title": "Google PageSpeed APIキー", + "description": "Google PageSpeed監視を有効にするにはGoogle PageSpeed APIキーを入力してください。キーを更新するにはリセットをクリックしてください。", + "option": { + "apiKey": { + "label": "PageSpeed APIキー", + "labelSet": "APIキーが設定されています。変更するにはリセットをクリックしてください。", + "placeholder": "Google PageSpeed APIキーを入力" + } + } + }, + "url": { + "title": "ステータスページにモニターのIP/URLを表示", + "description": "公開ステータスページにモニターのIPアドレスまたはURLを表示します。無効の場合、機密情報を保護するためにモニター名のみが表示されます。", + "option": { + "showURL": { + "label": "ステータスページにIP/URLを表示", + "enabled": "有効", + "disabled": "無効" + } + } + }, + "stats": { + "title": "モニター履歴", + "description": "チームのすべての監視履歴と統計をクリアします。この操作は元に戻せません。", + "option": { + "clear": { + "label": "すべての統計をクリアします。この操作は元に戻せません。" + } + }, + "dialog": { + "title": "すべての監視履歴をクリアしますか?", + "description": "この操作は元に戻せません" + } + }, + "retention": { + "title": "チェックデータの保持", + "description": "チェックデータが自動クリーンアップされるまでの保持期間を設定します。", + "option": { + "days": { + "label": "保持期間(日数)", + "unlimited": "無制限" + } + } + }, + "thresholds": { + "title": "グローバルしきい値", + "description": "インフラストラクチャ監視のグローバルなCPU、メモリ、ディスク、温度しきい値を設定します。オーバーライドしない限り、すべてのハードウェアモニターに適用されます。", + "option": { + "cpu": { + "label": "CPUしきい値(%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "メモリしきい値(%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "ディスクしきい値(%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "温度しきい値(°C)", + "placeholder": "1 - 150" + } + } + }, + "email": { + "title": "メール設定", + "description": "システムのメール設定を構成します。通知とアラートの送信に使用されます。", + "descriptionTransport": "これはNodeMailer用のSMTPトランスポートを構築します", + "descriptionTransportLink": "仕様はこちらを参照", + "option": { + "host": { + "label": "メールホスト - 接続先のホスト名またはIPアドレス", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "メールポート - 接続先のポート", + "placeholder": "587" + }, + "address": { + "label": "メールアドレス - 認証に使用", + "placeholder": "you@example.com" + }, + "user": { + "label": "メールユーザー - 認証用のユーザー名、指定時はメールアドレスを上書き", + "placeholder": "不要な場合は空欄のまま" + }, + "password": { + "label": "メールパスワード - 認証用のパスワード", + "labelSet": "パスワードが設定されています。変更するにはリセットをクリックしてください。", + "placeholder": "パスワードを入力" + }, + "tlsServername": { + "label": "TLSサーバー名 - ホストがIPの場合のTLS検証用オプションホスト名", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "メール接続ホスト - HELO/EHLOグリーティングで使用するホスト名", + "placeholder": "localhost" + }, + "secure": { + "label": "SSLを使用(推奨): SSL/TLSで接続を暗号化" + }, + "pool": { + "label": "接続プーリングを有効化: 既存の接続を再利用してパフォーマンスを向上" + }, + "ignoreTLS": { + "label": "STARTTLSを無効化: サーバーがサポートしていてもTLSを使用しない" + }, + "requireTLS": { + "label": "STARTTLSを強制: TLSアップグレードを要求、未サポート時は失敗" + }, + "rejectUnauthorized": { + "label": "無効な証明書を拒否: 自己署名または信頼されていない証明書の接続を拒否" + } + } + }, + "demoMonitors": { + "title": "デモモニター", + "description": "デモ目的でサンプルモニターを追加します。" + }, + "removeMonitors": { + "title": "システムリセット", + "description": "システムからすべてのモニターを削除します。", + "dialog": { + "title": "すべてのモニターを削除しますか?", + "description": "この操作は元に戻せません。", + "paragraph": "これらのモニターに関連するチェック履歴、インシデント、予定されたメンテナンスウィンドウ、ステータスページもすべて削除されます。データのバックアップを残したい場合は、先にモニターをエクスポートしてください。" + } + }, + "exportMonitors": { + "title": "モニターをエクスポート" + }, + "importExportMonitors": { + "title": "モニターのインポート/エクスポート", + "description": "バックアップまたは転送のために、モニターデータをJSONファイルとしてインポートまたはエクスポートします。" + }, + "about": { + "title": "概要", + "developedBy": "Bluewave Labsが開発" + }, + "validation": { + "errorMessage": "以下のバリデーションエラーを修正してください:" + } + }, + "header": { + "title": "設定", + "description": "SMTP、統合、保持、グローバルアプリの動作を設定します。" + } + }, + "statusPages": { + "deleteSuccess": "ステータスページが正常に削除されました", + "fallback": { + "title": "ステータスページがまだありません", + "actionButton": "ステータスページを作成!", + "description": "顧客と関係者にリアルタイムの稼働状況とインシデント履歴を表示する公開ページを公開します。" + }, + "monitorsList": { + "chartTypeHeatmap": "ヒートマップ", + "chartTypeHistogram": "ヒストグラム", + "noData": "データがありません", + "infrastructure": { + "title": "インフラストラクチャ", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "メモリ", + "disk": "ディスク", + "usage": "使用量", + "used": "使用済み", + "total": "合計" + }, + "uptime": { + "title": "稼働時間", + "responseTime": "応答時間" + } + }, + "statusBar": { + "allDown": "すべてのシステムがダウンしています", + "allUp": "すべてのシステムが正常に稼働しています", + "degraded": "一部のシステムに問題が発生しています", + "unknown": "システムステータスを判定できません", + "allBreached": "すべてのシステムがしきい値を超えました", + "allMaintenance": "すべてのシステムがメンテナンス中です", + "allPaused": "すべてのシステムが一時停止中です", + "allInitializing": "すべてのシステムが初期化中です", + "breachedAndDown": "一部のシステムがしきい値を超えてダウンしています", + "breached": "一部のシステムがしきい値を超えています", + "maintenanceAndDown": "一部のシステムがメンテナンス中にダウンしています", + "maintenance": "一部のシステムがメンテナンス中です", + "partiallyPaused": "一部のシステムが一時停止中です", + "initializing": "一部のシステムが初期化中です", + "noMonitors": "モニターが設定されていません" + }, + "table": { + "headers": { + "name": "ステータスページ名", + "url": "公開URL" + }, + "published": "公開済み", + "unpublished": "未公開" + }, + "title": "ステータスページ", + "details": { + "empty": { + "title": "まだ何もありません", + "addMonitor": "開始するにはモニターを追加してください" + } + }, + "form": { + "access": { + "title": "アクセス", + "description": "ステータスページの準備ができたら、公開済みとしてマークできます。", + "option": { + "published": { + "name": "公開済みで一般に表示" + } + } + }, + "basicInfo": { + "title": "基本情報", + "description": "会社名とステータスページが指すサブドメインを定義します。", + "option": { + "name": { + "label": "会社名", + "placeholder": "Acme Inc." + }, + "url": { + "label": "ステータスページのアドレス", + "placeholder": "my-status-page" + } + } + }, + "monitors": { + "title": "モニター", + "description": "ステータスページに表示するモニターを選択してください。", + "noMonitors": "モニターが選択されていません", + "option": { + "monitors": { + "label": "モニターを選択", + "placeholder": "モニターを検索して選択..." + } + } + }, + "timezone": { + "title": "タイムゾーン", + "description": "ステータスページの表示に使用するタイムゾーンを選択してください。", + "option": { + "timezone": { + "label": "タイムゾーン", + "placeholder": "タイムゾーンを選択..." + } + } + }, + "appearance": { + "title": "外観", + "description": "公開ステータスページのデフォルトの外観を定義します。", + "option": { + "logo": { + "label": "ロゴ" + }, + "color": { + "label": "ブランドカラー" + } + } + }, + "features": { + "title": "機能", + "description": "ステータスページに表示する情報を設定します。", + "option": { + "showCharts": { + "label": "応答時間チャートを表示" + }, + "showUptimePercentage": { + "label": "稼働率を表示" + }, + "showAdminLoginLink": { + "label": "管理者ログインリンクを表示" + }, + "showInfrastructure": { + "label": "インフラストラクチャメトリクスを表示" + } + } + } + }, + "header": { + "title": "ステータスページ", + "description": "顧客と関係者にリアルタイムの稼働状況とインシデント履歴を表示する公開ページを公開します。" + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "モニターを検索..." + } + }, + "table": { + "headers": { + "responseTime": "応答時間" + } + }, + "fallback": { + "actionButton": "モニターを作成!", + "title": "アップタイムモニターがまだありません", + "description": "HTTPエンドポイント、Ping、コンテナ、ポートを監視し、ダウンした瞬間に通知を受け取ります。" + }, + "header": { + "title": "稼働監視", + "description": "HTTPエンドポイント、Ping、コンテナ、ポートを監視し、ダウンした瞬間に通知を受け取ります。" + } } } } diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index 193c53788..072239a49 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -1,1140 +1,1447 @@ { - "submit": "Enviar", - "title": "Título", - "distributedStatusHeaderText": "Cobertura em tempo real e em dispositivos reais", - "distributedStatusSubHeaderText": "Impulsionado por milhões de dispositivos em todo o mundo, visualize o desempenho do sistema por região global, país ou cidade", - "settingsDisabled": "Desabilitado", - "settingsSuccessSaved": "Configurações salvas com sucesso", - "settingsFailedToSave": "Falha ao salvar as configurações", - "settingsStatsCleared": "Estatísticas limpas com sucesso", - "settingsFailedToClearStats": "Falha ao limpar estatísticas", - "settingsMonitorsDeleted": "Todos os monitores foram excluídos com sucesso", - "settingsFailedToDeleteMonitors": "Falha ao excluir todos os monitores", - "starPromptTitle": "Star Checkmate", - "starPromptDescription": "Veja os últimos lançamentos e ajude a expandir a comunidade no GitHub", - "https": "HTTPS", - "http": "HTTP", - "monitor": "monitor", - "aboutus": "Sobre nós", - "now": "Agora", - "delete": "Deletar", - "configure": "Configurar", - "responseTime": "Tempo de resposta", - "ms": "ms", - "bar": "Bar", - "area": "Área", - "country": "PAÍS", - "city": "CIDADE", - "response": "RESPOSTA", - "monitorStatusUp": "O monitor {name} ({url}) agora está ATIVO e respondendo", - "monitorStatusDown": "O monitor {name} ({url}) está INATIVO e não está respondendo", - "webhookSendSuccess": "Notificação de webhook enviada com sucesso", - "webhookSendError": "Erro ao enviar notificação de webhook para {platform}", - "webhookUnsupportedPlatform": "Plataforma não suportada: {platform}", - "distributedRightCategoryTitle": "Monitor", - "distributedStatusServerMonitors": "Monitores de servidor", - "distributedStatusServerMonitorsDescription": "Monitorar o status dos servidores relacionados", - "distributedUptimeCreateSelectURL": "Aqui você pode selecionar a URL do host, juntamente com o tipo de monitor.", - "distributedUptimeCreateChecks": "Verificações para realizar", - "distributedUptimeCreateChecksDescription": "Você sempre pode adicionar ou remover verificações depois de adicionar seu site.", - "distributedUptimeCreateIncidentNotification": "Notificações de incidentes", - "distributedUptimeCreateIncidentDescription": "Quando ocorrer um incidente, notifique os usuários.", - "distributedUptimeCreateAdvancedSettings": "Configurações avançadas", - "distributedUptimeDetailsNoMonitorHistory": "Ainda não há histórico de verificações para este monitor.", - "distributedUptimeDetailsStatusHeaderUptime": "Uptime:", - "distributedUptimeDetailsStatusHeaderLastUpdate": "Última atualização", - "notifications": { - "enableNotifications": "Habilitar notificações da {{platform}}", - "testNotification": "Notificação de teste", - "addOrEditNotifications": "Adicionar ou editar notificações", - "slack": { - "label": "Slack", - "description": "Para habilitar as notificações do Slack, crie um aplicativo Slack e habilite os webhooks de entrada. Depois disso, basta fornecer a URL do webhook aqui.", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "A URL do webhook do Slack é necessária" - }, - "discord": { - "label": "Discord", - "description": "Para enviar dados para um canal do Discord a partir do Checkmate por meio de notificações do Discord usando webhooks, você pode usar o recurso de Webhooks de entrada do Discord.", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "A URL do webhook do Discord é necessária" - }, - "telegram": { - "label": "Telegram", - "description": "Para habilitar as notificações do Telegram, crie um bot do Telegram usando o BotFather, um bot oficial para criar e gerenciar bots do Telegram. Em seguida, obtenha o token da API e o ID do chat e os coloque aqui.", - "tokenLabel": "Seu token de bot", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "Seu ID de bate-papo", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "O token do Telegram e o ID do chat são necessários" - }, - "webhook": { - "label": "Webhooks", - "description": "Você pode configurar um webhook personalizado para receber notificações quando ocorrerem incidentes.", - "urlLabel": "Webhook URL", - "urlPlaceholder": "https://seu-servidor.com/webhook", - "urlRequired": "URL do webhook é necessária" - }, - "testNotificationDevelop": "Notificação de teste 2", - "integrationButton": "Integração de notificação", - "testSuccess": "Notificação de teste enviada com sucesso!", - "testFailed": "Falha ao enviar notificação de teste", - "unsupportedType": "Tipo de notificação não suportado", - "networkError": "Ocorreu um erro de rede", - "fallback": { - "title": "Um canal de notificação é usado para:", - "checks": [ - "Alerte as equipes sobre tempo de inatividade ou problemas de desempenho", - "Informe os engenheiros sobre incidentes", - "Mantenha os administradores informados sobre as mudanças no sistema" - ], - "actionButton": "Vamos criar seu primeiro canal de notificação!" - }, - "createButton": "Criar canal de notificação", - "createTitle": "Canal de notificação", - "create": { - "success": "Notificação criada com sucesso", - "failed": "Falha ao criar notificação" - }, - "fetch": { - "success": "Notificações obtidas com sucesso", - "failed": "Falha ao buscar notificações" - }, - "delete": { - "success": "Notificação excluída com sucesso", - "failed": "Falha ao excluir a notificação" - }, - "edit": { - "success": "Notificação atualizada com sucesso", - "failed": "Falha ao atualizar a notificação" - }, - "test": { - "success": "Notificação de teste enviada com sucesso", - "failed": "Falha ao enviar notificação de teste" - } - }, - "testLocale": "testLocale", - "add": "Adicionar", - "monitors": "monitores", - "distributedUptimeStatusCreateStatusPage": "Pagina de status", - "distributedUptimeStatusCreateStatusPageAccess": "Acesso", - "distributedUptimeStatusCreateStatusPageReady": "Se sua página de status estiver pronta, você pode marcá-la como publicada.", - "distributedUptimeStatusBasicInfoHeader": "Informação básica", - "distributedUptimeStatusBasicInfoDescription": "Defina o nome da empresa e o subdomínio para o qual sua página de status aponta.", - "distributedUptimeStatusLogoHeader": "Logo", - "distributedUptimeStatusLogoDescription": "Carregue um logo para sua página de status", - "distributedUptimeStatusLogoUploadButton": "Upload logo", - "distributedUptimeStatusStandardMonitorsHeader": "Monitores padrão", - "distributedUptimeStatusStandardMonitorsDescription": "Anexe monitores padrão à sua página de status.", - "distributedUptimeStatusCreateYour": "Crie o seu", - "distributedUptimeStatusEditYour": "Edite seu", - "distributedUptimeStatusPublishedLabel": "Publicado e visível ao público", - "distributedUptimeStatusCompanyNameLabel": "Nome da empresa", - "distributedUptimeStatusPageAddressLabel": "Endereço da sua página de status", - "distributedUptimeStatus30Days": "30 dias", - "distributedUptimeStatus60Days": "60 dias", - "distributedUptimeStatus90Days": "90 dias", - "distributedUptimeStatusPageNotSetUp": "Uma página de status não está configurada.", - "distributedUptimeStatusContactAdmin": "Entre em contato com seu administrador", - "distributedUptimeStatusPageNotPublic": "Esta página de status não é pública.", - "distributedUptimeStatusPageDeleteDialog": "Você quer excluir esta página de status?", - "distributedUptimeStatusPageDeleteConfirm": "Sim, excluir página de status", - "distributedUptimeStatusPageDeleteDescription": "Uma vez excluída, sua página de status não poderá ser recuperada.", - "distributedUptimeStatusDevices": "Dispositivos", - "distributedUptimeStatusUpt": "UPT", - "distributedUptimeStatusUptBurned": "UPT queimado", - "distributedUptimeStatusUptLogo": "Logo UPT", - "incidentsTableNoIncidents": "Nenhum incidente registrado", - "incidentsTablePaginationLabel": "incidentes", - "incidentsTableMonitorName": "Nome do monitor", - "incidentsTableStatus": "Status", - "incidentsTableDateTime": "Data & Hora", - "incidentsTableStatusCode": "Código de status", - "incidentsTableMessage": "Mensagem", - "incidentsOptionsHeader": "Incidentes para:", - "incidentsOptionsHeaderFilterBy": "Filtrar por:", - "incidentsOptionsHeaderFilterAll": "Todos", - "incidentsOptionsHeaderFilterDown": "Inativos", - "incidentsOptionsHeaderFilterCannotResolve": "Não é possível resolver", - "incidentsOptionsHeaderShow": "Mostrar:", - "incidentsOptionsHeaderLastHour": "Última hora", - "incidentsOptionsHeaderLastDay": "Último dia", - "incidentsOptionsHeaderLastWeek": "Ultima semana", - "incidentsOptionsPlaceholderAllServers": "Todos os servidores", - "infrastructureCreateYour": "Crie o seu", - "infrastructureCreateGeneralSettingsDescription": "Aqui você pode inserir a URL do host, juntamente com o nome de exibição e o API secret de autorização para se conectar ao agente do servidor.", - "infrastructureServerRequirement": "O servidor que você quer monitorar deve estar executando o", - "infrastructureCustomizeAlerts": "Personalize alertas", - "infrastructureAlertNotificationDescription": "Envie uma notificação ao(s) usuário(s) quando os limites excederem uma porcentagem especificada.", - "infrastructureCreateMonitor": "Criar monitor de infraestrutura", - "infrastructureProtocol": "Protocolo", - "infrastructureServerUrlLabel": "URL do servidor", - "infrastructureDisplayNameLabel": "Nome de exibição", - "infrastructureAuthorizationSecretLabel": "API Secret", - "gb": "GB", - "mb": "MB", - "mem": "Mem", - "memoryUsage": "Uso de memória", - "cpu": "CPU", - "cpuUsage": "Uso da CPU", - "cpuTemperature": "Temperatura da CPU", - "diskUsage": "Uso de disco", - "used": "Usado", - "total": "Total", - "cores": "Núcleos", - "frequency": "Frequência", - "status": "Status", - "cpuPhysical": "CPU (Física)", - "cpuLogical": "CPU (Lógica)", - "cpuFrequency": "Frequência da CPU", - "avgCpuTemperature": "Temperatura média da CPU", - "memory": "Memória", - "disk": "Disco", - "uptime": "Uptime", - "os": "OS", - "host": "Host", - "actions": "Ações", - "integrations": "Integrações", - "integrationsPrism": "Conecte o Prism ao seu serviço favorito.", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "Conecte-se com o Slack e veja incidentes em um canal", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "Conecte-se com o Discord e visualize incidentes diretamente em um canal", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "Envie todos os incidentes para o Zapier e veja-os em todos os lugares", - "commonSave": "Salvar", - "createYour": "Crie o seu", - "createMonitor": "Criar monitor", - "pause": "Pausar", - "resume": "Resumir", - "editing": "Editando...", - "url": "URL", - "access": "Acesso", - "timezone": "Fuso horário", - "features": "Recursos", - "administrator": "Administrador?", - "loginHere": "Login aqui", - "displayName": "Nome de exibição", - "urlMonitor": "URL do monitor", - "portToMonitor": "Porta do monitor", - "websiteMonitoring": "Monitoramento de sites", - "websiteMonitoringDescription": "Use HTTP(s) para monitorar seu site ou API endpoint.", - "pingMonitoring": "Monitoramento de ping", - "pingMonitoringDescription": "Verifique se o seu servidor está disponível ou não.", - "dockerContainerMonitoring": "Monitoramento de contêiner Docker", - "dockerContainerMonitoringDescription": "Verifique se o seu contêiner Docker está em execução ou não.", - "portMonitoring": "Monitoramento de porta", - "portMonitoringDescription": "Verifique se sua porta está aberta ou não.", - "createMaintenanceWindow": "Criar janela de manutenção", - "createMaintenance": "Criar manutenção", - "editMaintenance": "Editar manutenção", - "maintenanceWindowName": "Nome da janela de manutenção", - "friendlyNameInput": "Nome amigável", - "friendlyNamePlaceholder": "Manutenção em __ : __ por ___ minutos", - "maintenanceRepeat": "Repetição de manutenção", - "maintenance": "manutenção", - "duration": "Duração", - "addMonitors": "Adicionar monitores", - "window": "janela", - "cancel": "Cancelar", - "message": "Mensagem", - "low": "baixo", - "high": "alto", - "statusCode": "Código de status", - "date&Time": "Data & Hora", - "type": "Tipo", - "statusPageName": "Nome da página de status", - "publicURL": "URL publica", - "repeat": "Repetir", - "edit": "Editar", - "createA": "Crie um", - "remove": "Remover", - "maintenanceWindowDescription": "Seus pings não serão enviados durante esse período", - "startTime": "Hora de início", - "timeZoneInfo": "Todas as datas e horários estão no fuso horário GMT+0.", - "monitorsToApply": "Monitores para aplicar a janela de manutenção", - "nextWindow": "Próxima janela", - "notFoundButton": "Vá para o painel principal", - "pageSpeedConfigureSettingsDescription": "Aqui você pode selecionar a URL do host, juntamente com o tipo de monitor.", - "monitorDisplayName": "Nome de exibição do monitor", - "whenNewIncident": "Quando há um novo incidente,", - "notifySMS": "Notificar via SMS (em breve)", - "notifyEmails": "Também notificar por e-mail para vários endereços (em breve)", - "seperateEmails": "Você pode separar vários e-mails com uma vírgula", - "checkFrequency": "Verifique a frequência", - "matchMethod": "Método de correspondência", - "expectedValue": "Valor esperado", - "deleteDialogTitle": "Você realmente deseja excluir este monitor?", - "deleteDialogDescription": "Uma vez excluído, este monitor não poderá ser recuperado.", - "pageSpeedMonitor": "Monitor PageSpeed", - "shown": "Mostrado", - "ago": "atrás", - "companyName": "Nome da empresa", - "pageSpeedDetailsPerformanceReport": "Os valores são estimados e podem variar.", - "pageSpeedDetailsPerformanceReportCalculator": "Veja calculadora", - "checkingEvery": "Verificando a cada", - "statusPageCreateSettings": "Se sua página de status estiver pronta, você pode marcá-la como publicada.", - "basicInformation": "Informações Básicas", - "statusPageCreateBasicInfoDescription": "Defina o nome da empresa e o subdomínio para o qual sua página de status aponta.", - "statusPageCreateSelectTimeZoneDescription": "Selecione o fuso horário em que será exibido na sua página de status.", - "statusPageCreateAppearanceDescription": "Defina a aparência padrão da sua página de status pública.", - "statusPageCreateSettingsCheckboxLabel": "Publicado e visível ao público", - "statusPageCreateBasicInfoStatusPageAddress": "Endereço da sua página de status", - "statusPageCreateTabsContent": "Servidores de páginas de status", - "statusPageCreateTabsContentDescription": "Você pode adicionar quantos monitores quiser à sua página de status. Você também pode reordená-los para uma melhor experiência de visualização.", - "statusPageCreateTabsContentFeaturesDescription": "Mostrar mais detalhes na página de status", - "showCharts": "Mostrar gráficos", - "showUptimePercentage": "Mostrar porcentagem de Uptime", - "removeLogo": "Remover logo", - "statusPageStatus": "Uma página de status pública não está configurada.", - "statusPageStatusContactAdmin": "Entre em contato com seu administrador", - "statusPageStatusNotPublic": "Esta página de status não é pública.", - "statusPageStatusNoPage": "Não há página de status aqui.", - "statusPageStatusServiceStatus": "Status do serviço", - "deleteStatusPage": "Você quer excluir esta página de status?", - "deleteStatusPageConfirm": "Sim, excluir página de status", - "deleteStatusPageDescription": "Uma vez excluída, sua página de status não poderá ser recuperada.", - "uptimeCreate": "O valor esperado é usado para corresponder ao resultado da resposta, e a correspondência determina o status.", - "uptimeCreateJsonPath": "Esta expressão será avaliada em relação aos dados JSON de resposta e o resultado será usado para corresponder ao valor esperado. Veja", - "uptimeCreateJsonPathQuery": "para documentação de linguagem de consulta.", - "maintenanceTableActionMenuDialogTitle": "Você realmente deseja remover esta janela de manutenção?", - "infrastructureEditYour": "Edite seu", - "infrastructureEditMonitor": "Salvar Monitor de Infraestrutura", - "infrastructureMonitorCreated": "Monitor de infraestrutura criado com sucesso!", - "infrastructureMonitorUpdated": "Monitor de infraestrutura atualizado com sucesso!", - "errorInvalidTypeId": "Tipo de notificação fornecido inválido", - "errorInvalidFieldId": "ID do campo fornecido inválido", - "inviteNoTokenFound": "Nenhum token de convite encontrado", - "pageSpeedWarning": "Aviso: você ainda não adicionou uma chave de API do Google PageSpeed. Sem ela, o monitor PageSpeed ​​não funcionará.", - "pageSpeedLearnMoreLink": "Clique aqui", - "pageSpeedAddApiKey": "para adicionar sua chave de API.", - "update": "Atualizar", - "invalidFileFormat": "Formato de arquivo não suportado!", - "invalidFileSize": "O tamanho do arquivo é muito grande!", - "ClickUpload": "Clique para enviar", - "DragandDrop": "arraste e solte", - "MaxSize": "Tamanho Máximo", - "SupportedFormats": "Formatos suportados", - "FirstName": "Nome", - "LastName": "Sobrenome", - "EmailDescriptionText": "Este é seu endereço de e-mail atual — ele não pode ser alterado.", - "YourPhoto": "Foto de perfil", - "PhotoDescriptionText": "Esta foto será exibida na sua página de perfil.", - "save": "Salvar", - "DeleteDescriptionText": "Isso removerá a conta e todos os dados associados do servidor. Essa ação não é reversível.", - "DeleteAccountWarning": "Remover sua conta significa que você não poderá fazer login novamente e todos os seus dados serão removidos. Isso não é reversível.", - "DeleteWarningTitle": "Deseja realmente remover esta conta?", - "bulkImport": { - "title": "Bulk Import", - "selectFileTips": "Selecione o arquivo CSV para enviar", - "selectFileDescription": "Você pode baixar nosso ou amostra", - "selectFile": "Selecione o arquivo", - "parsingFailed": "Falha na análise", - "uploadSuccess": "Monitores criados com sucesso!", - "validationFailed": "Falha na validação", - "noFileSelected": "Nenhum arquivo selecionado", - "fallbackPage": "Importe um arquivo para enviar uma lista de servidores em massa", - "invalidFileType": "Tipo de arquivo inválido", - "uploadFailed": "Falha no upload" - }, - "DeleteAccountTitle": "Remover conta", - "DeleteAccountButton": "Remover conta", - "publicLink": "Link publico", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "reset": "Resetar", - "ignoreTLSError": "Ignorar erro TLS/SSL", - "tlsErrorIgnored": "Erros TLS/SSL ignorados", - "ignoreTLSErrorDescription": "Ignore os erros de TLS/SSL e continue verificando a disponibilidade do site", - "createNew": "Criar novo", - "greeting": { - "prepend": "Ei", - "append": "A tarde é seu playground — vamos torná-la épica!", - "overview": "Aqui está uma visão geral dos seus monitores {{type}}." - }, - "roles": { - "superAdmin": "Super Admin", - "admin": "Admin", - "teamMember": "Membro da equipe", - "demoUser": "Usuário demo" - }, - "teamPanel": { - "teamMembers": "Membros da equipe", - "filter": { - "all": "Todos", - "member": "Membro" - }, - "inviteTeamMember": "Convidar um membro da equipe", - "inviteNewTeamMember": "Convidar novo membro da equipe", - "inviteDescription": "Ao adicionar um novo membro da equipe, ele terá acesso a todos os monitores.", - "email": "E-mail", - "selectRole": "Selecione a cargo", - "inviteLink": "Link de convite", - "cancel": "Cancelar", - "noMembers": "Não há membros da equipe com este cargo", - "getToken": "Obter token", - "emailToken": "Token de e-mail", - "table": { - "name": "Nome", - "email": "E-mail", - "role": "Cargo", - "created": "Criado" - }, - "addTeamMember": { - "addMemberMenu": "", - "title": "", - "description": "", - "addButton": "" - }, - "register": "", - "registerToast": { - "success": "", - "dbUserExists": "", - "unknownError": "" - }, - "registerTeamMember": { - "title": "", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "lastName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "email": { - "errors": { - "empty": "", - "invalid": "" - } - }, - "role": { - "errors": { - "empty": "" - } - } - } - } - } - }, - "role": "", - "changeTeamPassword": { - "changePasswordMenu": "", - "title": "", - "description": "", - "success": "" - } - }, - "monitorState": { - "paused": "Pausado", - "resumed": "Retomado", - "active": "Ativo" - }, - "menu": { - "uptime": "Uptime", - "pagespeed": "Pagespeed", - "infrastructure": "Infraestrutura", - "incidents": "Incidentes", - "statusPages": "Pagina de status", - "maintenance": "Manutenção", - "integrations": "Integrações", - "settings": "Configurações", - "support": "Suporte", - "discussions": "Discussões", - "docs": "Docs", - "changelog": "Changelog", - "profile": "Perfil", - "password": "Senha", - "team": "Equipe", - "logOut": "Sair", - "notifications": "Notificações", - "logs": "Registros" - }, - "settingsEmailUser": "Usuário de e-mail - Nome de usuário para autenticação, substitui o endereço de e-mail, se especificado", - "state": "Estado", - "statusBreadCrumbsStatusPages": "Pagina de status", - "statusBreadCrumbsDetails": "Detalhes", - "commonSaving": "Salvando...", - "navControls": "Controles", - "incidentsPageTitle": "Incidentes", - "passwordPanel": { - "passwordChangedSuccess": "Sua senha foi alterada com sucesso.", - "passwordInputIncorrect": "Sua senha digitada está incorreta.", - "currentPassword": "Senha atual", - "enterCurrentPassword": "Digite sua senha atual", - "newPassword": "Nova Senha", - "enterNewPassword": "Digite sua nova senha", - "confirmNewPassword": "Confirme a nova senha", - "passwordRequirements": "A nova senha deve conter pelo menos 8 caracteres e deve ter pelo menos uma letra maiúscula, uma letra minúscula, um número e um caractere especial.", - "saving": "Salvando..." - }, - "emailSent": "E-mail enviado com sucesso", - "failedToSendEmail": "Falha ao enviar e-mail", - "settingsTestEmailSuccess": "E-mail de teste enviado com sucesso", - "settingsTestEmailFailed": "Falha ao enviar e-mail de teste", - "settingsTestEmailFailedWithReason": "Falha ao enviar e-mail de teste: {{reason}}", - "settingsTestEmailUnknownError": "Erro desconhecido", - "statusMsg": { - "paused": "O monitoramento está pausado.", - "up": "Seu site está no ar.", - "down": "Seu site está fora do ar.", - "pending": "Pendente..." - }, - "uptimeGeneralInstructions": { - "http": "Insira a URL ou IP para monitorar (ex. https://exemplo.com.br/ ou 192.168.1.100) e adicione uma descrição que aparecerá na dashboard.", - "ping": "Insira o endereço IP ou nome de domínio para ping (ex. 192.168.1.100 ou exemplo.com.br) e adicione uma descrição que aparecerá na dashboard.", - "docker": "Insira o Docker Id do seu container. Docker Ids devem ser todos os 64 caracteres. Você pode executar docker inspect para descobrir o Id completo.", - "port": "Insira a URL ou o IP do servidor, aporta e uma descrição que aparecerá na dashboard.", - "game": "", - "https": "" - }, "common": { - "appName": "Checkmate", - "monitoringAgentName": "Capture", - "buttons": { - "toggleTheme": "Trocar claro e escuro" + "auth": { + "roles": { + "admin": "Admin", + "demo": "Demo", + "superadmin": "Super admin", + "user": "Usuário" + } }, - "toasts": { - "networkError": "Erro de rede", - "checkConnection": "Por favor, verifique a conexão", - "unknownError": "Erro desconhecido" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "Continuar", - "back": "Voltar" + "alerts": { + "pageSpeedApiKey": { + "content": "Aviso: Você ainda não adicionou uma chave de API do Google PageSpeed. Acesse Configurações para adicionar uma. Sem ela, o monitor PageSpeed não funcionará." + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "Detalhes", + "home": "Início" + }, + "buttons": { + "addMember": "Adicionar membro", + "cancel": "Cancelar", + "close": "Fechar", + "configure": "Configurar", + "confirm": "Confirmar", + "create": "Criar", + "delete": "Excluir", + "generateToken": "Gerar token", + "incidents": "Incidentes", + "inviteMember": "Convidar membro", + "pause": "Pausar", + "resume": "Retomar", + "save": "Salvar", + "sendInvite": "Enviar convite", + "test": "Testar", + "testNotifications": "Testar notificações", + "toggleTheme": "Trocar claro e escuro", + "flushQueue": "Limpar fila", + "notFound": "Ir para o painel principal", + "resetPassword": "Redefinir senha", + "reset": "Resetar", + "clear": "Limpar", + "addDemo": "Adicionar monitores demo", + "removeMonitors": "Remover monitores", + "sendTestEmail": "Enviar email de teste", + "exportToJSON": "Exportar para JSON", + "importFromJSON": "Importar de JSON", + "clearFilters": "Limpar filtros", + "removeUser": "Remover usuário" + }, + "charts": { + "labels": { + "averageResponseTime": "Tempo médio de resposta", + "downtime": "Tempo inativo", + "high": "alto", + "low": "baixo", + "uptime": "Uptime" }, - "inputs": { - "email": { - "label": "Email", - "placeholder": "joao.silva@dominio.com.br", - "errors": { - "empty": "Para continuar, insira seu endereço de email", - "invalid": "Por favor, confirme o endereço de email informado" - } - }, - "password": { - "label": "Senha", - "rules": { - "length": { - "beginning": "Deve ter no mínimo", - "highlighted": "8 caracteres de comprimento" - }, - "special": { - "beginning": "Deve conter pelo menos", - "highlighted": "um caractere especial" - }, - "number": { - "beginning": "Deve ter pelo menos", - "highlighted": "um número" - }, - "uppercase": { - "beginning": "Deve ter pelo menos", - "highlighted": "uma letra maiúscula" - }, - "lowercase": { - "beginning": "Deve ter pelo menos", - "highlighted": "uma letra minúscula" - }, - "match": { - "beginning": "Senha e confirmação de senha", - "highlighted": "devem combinar" - } - }, - "errors": { - "empty": "Por favor, digite sua senha", - "length": "A senha deve ter pelo menos 8 caracteres", - "uppercase": "A senha deve conter pelo menos 1 letra maiúscula", - "lowercase": "Senha deve ter pelo menos uma letra minúscula", - "number": "Senha deve ter pelo menos um número", - "special": "Senha deve ter pelo menos um caractere especial", - "incorrect": "A senha que você forneceu não está registrada" - } - }, - "passwordConfirm": { - "label": "Confirme a senha", - "placeholder": "Informe a senha novamente para confirmar", - "errors": { - "empty": "Por favor, informe a senha novamente para confirmação (cuidado com erros de digitação)", - "different": "As senhas informadas não combinam, então uma deve estar errada" - } - }, - "firstName": { - "label": "Nome", - "placeholder": "João", - "errors": { - "empty": "Por favor, insira seu nome", - "length": "Nome deve ter menos de 50 caracteres", - "pattern": "Nome deve conter apenas letras, espaços, apóstrofos ou hífens" - } - }, - "lastName": { - "label": "Sobrenome", - "placeholder": "Silva", - "errors": { - "empty": "Por favor, insira seu sobrenome", - "length": "Sobrenome deve ter menos de 50 caracteres", - "pattern": "Sobrenome deve conter apenas letras, espaços, apóstrofos ou hífens" + "histogram": { + "avg": "Média: {{value}} ms", + "max": "Máx: {{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "Esta ação não pode ser desfeita.", + "title": "Tem certeza de que deseja excluir isto?" + } + }, + "labels": { + "active": "Ativo", + "paused": "pausado", + "na": "N/D", + "resolved": "Resolvido", + "responseTime": "Tempo de resposta" + }, + "form": { + "role": { + "option": { + "role": { + "label": "Funções" } } }, - "errors": { - "validation": "Erro de validação de dados." - }, - "fields": { - "password": { - "errors": { - "incorrect": "A senha que você forneceu não corresponde aos nossos registros" + "name": { + "option": { + "firstName": { + "label": "Nome", + "placeholder": "João" + }, + "lastName": { + "label": "Sobrenome", + "placeholder": "Silva" } - }, - "role": { - "errors": { - "min": "Pelo menos uma função é necessária" + } + }, + "email": { + "option": { + "email": { + "label": "Email", + "placeholder": "voce@exemplo.com" } } } }, - "login": { - "heading": "Entrar", - "subheadings": { - "stepOne": "Insira seu email", - "stepTwo": "Insira sua senha" - }, - "links": { - "forgotPassword": "Esqueceu sua senha?", - "register": "Não tem uma conta?", - "forgotPasswordLink": "Redefinir senha", - "registerLink": "Registre-se aqui" - }, - "toasts": { - "success": "Bem-vindo de volta! Você está autenticado.", - "incorrectPassword": "Senha incorreta" - }, + "table": { + "empty": "Nada aqui", + "headers": { + "actions": "Ações", + "dateTime": "Data e hora", + "message": "Mensagem", + "monitor": "Monitor", + "monitorId": "ID do monitor", + "name": "Nome", + "status": "Status", + "type": "Tipo", + "url": "URL", + "interval": "Intervalo", + "active": "Ativo", + "responseTime": "Tempo de resposta" + } + } + }, + "components": { + "imageUpload": { + "clickToUpload": "Clique para enviar", + "dragAndDrop": "arraste e solte", + "supportedFormats": "Formatos suportados", + "maxSize": "Tamanho Máximo", + "orDragAndDrop": "ou arraste e solte", "errors": { - "password": { - "incorrect": "A senha que você forneceu não corresponde aos nossos registros" - } - }, - "welcome": "" + "invalidFileSize": "O tamanho do arquivo é muito grande!", + "invalidFileFormat": "Formato de arquivo não suportado!" + } }, - "registration": { - "heading": { - "superAdmin": "Crie um usuário administrador", - "user": "Se inscrever" - }, - "subheadings": { - "stepOne": "Informe seus dados pessoais", - "stepTwo": "Informe seu email", - "stepThree": "Crie sua senha" + "headerStatusPageControls": { + "publicLink": "Link publico" + }, + "headerTimeRange": { + "labels": { + "day": "Dia", + "month": "Mês", + "recent": "Recente", + "week": "Semana" }, "description": { - "superAdmin": "Crie sua conta de superadministrador para começar", - "user": "Cadastre-se como usuário e peça ao superadministrador acesso aos seus monitores" + "recent": "Exibindo estatísticas das últimas 2 horas.", + "day": "Exibindo estatísticas das últimas 24 horas.", + "week": "Exibindo estatísticas dos últimos 7 dias.", + "month": "Exibindo estatísticas dos últimos 30 dias." + } + }, + "offlineBanner": { + "serverUnreachable": "Não foi possível conectar ao servidor", + "retry": "Tentar novamente", + "retrying": "Tentando novamente...", + "reconnected": "Reconectado ao servidor com sucesso." + }, + "sidebar": { + "menu": { + "uptime": "Uptime", + "pagespeed": "Pagespeed", + "infrastructure": "Infraestrutura", + "notifications": "Notificações", + "checks": "Verificações", + "incidents": "Incidentes", + "statusPages": "Pagina de status", + "maintenance": "Manutenção", + "logs": "Registros", + "settings": "Configurações" }, - "gettingStartedButton": { - "superAdmin": "Criar conta de superadministrador", - "user": "Cadastre-se como usuário normal" + "bottomMenu": { + "support": "Suporte", + "discussions": "Discussões", + "docs": "Docs", + "changelog": "Changelog" }, - "termsAndPolicies": "Ao criar uma conta, você concorda com nossos Termos de Serviço e Política de Privacidade.", + "accountMenu": { + "profile": "Perfil", + "password": "Senha", + "team": "Equipe" + }, + "starPrompt": { + "title": "Avalie o Checkmate", + "description": "Veja os últimos lançamentos e ajude a comunidade a crescer no GitHub" + }, + "authFooter": { + "navControls": "Controles", + "logOut": "Sair", + "roles": { + "superAdmin": "Super admin", + "admin": "Admin", + "user": "Usuário", + "demoUser": "Usuário demo" + } + } + }, + "starPrompt": { + "title": "Avalie o Checkmate", + "description": "Veja os últimos lançamentos e ajude a comunidade a crescer no GitHub" + }, + "pageHeader": { "links": { - "login": "Já tem uma conta? Entrar" - }, - "toasts": { - "success": "Bem-vindo! Sua conta foi criada com sucesso." - }, - "welcome": "" - }, - "forgotPassword": { - "heading": "Esqueceu sua senha?", - "subheadings": { - "stepOne": "Não se preocupe, enviaremos instruções de redefinição.", - "stepTwo": "Enviamos um link de redefinição de senha para ", - "stepThree": "Sua nova senha deve ser diferente das senhas usadas anteriormente.", - "stepFour": "Sua senha foi redefinida com sucesso. Clique abaixo para fazer login magicamente." - }, - "buttons": { - "openEmail": "Abra o aplicativo de e-mail", - "resetPassword": "Redefinir senha" - }, - "imageAlts": { - "passwordKey": "Ícone de chave de senha", - "email": "Ícone de e-mail", - "lock": "Ícone de cadeado", - "passwordConfirm": "Ícone de confirmação de senha" - }, - "links": { - "login": "Voltar para o login", - "resend": "Não recebeu o email? Clique para reenviar" - }, - "toasts": { - "sent": "Instruções enviadas para .", - "emailNotFound": "E-mail não encontrado.", - "redirect": "Redirecionando em ...", - "success": "Sua senha foi redefinida com sucesso.", - "error": "Não foi possível redefinir a senha. Tente novamente mais tarde ou entre em contato com o suporte." + "support": "Suporte", + "discussions": "Discussões", + "docs": "Documentação", + "changelog": "Notas de versão" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "Reconectado ao servidor com sucesso.", - "stillUnreachable": "O servidor ainda está inacessível. Tente novamente mais tarde." + "pages": { + "notFound": { + "title": "Oh não! Você derrubou seu sushi!", + "subtitle": "A URL não existe ou você não tem acesso a ela." + }, + "account": { + "tabs": { + "profile": "Perfil", + "password": "Senha", + "team": "Equipe" }, - "alertBox": "Erro de conexão do servidor", - "description": "Não conseguimos conectar ao servidor. Verifique sua conexão com a internet ou sua configuração de implantação se o problema persistir.", - "retryButton": { - "default": "Tentar conectar novamente", - "processing": "Conectando..." + "form": { + "name": { + "title": "Nome", + "description": "Atualize suas informações pessoais" + }, + "photo": { + "title": "Foto de perfil", + "description": "Envie uma foto de perfil" + }, + "currentPassword": { + "title": "Senha atual", + "description": "Digite sua senha atual para verificar sua identidade", + "option": { + "label": "Senha atual", + "placeholder": "Digite a senha atual" + } + }, + "newPassword": { + "title": "Nova senha", + "description": "Escolha uma senha forte com pelo menos 8 caracteres", + "option": { + "newPassword": { + "label": "Nova senha", + "placeholder": "Digite a nova senha" + }, + "confirm": { + "label": "Confirmar senha", + "placeholder": "Confirme a nova senha" + } + } + }, + "deleteAccount": { + "title": "Excluir conta", + "description": "Esta ação é permanente e não pode ser desfeita" + } + }, + "team": { + "filter": { + "placeholder": "Filtrar por função", + "all": "Todos", + "admin": "Admin", + "member": "Membro" + }, + "table": { + "headers": { + "email": "Email", + "role": "Função", + "created": "Criado" + } + }, + "addMember": { + "title": "Registrar novo membro da equipe", + "description": "Crie uma nova conta de usuário. Compartilhe as credenciais de forma segura após a criação." + }, + "invite": { + "title": "Convidar membro da equipe", + "description": "Envie um convite para se juntar à sua equipe", + "email": { + "label": "Endereço de email", + "placeholder": "Digite o endereço de email" + }, + "role": { + "label": "Função", + "placeholder": "Selecione uma função" + }, + "linkLabel": "Link de convite" + }, + "fallback": { + "title": "Ainda não há membros na equipe", + "description": "Convide colegas por e-mail ou adicione-os diretamente. Assim que entrarem, aparecerão aqui com sua função." + } + }, + "header": { + "title": "Conta", + "description": "Gerencie seu perfil, senha, equipe e preferências de workspace." } - } - }, - "createNotifications": { - "title": "Criar canal de notificação", - "nameSettings": { - "title": "Nome", - "description": "Um nome descritivo para sua integração.", - "nameLabel": "Nome", - "namePlaceholder": "por exemplo, notificações do Slack" }, - "typeSettings": { - "title": "Tipo", - "description": "Selecione o tipo de canal de notificação que você deseja criar.", - "typeLabel": "Tipo" - }, - "emailSettings": { - "title": "E-mail", - "description": "Endereços de e-mail de destino.", - "emailLabel": "Endereço de email", - "emailPlaceholder": "por exemplo john@example.com" - }, - "slackSettings": { - "title": "Folga", - "description": "Configure seu webhook do Slack aqui", - "webhookLabel": "URL do webhook do Slack", - "webhookPlaceholder": "https://hooks.slack.com/services/..." - }, - "pagerdutySettings": { - "title": "PagerDuty", - "description": "Configure sua integração com o PagerDuty aqui", - "integrationKeyLabel": "Chave de integração", - "integrationKeyPlaceholder": "1234567890" - }, - "discordSettings": { - "title": "Discórdia", - "description": "Configure seu webhook do Discord aqui", - "webhookLabel": "URL do webhook do Discord", - "webhookPlaceholder": "https://seu-servidor.com/webhook" - }, - "webhookSettings": { - "title": "Webhook", - "description": "Configure seu webhook aqui", - "webhookLabel": "URL do webhook", - "webhookPlaceholder": "https://seu-servidor.com/webhook" - }, - "testNotification": "Notificação de teste", - "dialogDeleteTitle": "Tem certeza de que deseja excluir esta notificação?", - "dialogDeleteConfirm": "Excluir" - }, - "notificationConfig": { - "title": "Notificações", - "description": "Selecione os canais de notificação que deseja usar" - }, - "monitorStatus": { - "checkingEvery": "Verificando a cada {{intervalo}}", - "withCaptureAgent": "com agente de captura {{versão}}", - "up": "acima", - "down": "abaixo", - "paused": "pausado" - }, - "advancedMatching": "Correspondência avançada", - "sendTestNotifications": "Enviar notificações de teste", - "selectAll": "Selecionar tudo", - "showAdminLoginLink": "Mostrar o link \"Administrador? Efetue login aqui\" na página de status", - "logsPage": { - "title": "Registros", - "description": "Logs do sistema - últimas 1000 linhas", - "tabs": { - "queue": "Fila de tarefas", - "logs": "Logs do servidor", - "diagnostics": "Diagnóstico" - }, - "toast": { - "fetchLogsSuccess": "Logs obtidos com sucesso" - }, - "logLevelSelect": { - "title": "Nível de log", - "values": { - "all": "Todos", - "info": "Informações", - "warn": "Avisar", - "error": "Erro", - "debug": "Depurar" + "auth": { + "brandPanel": { + "eyebrow": "Monitoramento de infraestrutura open-source", + "tagline": "Acompanhe cada passo dos seus servidores.", + "description": "Monitoramento em tempo real de disponibilidade, tempo de resposta e incidentes - auto-hospedado e seu." + }, + "common": { + "passwordRules": { + "length": { + "beginning": "Deve ter no mínimo", + "highlighted": "8 caracteres de comprimento" + }, + "lowercase": { + "beginning": "Deve ter pelo menos", + "highlighted": "uma letra minúscula" + }, + "match": { + "beginning": "Senha e confirmação de senha", + "highlighted": "devem combinar" + }, + "number": { + "beginning": "Deve ter pelo menos", + "highlighted": "um número" + }, + "special": { + "beginning": "Deve conter pelo menos", + "highlighted": "um caractere especial" + }, + "uppercase": { + "beginning": "Deve ter pelo menos", + "highlighted": "uma letra maiúscula" + } + }, + "form": { + "option": { + "email": { + "label": "Email", + "placeholder": "eu@exemplo.com" + }, + "password": { + "label": "Senha", + "placeholder": "********" + }, + "confirmPassword": { + "label": "Confirmar senha" + } + } + } + }, + "login": { + "title": "Bem-vindo de volta ao Checkmate!", + "subtitle": "Faça login para continuar", + "submit": "Entrar", + "links": { + "forgotPassword": { + "text": "Esqueceu a senha?", + "linkText": "Redefinir senha" + }, + "register": { + "text": "Não tem uma conta?", + "linkText": "Cadastre-se aqui" + } + } + }, + "register": { + "title": "Bem-vindo ao Checkmate!", + "subtitle": "Cadastre-se para começar", + "submit": "Cadastrar", + "setupNotice": "Esta página está disponível apenas durante a configuração inicial. Após o registro de um super administrador, usuários adicionais podem ser convidados a partir das configurações da equipe." + }, + "forgotPassword": { + "title": "Esqueceu sua senha?", + "subtitle": "Não se preocupe, enviaremos instruções de recuperação.", + "submit": "Solicitar recuperação", + "links": { + "login": { + "text": "Voltar para", + "linkText": "login" + } + } + }, + "setNewPassword": { + "title": "Redefinir sua senha", + "subtitle": "Sua nova senha deve ser diferente das senhas usadas anteriormente." } - } - }, - "queuePage": { - "title": "Fila", - "refreshButton": "Atualizar", - "flushButton": "Fila de descarga", - "jobTable": { - "title": "Empregos atualmente na fila", - "idHeader": "ID do monitor", - "urlHeader": "URL", - "typeHeader": "Tipo", - "activeHeader": "Ativo", - "lockedAtHeader": "Trancado em", - "runCountHeader": "Contagem de corridas", - "failCountHeader": "Contagem de falhas", - "lastRunHeader": "Última corrida em", - "lastFinishedAtHeader": "Última conclusão em", - "lastRunTookHeader": "A última corrida aconteceu", - "intervalHeader": "" }, - "metricsTable": { - "title": "Métricas de fila", - "metricHeader": "Métrica", - "valueHeader": "Valor" + "checks": { + "selects": { + "monitor": { + "all": "Todos os monitores" + }, + "status": { + "all": "Todos", + "down": "Fora do ar", + "up": "Online" + } + }, + "table": { + "empty": "Nenhuma verificação com falha neste período", + "headers": { + "statusCode": "Código de status", + "location": "Localização" + } + }, + "fallback": { + "title": "Ainda não há verificações", + "description": "Assim que você criar um monitor, cada sondagem de status e tempo de resposta aparecerá aqui como uma verificação." + }, + "header": { + "title": "Verificações", + "description": "Cada sonda que o Checkmate executa contra seus monitores aparece aqui com código de status e tempo de resposta." + } }, - "failedJobTable": { - "title": "Trabalhos com falha", - "monitorIdHeader": "ID do monitor", - "monitorUrlHeader": "URL do monitor", - "failCountHeader": "Contagem de falhas", - "failedAtHeader": "Última falha em", - "failReasonHeader": "Motivo da falha" - } - }, - "export": { - "title": "Monitores de Exportação", - "success": "Monitores exportados com sucesso!", - "failed": "Falha ao exportar monitores" - }, - "monitorActions": { - "title": "Exportação/Importação", - "import": "Monitores de importação", - "export": "Monitores de Exportação", - "deleteSuccess": "Monitor excluído com sucesso", - "deleteFailed": "Falha ao excluir o monitor", - "details": "Detalhes" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "Desenvolvido pela Bluewave Labs", - "labelVersion": "Versão", - "title": "Sobre" + "common": { + "monitors": { + "actions": { + "configure": "Configurar", + "delete": "Excluir", + "generateToken": "Gerar token", + "details": "Detalhes", + "incidents": "Incidentes", + "inviteMember": "Convidar membro", + "openSite": "Abrir site", + "pause": "Pausar", + "resume": "Retomar", + "selectAll": "Selecionar todos os monitores", + "selectMonitor": "Selecionar {{name}}", + "bulkSelected_one": "{{count}} monitor selecionado", + "bulkSelected_other": "{{count}} monitores selecionados" + }, + "statBoxes": { + "activeFor": "Ativo há", + "certificateExpiry": "Validade do certificado", + "lastCheck": "Última verificação", + "lastResponseTime": "Último tempo de resposta", + "serviceIsDown": "O serviço está fora do ar" + }, + "status": { + "down": "fora do ar", + "breached": "limite excedido", + "initializing": "inicializando", + "maintenance": "manutenção", + "paused": "pausado", + "total": "total", + "up": "online" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "Jogo", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "Porta", + "optionPagespeed": "PageSpeed", + "optionHardware": "Infraestrutura", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} monitor pausado", + "paused_other": "{{count}} monitores pausados", + "resumed_one": "{{count}} monitor retomado", + "resumed_other": "{{count}} monitores retomados", + "alreadyPaused_one": "O monitor selecionado já está pausado", + "alreadyPaused_other": "Todos os monitores selecionados já estão pausados", + "alreadyRunning_one": "O monitor selecionado já está em execução", + "alreadyRunning_other": "Todos os monitores selecionados já estão em execução" + } + } }, - "demoMonitorsSettings": { - "buttonAddMonitors": "Adicionar monitores de demonstração", - "description": "Adicione monitores de amostra para fins de demonstração.", - "title": "Monitores de demonstração" + "createMonitor": { + "form": { + "advanced": { + "description": "Configurações opcionais para casos de uso avançados", + "option": { + "advancedMatching": { + "label": "Usar correspondência avançada" + }, + "expectedValue": { + "label": "Valor esperado" + }, + "jsonPath": { + "description": "Esta expressão será avaliada contra os dados JSON da resposta e o resultado será usado para comparar com o valor esperado. Consulte jmespath.org para a documentação da linguagem de consulta.", + "label": "Expressão JSONPath" + }, + "matchMethod": { + "label": "Método de correspondência", + "equal": "", + "include": "", + "regex": "Regex" + } + }, + "title": "Configurações avançadas" + }, + "frequency": { + "description": "Com que frequência você deseja verificar o status deste monitor?", + "option": { + "frequency": { + "label": "Frequência de verificação", + "value": { + "fifteenMinutes": "15 minutos", + "fifteenSeconds": "", + "fiveMinutes": "5 minutos", + "fourMinutes": "4 minutos", + "oneMinute": "1 minuto", + "tenMinutes": "10 minutos", + "thirtyMinutes": "30 minutos", + "thirtySeconds": "", + "threeMinutes": "3 minutos", + "twoMinutes": "2 minutos" + } + } + }, + "title": "Frequência de verificação" + }, + "general": { + "option": { + "container": { + "label": "Nome/ID do container", + "placeholder": "meu-app ou abcd1234" + }, + "host": { + "label": "Host", + "placeholder": "192.168.1.100 ou exemplo.com" + }, + "name": { + "label": "Nome de exibição", + "placeholder": "ex. Meu Site" + }, + "secret": { + "label": "Chave de autorização", + "placeholder": "Digite sua chave secreta" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "", + "placeholder": "" + }, + "port": { + "label": "Porta para monitorar", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "Nome do serviço", + "placeholder": "ex. meu.servico.v1 (deixe vazio para verificação geral)" + }, + "wsUrl": { + "label": "URL do WebSocket", + "placeholder": "wss://exemplo.com/socket" + }, + "captureUrl": { + "label": "Endpoint do Capture", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "Configurações gerais", + "description": { + "http": "Digite a URL ou IP para monitorar (ex.: https://exemplo.com/ ou 192.168.1.100) e adicione um nome de exibição claro que aparecerá no painel.", + "ping": "Insira o endereço IP ou nome de domínio para ping (ex. 192.168.1.100 ou exemplo.com.br) e adicione uma descrição que aparecerá na dashboard.", + "port": "Insira a URL ou o IP do servidor, aporta e uma descrição que aparecerá na dashboard.", + "docker": "Insira o Docker Id do seu container. Docker Ids devem ser todos os 64 caracteres. Você pode executar docker inspect para descobrir o Id completo.", + "game": "Digite o endereço IP ou nome de host e o número da porta para pingar (por exemplo, 192.168.1.100 ou example.com) e escolha o tipo de jogo. Alguns jogos exigem habilitar consultas ao servidor no arquivo de configuração — consulte a documentação do seu servidor de jogo. Veja a lista de jogos suportados para detalhes do protocolo.", + "pagespeed": "Acompanhe o desempenho de carregamento da página, Core Web Vitals e pontuações de otimização do seu site.", + "grpc": "Digite o hostname e a porta do servidor gRPC, opcionalmente especifique um nome de serviço para a verificação de saúde e adicione um nome de exibição.", + "websocket": "Digite a URL do WebSocket para monitorar (ex.: wss://exemplo.com/socket) e adicione um nome de exibição.", + "hardware": "Monitore o uso de CPU, memória, disco e temperatura da sua infraestrutura. A URL do endpoint do Capture deve incluir o caminho completo /api/v1/metrics (por exemplo, http://192.168.1.10:5001/api/v1/metrics). Para a chave de autorização, cole apenas a string do token — não inclua 'Bearer'." + } + }, + "ignoreTls": { + "description": "Configure a validação de certificados TLS/SSL para conexões HTTPS.", + "option": { + "tls": { + "label": "Ignorar erros de TLS/SSL" + } + }, + "title": "Configurações de TLS/SSL" + }, + "incidents": { + "description": "Uma janela deslizante é usada para determinar quando um monitor fica fora do ar. O status de um monitor só mudará quando a porcentagem de verificações na janela deslizante atingir o valor especificado.", + "option": { + "checks": { + "label": "Número de verificações na janela deslizante" + }, + "percentage": { + "label": "Qual porcentagem de verificações na janela deslizante deve falhar/ter sucesso antes de mudar o status do monitor?" + } + }, + "title": "Incidentes" + }, + "notifications": { + "description": "Selecione os canais de notificação que deseja usar", + "title": "Notificações" + }, + "type": { + "description": "Selecione o tipo de verificação a ser realizada", + "optionDockerDescription": "Use Docker para monitorar se um container está em execução.", + "optionGameDescription": "Monitore se um servidor de jogo específico está online.", + "optionGrpcDescription": "Monitore serviços gRPC usando o protocolo padrão de verificação de saúde.", + "optionWebSocketDescription": "Monitore endpoints WebSocket para verificar a saúde da conexão e o tempo de resposta.", + "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" + }, + "thresholds": { + "title": "Limites de alerta", + "description": "Defina os limites em que os alertas devem ser disparados para este monitor de hardware.", + "option": { + "cpuThreshold": { + "label": "Limite de alerta de CPU (%)" + }, + "memoryThreshold": { + "label": "Limite de alerta de memória (%)" + }, + "diskThreshold": { + "label": "Limite de alerta de disco (%)" + }, + "tempThreshold": { + "label": "Limite de alerta de temperatura (°C)" + } + } + }, + "geoChecks": { + "title": "Verificações geodistribuídas", + "description": "Execute verificações de múltiplas localizações geográficas para monitorar a disponibilidade e o desempenho global.", + "option": { + "enabled": { + "label": "Ativar verificações geodistribuídas" + }, + "locations": { + "label": "Localizações", + "placeholder": "Selecionar localizações", + "options": { + "EU": "Europa", + "NA": "América do Norte", + "AS": "Ásia", + "SA": "América do Sul", + "AF": "África", + "OC": "Oceania" + } + }, + "interval": { + "label": "Intervalo de verificação", + "value": { + "fiveMinutes": "5 minutos", + "tenMinutes": "10 minutos", + "fifteenMinutes": "15 minutos", + "thirtyMinutes": "30 minutos" + } + } + } + }, + "url": { + "title": "IP/URL do monitor na página de status", + "description": "Exiba o endereço IP ou a URL do monitor na página de status pública. Se desativado, apenas o nome do monitor será exibido para proteger informações sensíveis.", + "option": { + "showURL": { + "label": "Exibir IP/URL na página de status", + "enabled": "Ativado", + "disabled": "Desativado" + } + } + }, + "stats": { + "title": "Histórico do monitor", + "description": "Limpe todo o histórico de monitoramento e estatísticas da sua equipe. Esta ação é irreversível.", + "buttonText": "Limpar todas as estatísticas", + "dialog": { + "title": "Limpar todo o histórico de monitoramento?", + "description": "Isso excluirá permanentemente todo o histórico de monitoramento, estatísticas e dados de verificação da sua equipe. Esta ação não pode ser desfeita.", + "confirm": "Sim, limpar todas as estatísticas" + } + } + } }, - "emailSettings": { - "buttonSendTestEmail": "Enviar e-mail de teste", - "description": "Configure as configurações de e-mail do seu sistema. Elas são usadas para enviar notificações e alertas.", - "descriptionTransport": "Isso cria um transporte SMTP para o NodeMailer", - "labelAddress": "Endereço de e-mail - Usado para autenticação", - "labelConnectionHost": "Host de conexão de e-mail - Nome do host a ser usado na saudação HELO/EHLO", - "labelHost": "Host de e-mail - Nome do host ou endereço IP para conectar", - "labelIgnoreTLS": "Desabilitar STARTTLS: Não use TLS mesmo que o servidor suporte", - "labelPassword": "Senha de e-mail - Senha para autenticação", - "labelPasswordSet": "A senha foi definida. Clique em Redefinir para alterá-la.", - "labelPool": "Habilitar pool de conexões: Reutilize conexões existentes para melhorar o desempenho", - "labelPort": "Porta de e-mail - Porta para conectar", - "labelRejectUnauthorized": "Rejeitar certificados inválidos: rejeitar conexões com certificados autoassinados ou não confiáveis", - "labelRequireTLS": "Forçar STARTTLS: requer atualização de TLS, falha se não for suportado", - "labelSecure": "Usar SSL (recomendado): criptografar a conexão usando SSL/TLS", - "labelTLSServername": "Nome do servidor TLS - Nome do host opcional para validação TLS quando o host é um IP", - "labelUser": "Usuário de e-mail - Nome de usuário para autenticação, substitui o endereço de e-mail, se especificado", - "linkTransport": "Veja as especificações aqui", - "placeholderUser": "Deixe em branco se não for necessário", - "title": "E-mail", - "toastEmailRequiredFieldsError": "Endereço de e-mail, host, porta e senha são obrigatórios" + "editUser": { + "form": { + "roles": { + "title": "Funções", + "description": "Atribua funções ao usuário. Múltiplas funções podem ser selecionadas." + } + }, + "dialog": { + "removeUser": { + "title": "Remover usuário", + "content": "Tem certeza de que deseja remover {{name}} da sua equipe? Esta ação não pode ser desfeita." + } + } }, - "pageSpeedSettings": { - "description": "Insira sua chave da API do Google PageSpeed para ativar o monitoramento do Google PageSpeed. Clique em Redefinir para atualizar a chave.", - "labelApiKeySet": "A chave de API está definida. Clique em Redefinir para alterá-la.", - "labelApiKey": "Chave da API do PageSpeed", - "title": "Chave da API do Google PageSpeed" + "incidents": { + "fallback": { + "title": "Sem incidentes — todos os sistemas saudáveis", + "description": "Quando um monitor falha ou se recupera, o incidente aparecerá aqui com horários, detalhes do monitor e histórico de resolução." + }, + "dialog": { + "resolveIncident": { + "title": "Resolver incidente", + "option": { + "comment": { + "label": "Comentário (opcional)", + "placeholder": "Adicione um comentário sobre a resolução..." + } + } + }, + "details": { + "analysis": "Análise do incidente", + "comment": "Comentário:", + "detailsLabel": "Detalhes", + "downtime": "Tempo inativo:", + "message": "Mensagem:", + "monitor": "Monitor:", + "overview": "Visão geral", + "resolutionDetails": "Detalhes da resolução", + "resolutionType": "Tipo:", + "resolutionTypes": { + "automatic": "Automática", + "manual": "Manual" + }, + "resolve": "Resolver incidente", + "resolvedAt": "Resolvido em:", + "resolvedBy": "Resolvido por:", + "startedAt": "Iniciado em:", + "status": "Status:", + "statusCode": "Código de status:", + "timeline": "Linha do tempo", + "title": "Detalhes do incidente", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "Todos os monitores", + "monitor": "Monitor", + "resolutionType": "Tipo de resolução", + "resolutionTypes": { + "manual": "Manual", + "automatic": "Automática", + "all": "Todos" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "Incidentes ativos", + "active_zero": "Nenhum incidente ativo", + "active_one": "{{count}} incidente ativo", + "active_other": "{{count}} incidentes ativos" + }, + "incidentStats": { + "avgResolutionTime": "Tempo médio de resolução", + "mostAffectedMonitor": "Monitor mais afetado", + "title": "Estatísticas de incidentes", + "totalIncidents": "Total de incidentes" + }, + "latestIncidents": { + "title": "Últimos incidentes" + } + }, + "table": { + "actions": { + "details": "Detalhes", + "goToMonitor": "Ir para o monitor", + "resolveManually": "Resolver manualmente" + }, + "activeIncidents": "Incidentes ativos", + "headers": { + "endTime": "Hora de término", + "resolutionType": "Tipo de resolução", + "startTime": "Hora de início", + "statusCode": "Código de status" + }, + "resolvedIncidents": "Incidentes resolvidos", + "status": { + "active": "Ativo", + "resolved": "Resolvido" + } + }, + "header": { + "title": "Incidentes", + "description": "Quando um monitor falha ou se recupera, os incidentes aparecem aqui com horários, detalhes do monitor e histórico de resolução." + } }, - "saveButtonLabel": "Salvar", - "statsSettings": { - "clearAllStatsButton": "Limpar todas as estatísticas", - "clearAllStatsDescription": "Limpe todas as estatísticas. Isso é irreversível.", - "clearAllStatsDialogConfirm": "Sim, limpar todas as estatísticas", - "clearAllStatsDialogDescription": "Uma vez removido, o histórico de monitoramento e as estatísticas não podem ser recuperados.", - "clearAllStatsDialogTitle": "Você quer limpar todas as estatísticas?", - "description": "Defina por quanto tempo você deseja manter os dados históricos. Você também pode limpar todos os dados existentes.", - "labelTTL": "Os dias em que você deseja continuar monitorando o histórico.", - "labelTTLOptional": "0 para infinito", - "title": "Histórico do monitor" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "Remover todos os monitores", - "description": "Remova todos os monitores do seu sistema.", - "dialogConfirm": "Sim, remova todos os monitores", - "dialogDescription": "Uma vez removidos, os monitores não podem ser recuperados.", - "dialogTitle": "Você deseja remover todos os monitores?", - "title": "Reinicialização do sistema" - }, - "timezoneSettings": { - "description": "Selecione o fuso horário usado para exibir datas e horas em todo o aplicativo.", - "label": "Exibir fuso horário", - "title": "Exibir fuso horário" - }, - "title": "Configurações", - "uiSettings": { - "description": "Alterne entre o modo claro e escuro ou altere o idioma da interface do usuário.", - "labelLanguage": "Linguagem", - "labelTheme": "Modo de tema", - "title": "Aparência" - }, - "urlSettings": { - "description": "Exiba o endereço IP ou URL do monitor na página de status pública. Se estiver desabilitado, apenas o nome do monitor será exibido para proteger informações confidenciais.", - "label": "Exibir IP/URL na página de status", - "selectDisabled": "Desabilitado", - "selectEnabled": "Habilitado", - "title": "Monitorar IP/URL na página de status" - }, - "globalThresholds": { - "title": "Limites Globais", - "description": "Configurar limites globais da CPU, Memória, Disco e Temperatura. Se um valor for fornecido, o monitoramento será ativado automaticamente" - } - }, - "statusPageCreate": { - "buttonSave": "Salvar" - }, - "incidentsOptionsHeaderFilterResolved": "Resolvido", - "settingsSave": "Salvar", - "statusPageCreateAppearanceTitle": "Aparência", - "confirmPassword": "Confirme sua senha", - "monitorHooks": { - "failureAddDemoMonitors": "Falha ao adicionar monitores de demonstração", - "successAddDemoMonitors": "Monitores de demonstração adicionados com sucesso" - }, - "settingsAppearance": "Aparência", - "settingsDisplayTimezone": "Exibir fuso horário", - "settingsGeneralSettings": "Configurações gerais", - "incidentsOptionsHeaderTotalIncidents": "Total de incidentes", - "statusPage": { - "deleteSuccess": "Página de status excluída com sucesso", - "deleteFailed": "Falha ao excluir a página de status", - "createSuccess": "Página de status criada com sucesso", - "updateSuccess": "Página de status atualizada com sucesso", - "generalSettings": "Configurações gerais", - "contents": "Conteúdo", - "fallback": { - "checks": [ - "Monitore e exiba a integridade dos seus serviços em tempo real", - "Acompanhe vários serviços e compartilhe seus status", - "Mantenha os usuários informados sobre interrupções e desempenho" - ], - "title": "Uma página de status é usada para:", - "actionButton": "Vamos criar sua primeira página de status!" - } - }, - "testNotificationsDisabled": "Não há notificações configuradas para este monitor. Você precisa adicionar uma clicando no botão \"Configurar\".", - "incidentsTableResolvedAt": "Resolvido em", - "incidentsTableActionResolve": "Resolver", - "checkHooks": { - "failureResolveOne": "Falha ao resolver o incidente.", - "failureResolveAll": "Falha ao resolver todos os incidentes.", - "failureResolveMonitor": "Falha ao resolver incidentes do monitor." - }, - "checkFormError": "Verifique se há erros no formulário.", - "diagnosticsPage": { - "diagnosticDescription": "Diagnóstico do sistema", - "statsDescription": "Estatísticas do sistema", - "gauges": { - "heapAllocationTitle": "Alocação de heap", - "heapAllocationSubtitle": "% de memória disponível", - "heapUsageTitle": "Uso de heap", - "heapUsageSubtitle": "% de memória disponível", - "heapUtilizationTitle": "Utilização de heap", - "heapUtilizationSubtitle": "% do alocado", - "instantCpuUsageTitle": "Uso instantâneo da CPU", - "instantCpuUsageSubtitle": "% de 1s usados pela CPU" - }, - "stats": { - "eventLoopDelayTitle": "Atraso do loop de eventos", - "uptimeTitle": "Tempo de atividade", - "usedHeapSizeTitle": "Tamanho de heap usado", - "totalHeapSizeTitle": "Tamanho total do heap", - "osMemoryLimitTitle": "Limite de memória do sistema operacional" - } - }, - "pageSpeedLighthouseAPI": "Use a API Lighthouse PageSpeed para monitorar seu site", - "time": { - "threeMinutes": "3 minutos", - "fiveMinutes": "5 minutos", - "tenMinutes": "10 minutos", - "twentyMinutes": "20 minutos", - "oneHour": "1 hora", - "oneDay": "1 dia", - "oneWeek": "1 semana", - "fourMinutes": "4 minutos", - "oneMinute": "1 minuto", - "twoMinutes": "2 minutos", - "fifteenSeconds": "", - "thirtySeconds": "" - }, - "general": { - "noOptionsFound": "Nenhuma {{unidade}} encontrada" - }, - "infrastructureMonitor": { - "fallback": { - "checks": [ - "Acompanhe o desempenho dos seus servidores", - "Identifique gargalos e otimize o uso", - "Garanta a confiabilidade com monitoramento em tempo real" - ], - "title": "Um monitor de infraestrutura é usado para:", - "actionButton": "Vamos criar seu primeiro monitor de infraestrutura!" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [ - "Marque seus períodos de manutenção", - "Elimine qualquer mal-entendido", - "Pare de enviar alertas em janelas de manutenção" - ], - "title": "Uma janela de manutenção é usada para:", - "actionButton": "Vamos criar sua primeira janela de manutenção!" - } - }, - "pageSpeed": { - "fallback": { - "checks": [ - "Relatar a experiência do usuário em uma página", - "Ajudar a analisar a velocidade da página", - "Dar sugestões sobre como a página pode ser melhorada" - ], - "title": "Um monitor PageSpeed é usado para:", - "actionButton": "Vamos criar seu primeiro monitor PageSpeed!" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [ - "Verifique se sites ou servidores estão online e responsivos", - "Alerte as equipes sobre tempo de inatividade ou problemas de desempenho", - "Monitore endpoints HTTP, pings, contêineres e portas", - "Acompanhe o histórico de tempo de atividade e tendências de confiabilidade" - ], - "title": "Um monitor de tempo de atividade é usado para:", - "actionButton": "Vamos criar seu primeiro monitor de tempo de atividade!" - } - }, - "editUserPage": { - "form": { - "email": "E-mail", - "firstName": "Primeiro nome", - "lastName": "Sobrenome", - "role": "Funções", - "save": "Salvar" - }, - "table": { - "actionHeader": "Ação", - "roleHeader": "Papel" - }, - "title": "Editar usuário", - "toast": { - "successUserUpdate": "Usuário atualizado com sucesso", - "validationErrors": "Erros de validação" - } - }, - "incidentsPageActionResolveMonitor": "Resolver incidentes de monitoramento", - "incidentsPageActionResolveAll": "Resolver todos os incidentes", - "matchMethodOptions": { - "equal": "", - "equalPlaceholder": "", - "include": "", - "includePlaceholder": "", - "regex": "Regex", - "regexPlaceholder": "", - "text": "" - }, - "monitorType": { - "docker": { - "label": "", - "namePlaceholder": "Meu Contêiner", - "placeholder": "abcd1234" - }, - "http": { - "label": "URL para monitorar", - "namePlaceholder": "Google", - "placeholder": "google.com" - }, - "ping": { - "label": "Endereço IP para monitorar", - "namePlaceholder": "Google", - "placeholder": "1.1.1.1" - }, - "port": { - "label": "URL para monitorar", - "namePlaceholder": "Localhost:5173", - "placeholder": "localhost" - }, - "game": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "" - }, - "bytesPerSecond": "", - "bytesReceived": "", - "bytesSent": "", - "chooseGame": "", - "createMonitorPage": { - "incidentConfigDescription": "", - "incidentConfigStatusWindowLabel": "", - "incidentConfigStatusWindowThresholdLabel": "", - "incidentConfigTitle": "", - "incidentConfigDescriptionV2": "", - "incidentConfigStatusCheckNumber": "", - "intervalTitle": "", - "intervalDescription": "" - }, - "dataRate": "", - "dataReceived": "", - "dataSent": "", - "details": "", - "drops": "", - "errors": "", - "errorsIn": "", - "errorsOut": "", - "gameServerMonitoring": "", - "gameServerMonitoringDescription": "", - "network": "", - "networkDrops": "", - "networkErrors": "", - "networkInterface": "", - "noNetworkStatsAvailable": "", - "packetsPerSecond": "", - "packetsReceived": "", - "packetsReceivedRate": "", - "packetsSent": "", - "rate": "", - "selectInterface": "", - "v1": { "infrastructure": { - "disk_selection_title": "Seleção de Discos", - "disk_selection_info": "Nenhum disco detectado no momento.", - "disk_selection_description": "Selecione os discos específicos que você deseja monitorar." + "charts": { + "labels": { + "cpu": "Uso de CPU", + "disk": "Uso de disco", + "memory": "Uso de memória", + "netBytesRecv": "{{name}} - Bytes recebidos", + "netBytesSent": "{{name}} - Bytes enviados", + "netNoTraffic": "Capture está reportando zero tráfego em todas as interfaces. Isso é normal quando Capture é executado dentro de um container que só consegue ver interfaces de loopback — execute Capture diretamente no host para ver a atividade real da rede.", + "temp": "Temp" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "Frequência máxima", + "title": "Uso de CPU", + "upperLabel": "Frequência atual" + }, + "disk": { + "lowerLabel": "Livre", + "title": "Uso do disco {{idx}}", + "upperLabel": "Usado" + }, + "memory": { + "lowerLabel": "Livre", + "title": "Uso de memória", + "upperLabel": "Usado" + } + }, + "statBoxes": { + "avgCpuTemperature": "Temperatura média da CPU", + "cpuFrequency": "Frequência da CPU", + "cpuLogical": "CPU (Lógica)", + "cpuPhysical": "CPU (Física)", + "disk": "Disco", + "memory": "Memória", + "os": "SO" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "Disco", + "memory": "Memória" + } + }, + "tabs": { + "labels": { + "network": "", + "overview": "Visão geral" + } + }, + "fallback": { + "actionButton": "Criar um monitor!", + "title": "Ainda não há monitores de infraestrutura", + "description": "Acompanhe o desempenho dos servidores, identifique gargalos e garanta confiabilidade com monitoramento em tempo real." + }, + "header": { + "title": "Monitores de infraestrutura", + "description": "Acompanhe o desempenho dos servidores, identifique gargalos e garanta confiabilidade com monitoramento em tempo real." + } + }, + "logs": { + "fallback": { + "title": "Ainda não há logs", + "description": "Atividade do servidor, avisos e erros aparecerão aqui enquanto o Checkmate roda." + }, + "tabs": { + "diagnostics": "Diagnóstico", + "logs": "Logs do servidor", + "queue": "Fila de tarefas" + }, + "logLevelSelect": { + "label": "Nível de log" + }, + "jobQueue": "Fila de tarefas", + "jobQueueExplainer": "As linhas destacadas são as tarefas que estão sendo processadas atualmente por um worker.", + "failedJobs": "Tarefas com falha", + "failedJobsExplainer": "Falhas recentes por monitor — horário da última falha, contagem de falhas e o motivo.", + "noLogs": "Nenhum log encontrado", + "metrics": { + "jobs": "Tarefas", + "activeJobs": "Tarefas ativas", + "failingJobs": "Tarefas com falha", + "totalRuns": "Total de execuções", + "totalFailures": "Total de falhas" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "Atraso do event loop", + "uptime": "Uptime", + "usedHeapSize": "Tamanho do heap usado", + "totalHeapSize": "Tamanho total do heap", + "osMemoryLimit": "Limite de memória do SO" + }, + "gauges": { + "heapAllocation": "Alocação de heap", + "heapUsage": "Uso do heap", + "heapUtilization": "Utilização do heap", + "instantCpuUsage": "Uso instantâneo de CPU", + "availableMemoryPercentage": "% de memória disponível", + "allocatedPercentage": "% alocado", + "usedSPercentage": "% de 1s usado pela CPU", + "total": "Total", + "used": "Usado" + }, + "sections": { + "runtimeStats": { + "title": "Estatísticas de execução", + "description": "Snapshot do processo Node.js e da máquina host. Atualiza a cada 5 segundos." + }, + "memoryCpu": { + "title": "Memória e CPU", + "description": "Como o processo está usando a memória alocada e a CPU do host." + } + } + }, + "table": { + "headers": { + "timestamp": "Data/hora", + "level": "Nível", + "service": "Serviço", + "method": "Método", + "monitorId": "ID do monitor", + "runCount": "Contagem de execuções", + "failCount": "Contagem de falhas", + "lastRunAt": "Última execução em", + "lockedAt": "Bloqueado em", + "lastFinishedAt": "Última conclusão em", + "lastRunTook": "Última execução durou", + "lastFailedAt": "Última falha em", + "failReason": "Motivo da falha" + } + }, + "queueFallback": { + "title": "Sem tarefas na fila", + "description": "Verificações de monitores agendadas ou em execução aparecerão aqui. Uma fila vazia significa que tudo está em dia." + }, + "header": { + "title": "Logs e fila", + "description": "Inspecione logs do servidor, fila de tarefas de monitor e diagnósticos de runtime em um só lugar." + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "Criar uma janela de manutenção!", + "title": "Nenhuma janela de manutenção agendada", + "description": "Programe paradas planejadas para que os alertas sejam suprimidos e sua equipe e usuários saibam o que esperar." + }, + "table": { + "headers": { + "nextWindow": "Próxima janela", + "repeat": "Repetir" + } + }, + "form": { + "general": { + "title": "Configurações gerais", + "description": "Defina um nome e a opção de repetição para sua janela de manutenção.", + "option": { + "name": { + "label": "Nome", + "placeholder": "ex. Manutenção semanal" + }, + "repeat": { + "label": "Repetir" + } + } + }, + "startDate": { + "title": "Data de início", + "description": "Selecione a data de início da sua janela de manutenção.", + "option": { + "startDate": { + "label": "Data de início" + } + } + }, + "startTime": { + "title": "Hora de início", + "description": "Defina a hora de início e a duração da sua janela de manutenção. Todos os valores estão em UTC", + "option": { + "duration": { + "label": "Duração" + }, + "startTime": { + "label": "Hora de início" + } + }, + "monitors": { + "title": "Monitores", + "description": "Monitores aos quais a janela de manutenção deve ser aplicada", + "option": { + "addMonitors": { + "label": "Adicionar monitores" + } + } + } + } + }, + "header": { + "title": "Janelas de manutenção", + "description": "Programe paradas planejadas para que os alertas sejam suprimidos e sua equipe e usuários saibam o que esperar." + } + }, + "notifications": { + "fallback": { + "actionButton": "Criar um canal", + "title": "Ainda não há canais de notificação", + "description": "Conecte Slack, Discord, e-mail, webhooks e outros para que as pessoas certas saibam dos incidentes assim que acontecerem." + }, + "form": { + "accessToken": { + "optionAccessToken": "Token de acesso", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "O endereço para onde as notificações serão enviadas.", + "optionAddress": "Endereço", + "placeholderEmail": "exemplo@exemplo.com", + "placeholderWebhook": "https://seu-servidor.com/webhook", + "title": "Endereço" + }, + "homeServer": { + "optionHomeServer": "Servidor principal", + "placeholder": "exemplo.com" + }, + "matrix": { + "description": "Configure a conexão do seu servidor Matrix para notificações.", + "title": "Configuração do Matrix" + }, + "notificationName": { + "description": "Um nome descritivo para o canal de notificação", + "optionName": "Nome do canal", + "placeholder": "ex. Alertas de produção", + "title": "Nome do canal" + }, + "pagerDuty": { + "description": "Sua chave de integração do PagerDuty para receber alertas.", + "optionIntegrationKey": "Chave de integração", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "Chave de integração" + }, + "roomId": { + "optionRoomId": "ID da sala", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "Selecione o tipo de canal de notificação a ser criado.", + "optionType": "Tipo", + "title": "Tipo de canal" + }, + "telegram": { + "title": "Configuração do Telegram", + "description": "Para habilitar as notificações do Telegram, crie um bot do Telegram usando o BotFather, um bot oficial para criar e gerenciar bots do Telegram. Em seguida, obtenha o token da API e o ID do chat e os coloque aqui.", + "optionBotToken": "Token do bot", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "ID do chat", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Configuração do Pushover", + "description": "Insira o token de aplicação e a chave de usuário do Pushover.", + "optionAppToken": "Token de aplicação", + "placeholderAppToken": "Insira o token de aplicação", + "optionUserKey": "Chave de usuário", + "placeholderUserKey": "Insira a chave de usuário" + }, + "twilio": { + "title": "Configuração do Twilio", + "description": "Insira as credenciais da conta Twilio para enviar SMS.", + "optionAccountSid": "SID da conta", + "placeholderAccountSid": "Insira o SID da conta", + "optionAuthToken": "Token de autenticação", + "placeholderAuthToken": "Insira o token de autenticação", + "optionFromNumber": "Número de origem", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "Número de destino", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "Destino" + } + }, + "header": { + "title": "Notificações", + "description": "Conecte Slack, Discord, e-mail, webhooks e outros para que as pessoas certas saibam dos incidentes assim que acontecerem." + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "Mudança cumulativa de layout (CLS)", + "fcp": "Primeira exibição de conteúdo (FCP)", + "lcp": "Maior exibição de conteúdo (LCP)", + "si": "Índice de velocidade (SI)", + "tbt": "Tempo total de bloqueio (TBT)" + }, + "legend": { + "title": "Relatório PageSpeed", + "weight": "Peso" + }, + "pie": { + "title": "Relatório de desempenho" + } + }, + "table": { + "headers": { + "pageSpeedScore": "Pontuação PageSpeed" + } + }, + "fallback": { + "actionButton": "Criar um monitor!", + "title": "Ainda não há 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." + }, + "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." + } + }, + "settings": { + "form": { + "timezone": { + "title": "Fuso horário de exibição", + "description": "Selecione o fuso horário usado para exibir datas e horários em toda a aplicação.", + "option": { + "timezone": { + "label": "Fuso horário de exibição" + } + } + }, + "ui": { + "title": "Aparência", + "description": "Alterne entre modo claro e escuro, mude o idioma ou personalize o tipo de gráfico.", + "option": { + "theme": { + "label": "Modo do tema", + "light": "Claro", + "dark": "Escuro" + }, + "language": { + "label": "Idioma" + }, + "chartType": { + "label": "Tipo de gráfico", + "histogram": "Histograma", + "heatmap": "Mapa de calor" + } + } + }, + "pagespeed": { + "title": "Chave de API do Google PageSpeed", + "description": "Digite sua chave de API do Google PageSpeed para ativar o monitoramento PageSpeed. Clique em Resetar para atualizar a chave.", + "option": { + "apiKey": { + "label": "Chave de API PageSpeed", + "labelSet": "Chave de API definida. Clique em Resetar para alterá-la.", + "placeholder": "Digite sua chave de API do Google PageSpeed" + } + } + }, + "url": { + "title": "IP/URL do monitor na página de status", + "description": "Exiba o endereço IP ou a URL do monitor na página de status pública. Se desativado, apenas o nome do monitor será exibido para proteger informações sensíveis.", + "option": { + "showURL": { + "label": "Exibir IP/URL na página de status", + "enabled": "Ativado", + "disabled": "Desativado" + } + } + }, + "stats": { + "title": "Histórico do monitor", + "description": "Limpe todo o histórico de monitoramento e estatísticas da sua equipe. Esta ação é irreversível.", + "option": { + "clear": { + "label": "Limpar todas as estatísticas. Isso é irreversível." + } + }, + "dialog": { + "title": "Limpar todo o histórico de monitoramento?", + "description": "Isso não pode ser desfeito" + } + }, + "retention": { + "title": "Retenção de verificações", + "description": "Defina por quanto tempo os dados de verificação são retidos antes de serem limpos automaticamente.", + "option": { + "days": { + "label": "Período de retenção (dias)", + "unlimited": "Ilimitado" + } + } + }, + "thresholds": { + "title": "Limites globais", + "description": "Defina limites globais de CPU, memória, disco e temperatura para monitoramento de infraestrutura. Eles se aplicam a todos os monitores de hardware, a menos que sejam substituídos.", + "option": { + "cpu": { + "label": "Limite de CPU (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "Limite de memória (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "Limite de disco (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "Limite de temperatura (°C)", + "placeholder": "1 - 150" + } + } + }, + "email": { + "title": "Configurações de email", + "description": "Configure as configurações de email do seu sistema. Isso é usado para enviar notificações e alertas.", + "descriptionTransport": "Isso cria um transporte SMTP para o NodeMailer", + "descriptionTransportLink": "Veja as especificações aqui", + "option": { + "host": { + "label": "Host de email - Nome do host ou endereço IP para conectar", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "Porta de email - Porta para conectar", + "placeholder": "587" + }, + "address": { + "label": "Endereço de email - Usado para autenticação", + "placeholder": "voce@exemplo.com" + }, + "user": { + "label": "Usuário de email - Nome de usuário para autenticação, substitui o endereço de email se especificado", + "placeholder": "Deixe vazio se não for necessário" + }, + "password": { + "label": "Senha de email - Senha para autenticação", + "labelSet": "Senha definida. Clique em Resetar para alterá-la.", + "placeholder": "Digite sua senha" + }, + "tlsServername": { + "label": "Nome do servidor TLS - Nome de host opcional para validação TLS quando o host é um IP", + "placeholder": "exemplo.com" + }, + "connectionHost": { + "label": "Host de conexão de email - Nome de host para usar na saudação HELO/EHLO", + "placeholder": "localhost" + }, + "secure": { + "label": "Usar SSL (recomendado): Criptografar a conexão usando SSL/TLS" + }, + "pool": { + "label": "Ativar pool de conexões: Reutilizar conexões existentes para melhorar o desempenho" + }, + "ignoreTLS": { + "label": "Desativar STARTTLS: Não usar TLS mesmo que o servidor suporte" + }, + "requireTLS": { + "label": "Forçar STARTTLS: Exigir atualização TLS, falhar se não suportado" + }, + "rejectUnauthorized": { + "label": "Rejeitar certificados inválidos: Rejeitar conexões com certificados autoassinados ou não confiáveis" + } + } + }, + "demoMonitors": { + "title": "Monitores demo", + "description": "Adicione monitores de exemplo para fins de demonstração." + }, + "removeMonitors": { + "title": "Redefinir sistema", + "description": "Remover todos os monitores do seu sistema.", + "dialog": { + "title": "Remover todos os monitores?", + "description": "Esta ação não pode ser desfeita.", + "paragraph": "Todo o histórico de verificações, incidentes, janelas de manutenção agendadas e páginas de status que usavam esses monitores também serão removidos. Exporte os monitores antes se quiser manter uma cópia de segurança dos dados." + } + }, + "exportMonitors": { + "title": "Exportar monitores" + }, + "importExportMonitors": { + "title": "Importar / Exportar monitores", + "description": "Importe ou exporte os dados dos seus monitores como um arquivo JSON para backup ou transferência." + }, + "about": { + "title": "Sobre", + "developedBy": "Desenvolvido por Bluewave Labs" + }, + "validation": { + "errorMessage": "Por favor, corrija os seguintes erros de validação:" + } + }, + "header": { + "title": "Configurações", + "description": "Configure SMTP, integrações, retenção e comportamento global do app." + } + }, + "statusPages": { + "deleteSuccess": "Página de status excluída com sucesso", + "fallback": { + "title": "Ainda não há páginas de status", + "actionButton": "Criar uma página de status!", + "description": "Publique uma página pública que mostre aos seus clientes e partes interessadas o tempo de atividade em tempo real e o histórico de incidentes." + }, + "monitorsList": { + "chartTypeHeatmap": "Mapa de calor", + "chartTypeHistogram": "Histograma", + "noData": "Nenhum dado disponível", + "infrastructure": { + "title": "Infraestrutura", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "Memória", + "disk": "Disco", + "usage": "Uso", + "used": "Usado", + "total": "Total" + }, + "uptime": { + "title": "Uptime", + "responseTime": "Tempo de resposta" + } + }, + "statusBar": { + "allDown": "Todos os sistemas estão fora do ar", + "allUp": "Todos os sistemas operacionais", + "degraded": "Alguns sistemas estão com problemas", + "unknown": "Não foi possível determinar o status do sistema", + "allBreached": "Todos os sistemas excederam os limites", + "allMaintenance": "Todos os sistemas estão em manutenção", + "allPaused": "Todos os sistemas estão pausados", + "allInitializing": "Todos os sistemas estão inicializando", + "breachedAndDown": "Alguns sistemas excederam os limites e estão fora do ar", + "breached": "Alguns sistemas excederam os limites", + "maintenanceAndDown": "Alguns sistemas estão fora do ar durante a manutenção", + "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" + }, + "table": { + "headers": { + "name": "Nome da página de status", + "url": "URL pública" + }, + "published": "Publicada", + "unpublished": "Não publicada" + }, + "title": "Páginas de status", + "details": { + "empty": { + "title": "Ainda não há nada aqui", + "addMonitor": "Adicione um monitor para começar" + } + }, + "form": { + "access": { + "title": "Acesso", + "description": "Se sua página de status estiver pronta, você pode marcá-la como publicada.", + "option": { + "published": { + "name": "Publicada e visível ao público" + } + } + }, + "basicInfo": { + "title": "Informações básicas", + "description": "Defina o nome da empresa e o subdomínio para onde sua página de status aponta.", + "option": { + "name": { + "label": "Nome da empresa", + "placeholder": "Acme Inc." + }, + "url": { + "label": "Endereço da sua página de status", + "placeholder": "minha-pagina-de-status" + } + } + }, + "monitors": { + "title": "Monitores", + "description": "Selecione os monitores para exibir na sua página de status.", + "noMonitors": "Nenhum monitor selecionado", + "option": { + "monitors": { + "label": "Selecionar monitores", + "placeholder": "Buscar e selecionar monitores..." + } + } + }, + "timezone": { + "title": "Fuso horário", + "description": "Selecione o fuso horário em que sua página de status será exibida.", + "option": { + "timezone": { + "label": "Fuso horário", + "placeholder": "Selecione o fuso horário..." + } + } + }, + "appearance": { + "title": "Aparência", + "description": "Defina a aparência padrão da sua página de status pública.", + "option": { + "logo": { + "label": "Logo" + }, + "color": { + "label": "Cor da marca" + } + } + }, + "features": { + "title": "Recursos", + "description": "Configure quais informações são exibidas na sua página de status.", + "option": { + "showCharts": { + "label": "Mostrar gráficos de tempo de resposta" + }, + "showUptimePercentage": { + "label": "Mostrar porcentagem de uptime" + }, + "showAdminLoginLink": { + "label": "Mostrar link de login do admin" + }, + "showInfrastructure": { + "label": "Mostrar métricas de infraestrutura" + } + } + } + }, + "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." + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "Buscar monitores..." + } + }, + "table": { + "headers": { + "responseTime": "Tempo de resposta" + } + }, + "fallback": { + "actionButton": "Criar um monitor!", + "title": "Ainda não há monitores de uptime", + "description": "Acompanhe endpoints HTTP, pings, contêineres e portas — receba alertas no momento em que algo falhar." + }, + "header": { + "title": "Monitores de uptime", + "description": "Acompanhe endpoints HTTP, pings, contêineres e portas — receba alertas no momento em que algo falhar." + } } } } diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index ab40ecb0d..c6282e75a 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -1,1140 +1,1447 @@ { - "submit": "Подтвердить", - "title": "Название", - "distributedStatusHeaderText": "Охват реального времени и реального устройства", - "distributedStatusSubHeaderText": "Работает на миллионах устройств по всему миру, просматривайте производительность системы по глобальному региону, стране или городу", - "settingsDisabled": "Выключено", - "settingsSuccessSaved": "Настройки успешно сохранены", - "settingsFailedToSave": "Не удалось сохранить настройки", - "settingsStatsCleared": "Статистика успешно очищена", - "settingsFailedToClearStats": "Не удалось очистить статистику", - "settingsMonitorsDeleted": "Успешно удалены все мониторы", - "settingsFailedToDeleteMonitors": "Не удалось удалить все мониторы", - "starPromptTitle": "Запустить Checkmate.", - "starPromptDescription": "Ознакомьтесь с последними релизами и помогите развить сообщество на GitHub", - "https": "HTTPS", - "http": "HTTP", - "monitor": "монитор", - "aboutus": "О Нас", - "now": "Сейчас", - "delete": "Удалить", - "configure": "Настроить", - "responseTime": "Время ответа:", - "ms": "мс", - "bar": "Bar", - "area": "Area", - "country": "СТРАНА", - "city": "ГОРОД", - "response": "ОТВЕТ", - "monitorStatusUp": "Монитор {name} ({url}) теперь включен и отвечает", - "monitorStatusDown": "Монитор {name} ({url}) ОТКЛЮЧЕН и не отвечает", - "webhookSendSuccess": "Уведомление Webhook успешно отправлено", - "webhookSendError": "Ошибка отправки уведомления webhook на {platform}", - "webhookUnsupportedPlatform": "Неподдерживаемая платформа: {platform}", - "distributedRightCategoryTitle": "Монитор", - "distributedStatusServerMonitors": "Серверные мониторы", - "distributedStatusServerMonitorsDescription": "Мониторинг состояния связанных серверов", - "distributedUptimeCreateSelectURL": "Здесь вы можете выбрать URL-адрес хоста, а также тип монитора.", - "distributedUptimeCreateChecks": "Проверки, которые необходимо выполнить", - "distributedUptimeCreateChecksDescription": "Вы всегда можете добавить или удалить проверки после добавления своего сайта.", - "distributedUptimeCreateIncidentNotification": "Уведомления об инцидентах", - "distributedUptimeCreateIncidentDescription": "В случае возникновения инцидента сообщите об этом пользователям.", - "distributedUptimeCreateAdvancedSettings": "Расширенные настройки", - "distributedUptimeDetailsNoMonitorHistory": "Для этого монитора пока нет истории проверок.", - "distributedUptimeDetailsStatusHeaderUptime": "Аптайм:", - "distributedUptimeDetailsStatusHeaderLastUpdate": "Последнее обновление", - "notifications": { - "enableNotifications": "Включить уведомления {{platform}}", - "testNotification": "Тестовое уведомление", - "addOrEditNotifications": "Добавить или изменить уведомления", - "slack": { - "label": "Slack", - "description": "Чтобы включить уведомления Slack, создайте приложение Slack и включите входящие вебхуки. После этого просто укажите URL вебхука здесь.", - "webhookLabel": "URL вебхука", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "Требуется URL-адрес Slack webhook" - }, - "discord": { - "label": "Discord", - "description": "Чтобы отправить данные на канал Discord из Checkmate через уведомления Discord с использованием веб-хуков, вы можете использовать функцию входящих веб-хуков Discord.", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "Требуется URL-адрес веб-сайта Discord webhook" - }, - "telegram": { - "label": "Telegram", - "description": "Чтобы включить уведомления Telegram, создайте бота Telegram с помощью BotFather, официального бота для создания и управления ботами Telegram. Затем получите токен API и идентификатор чата и запишите их здесь.", - "tokenLabel": "Ваш токен бота", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "ID Вашего Чата", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "Требуется токен Telegram и ID чата" - }, - "webhook": { - "label": "Вебхуки", - "description": "Вы можете настроить пользовательский вебхук для получения уведомлений о возникновении инцидентов.", - "urlLabel": "URL вебхука", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "Требуется URL-адрес webhook" - }, - "testNotificationDevelop": "Тестовое уведомление 2", - "integrationButton": "Интеграция уведомлений", - "testSuccess": "Уведомление о тестировании отправлено успешно!", - "testFailed": "Не удалось отправить тестовое уведомление", - "unsupportedType": "Неподдерживаемый тип уведомления", - "networkError": "Произошла сетевая ошибка", - "fallback": { - "title": "Канал уведомлений используется для:", - "checks": [ - "Дайте знать команде, если сервер ложится или что-то виснет", - "Сообщите инженерам, когда появляется какая-то проблема", - "Уведомляйте админов, если в системе что-то поменялось" - ], - "actionButton": "Давайте создадим ваш первый канал уведомлений!" - }, - "createButton": "Создать канал уведомлений", - "createTitle": "Канал уведомлений", - "create": { - "success": "Уведомление успешно создано", - "failed": "Не удалось создать уведомление." - }, - "fetch": { - "success": "Уведомления успешно получены", - "failed": "Не удалось получить уведомления." - }, - "delete": { - "success": "Уведомление успешно удалено", - "failed": "Не удалось удалить уведомление." - }, - "edit": { - "success": "Уведомление успешно обновлено", - "failed": "Не удалось обновить уведомление." - }, - "test": { - "success": "Тестовое уведомление успешно отправлено", - "failed": "Не удалось отправить тестовое уведомление." - } - }, - "testLocale": "testLocale", - "add": "Добавить", - "monitors": "мониторы", - "distributedUptimeStatusCreateStatusPage": "страница статуса", - "distributedUptimeStatusCreateStatusPageAccess": "Доступ", - "distributedUptimeStatusCreateStatusPageReady": "Если ваша страница статуса готова, вы можете отметить ее как опубликованную.", - "distributedUptimeStatusBasicInfoHeader": "Основная информация", - "distributedUptimeStatusBasicInfoDescription": "Определите название компании и поддомен, на который ссылается ваша страница статуса.", - "distributedUptimeStatusLogoHeader": "Логотип", - "distributedUptimeStatusLogoDescription": "Загрузите логотип для своей страницы статуса", - "distributedUptimeStatusLogoUploadButton": "Загрузить логотип", - "distributedUptimeStatusStandardMonitorsHeader": "Стандартные Мониторы", - "distributedUptimeStatusStandardMonitorsDescription": "Прикрепите стандартные мониторы к своей странице статуса.", - "distributedUptimeStatusCreateYour": "Создайте свой", - "distributedUptimeStatusEditYour": "Отредактируйте свой", - "distributedUptimeStatusPublishedLabel": "Опубликовано и доступно для общественности", - "distributedUptimeStatusCompanyNameLabel": "Название компании", - "distributedUptimeStatusPageAddressLabel": "Адрес вашей страницы статуса", - "distributedUptimeStatus30Days": "30 дней", - "distributedUptimeStatus60Days": "60 дней", - "distributedUptimeStatus90Days": "90 дней", - "distributedUptimeStatusPageNotSetUp": "Страница статуса не настроена.", - "distributedUptimeStatusContactAdmin": "Пожалуйста, свяжитесь с вашим администратором", - "distributedUptimeStatusPageNotPublic": "Эта страница статуса не является публичной.", - "distributedUptimeStatusPageDeleteDialog": "Вы хотите удалить эту страницу статуса?", - "distributedUptimeStatusPageDeleteConfirm": "Да, удалить страницу статуса", - "distributedUptimeStatusPageDeleteDescription": "После удаления ваша страница статуса не может быть восстановлена.", - "distributedUptimeStatusDevices": "Устройства", - "distributedUptimeStatusUpt": "UPT", - "distributedUptimeStatusUptBurned": "UPT Burned", - "distributedUptimeStatusUptLogo": "Upt Logo", - "incidentsTableNoIncidents": "Инцидентов не зафиксировано", - "incidentsTablePaginationLabel": "инциденты", - "incidentsTableMonitorName": "Имя Монитора", - "incidentsTableStatus": "Статус", - "incidentsTableDateTime": "Дата и Время", - "incidentsTableStatusCode": "Код Статуса", - "incidentsTableMessage": "Сообщение", - "incidentsOptionsHeader": "Инцидент для:", - "incidentsOptionsHeaderFilterBy": "Фильтровать по:", - "incidentsOptionsHeaderFilterAll": "Все", - "incidentsOptionsHeaderFilterDown": "Недоступно", - "incidentsOptionsHeaderFilterCannotResolve": "Невозможно решить", - "incidentsOptionsHeaderShow": "Показать:", - "incidentsOptionsHeaderLastHour": "Последний час", - "incidentsOptionsHeaderLastDay": "Последний день", - "incidentsOptionsHeaderLastWeek": "Последняя неделя", - "incidentsOptionsPlaceholderAllServers": "Все сервера", - "infrastructureCreateYour": "Создайте свой", - "infrastructureCreateGeneralSettingsDescription": "Здесь вы можете выбрать URL-адрес хоста, а также понятное имя и секретный ключ авторизации для подключения к агенту сервера.", - "infrastructureServerRequirement": "Сервер, который вы отслеживаете, должен быть запущен", - "infrastructureCustomizeAlerts": "Настройте оповещения", - "infrastructureAlertNotificationDescription": "Отправлять уведомления пользователям, когда пороговые значения превышают указанный процент.", - "infrastructureCreateMonitor": "Создать Монитор Инфраструктуры", - "infrastructureProtocol": "Протокол", - "infrastructureServerUrlLabel": "URL Сервера", - "infrastructureDisplayNameLabel": "Отображаемое имя", - "infrastructureAuthorizationSecretLabel": "Секрет авторизации", - "gb": "ГБ", - "mb": "МБ", - "mem": "Mem", - "memoryUsage": "Использование памяти", - "cpu": "CPU", - "cpuUsage": "Использование CPU", - "cpuTemperature": "Температура CPU", - "diskUsage": "Использование диска", - "used": "Использовал", - "total": "Всего", - "cores": "Ядра", - "frequency": "Частота", - "status": "Статус", - "cpuPhysical": "CPU (физический)", - "cpuLogical": "CPU (логический)", - "cpuFrequency": "Частота CPU", - "avgCpuTemperature": "Средняя температура CPU", - "memory": "Память", - "disk": "Диск", - "uptime": "Аптайм", - "os": "ОС", - "host": "Хост", - "actions": "Действия", - "integrations": "Интеграции", - "integrationsPrism": "Подключите Prism к вашему любимому сервису.", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "Подключитесь к Slack и просматривайте инциденты в канале", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "Подключитесь к Discord и просматривайте инциденты прямо в канале", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "Отправляйте все инциденты в Zapier, и тогда вы сможете видеть их везде", - "commonSave": "Сохранить", - "createYour": "Создать свой", - "createMonitor": "Создать монитор", - "pause": "Пауза", - "resume": "Продолжить", - "editing": "Редактирование...", - "url": "URL", - "access": "Доступ", - "timezone": "Часовой пояс", - "features": "Функции", - "administrator": "Администратор?", - "loginHere": "Войти здесь", - "displayName": "Отображаемое имя", - "urlMonitor": "URL для монитора", - "portToMonitor": "Порт для монитора", - "websiteMonitoring": "Мониторинг сайта", - "websiteMonitoringDescription": "Используйте HTTP(s) для вашего веб-сайта или API-endpoint.", - "pingMonitoring": "Ping мониторинг", - "pingMonitoringDescription": "Проверьте, доступен ли ваш сервер.", - "dockerContainerMonitoring": "Мониторинг Docker-контейнера", - "dockerContainerMonitoringDescription": "Проверьте, запущен ли ваш Docker-контейнер.", - "portMonitoring": "Мониторинг портов", - "portMonitoringDescription": "Проверьте, открыт ли ваш порт.", - "createMaintenanceWindow": "Создать окно техобслуживания", - "createMaintenance": "Создание техобслуживания", - "editMaintenance": "Редактировать техобслуживание", - "maintenanceWindowName": "Название окна техобслуживания", - "friendlyNameInput": "Дружественное имя", - "friendlyNamePlaceholder": "Техническое обслуживание в __ : __ в течение ___ минут", - "maintenanceRepeat": "Повторное техническое обслуживание", - "maintenance": "техобслуживание", - "duration": "Продолжительность", - "addMonitors": "Добавить мониторы", - "window": "окно", - "cancel": "Отмена", - "message": "Сообщение", - "low": "низкий", - "high": "высокий", - "statusCode": "Код статуса", - "date&Time": "Дата и Время", - "type": "Тип", - "statusPageName": "Имя страница статуса", - "publicURL": "Публичный URL", - "repeat": "Повторить", - "edit": "Редактировать", - "createA": "Создать", - "remove": "Удалить", - "maintenanceWindowDescription": "Ваши запросы не будут отправляться в этот период времени.", - "startTime": "Время начала", - "timeZoneInfo": "Все даты и время указаны в часовом поясе GMT+0.", - "monitorsToApply": "Мониторы, к которым применяется окно техобслуживания", - "nextWindow": "Следующее окно", - "notFoundButton": "Перейти на главную панель управления", - "pageSpeedConfigureSettingsDescription": "Здесь вы можете выбрать URL-адрес хоста, а также тип монитора.", - "monitorDisplayName": "Отображаемое имя монитора", - "whenNewIncident": "Когда происходит новый инцидент,", - "notifySMS": "Уведомить по SMS (скоро)", - "notifyEmails": "Также уведомлять по электронной почте на несколько адресов (скоро)", - "seperateEmails": "Вы можете разделить несколько адресов электронной почты запятой.", - "checkFrequency": "Проверить частоту", - "matchMethod": "Метод сопоставления", - "expectedValue": "Ожидаемое значение", - "deleteDialogTitle": "Вы действительно хотите удалить этот монитор?", - "deleteDialogDescription": "После удаления этот монитор не может быть восстановлен.", - "pageSpeedMonitor": "PageSpeed ​​монитор", - "shown": "Показано", - "ago": "назад", - "companyName": "Название компании", - "pageSpeedDetailsPerformanceReport": "Значения являются приблизительными и могут отличаться.", - "pageSpeedDetailsPerformanceReportCalculator": "Посмотреть калькулятор", - "checkingEvery": "Проверка каждого", - "statusPageCreateSettings": "Если ваша страница статуса готова, вы можете отметить ее как опубликованную.", - "basicInformation": "Основная информация", - "statusPageCreateBasicInfoDescription": "Определите название компании и поддомен, на который ссылается ваша страница статуса.", - "statusPageCreateSelectTimeZoneDescription": "Выберите часовой пояс, в котором будет отображаться ваша страница статуса.", - "statusPageCreateAppearanceDescription": "Определите внешний вид и содержание вашей публичной страницы статуса по умолчанию.", - "statusPageCreateSettingsCheckboxLabel": "Опубликовано и доступно для общественности", - "statusPageCreateBasicInfoStatusPageAddress": "Адрес вашей страницы статуса", - "statusPageCreateTabsContent": "Серверы страниц состояния", - "statusPageCreateTabsContentDescription": "Вы можете добавить любое количество серверов, которые вы отслеживаете, на свою страницу статуса. Вы также можете изменить их порядок для лучшего просмотра.", - "statusPageCreateTabsContentFeaturesDescription": "Показать больше подробностей на странице статуса", - "showCharts": "Показать графики", - "showUptimePercentage": "Показать процент работоспособности", - "removeLogo": "Удалить логотип", - "statusPageStatus": "Публичная страница статуса не создана.", - "statusPageStatusContactAdmin": "Пожалуйста, свяжитесь с вашим администратором.", - "statusPageStatusNotPublic": "Эта страница статуса не является публичной.", - "statusPageStatusNoPage": "Здесь нет страницы статуса.", - "statusPageStatusServiceStatus": "Статус услуги", - "deleteStatusPage": "Вы хотите удалить эту страницу статуса?", - "deleteStatusPageConfirm": "Да, удалить страницу статуса", - "deleteStatusPageDescription": "После удаления ваша страница статуса не может быть восстановлена.", - "uptimeCreate": "Ожидаемое значение используется для сопоставления с результатом ответа, и совпадение определяет статус.", - "uptimeCreateJsonPath": "Это выражение будет оценено по данным ответа JSON, а результат будет использован для сопоставления с ожидаемым значением. См.", - "uptimeCreateJsonPathQuery": "для документации по языку запросов.", - "maintenanceTableActionMenuDialogTitle": "Вы действительно хотите удалить это окно обслуживания?", - "infrastructureEditYour": "Отредактируйте свой", - "infrastructureEditMonitor": "Сохранить Монитор Инфраструктуры", - "infrastructureMonitorCreated": "Монитор инфраструктуры успешно создан!", - "infrastructureMonitorUpdated": "Монитор инфраструктуры успешно обновлен!", - "errorInvalidTypeId": "Указанный неверный тип уведомления", - "errorInvalidFieldId": "Указан неверный идентификатор поля", - "inviteNoTokenFound": "Токен приглашения не найден", - "pageSpeedWarning": "Внимание: Вы еще не добавили API-ключ Google PageSpeed. Без него PageSpeed монитор не будет работать.", - "pageSpeedLearnMoreLink": "Нажми здесь", - "pageSpeedAddApiKey": "чтобы добавить свой API-ключ.", - "update": "Обновить", - "invalidFileFormat": "Неподдерживаемый формат файла!", - "invalidFileSize": "Размер файла слишком велик!", - "ClickUpload": "Нажмите, чтобы загрузить", - "DragandDrop": "drag and drop", - "MaxSize": "Максимальный размер", - "SupportedFormats": "Поддерживаемые форматы", - "FirstName": "Имя", - "LastName": "Фамилия", - "EmailDescriptionText": "Это ваш текущий адрес электронной почты — он не может быть изменен.", - "YourPhoto": "Фотография профиля", - "PhotoDescriptionText": "Эта фотография будет отображена на странице вашего профиля.", - "save": "Сохранить", - "DeleteDescriptionText": "Это приведет к удалению учетной записи и всех связанных с ней данных с сервера. Это необратимо.", - "DeleteAccountWarning": "Удаление вашей учетной записи означает, что вы не сможете снова войти в систему, и все ваши данные будут удалены. Это необратимо.", - "DeleteWarningTitle": "Действительно удаляете эту учетную запись?", - "bulkImport": { - "title": "Массовый импорт", - "selectFileTips": "Выберите CSV-файл для загрузки", - "selectFileDescription": "Вы можете скачать наш или образец", - "selectFile": "Выберите файл", - "parsingFailed": "Не удалось выполнить синтаксический анализ", - "uploadSuccess": "Мониторы успешно созданы!", - "validationFailed": "Не удалось выполнить проверку", - "noFileSelected": "Файл не выбран", - "fallbackPage": "Импортируйте файл для массовой загрузки списка серверов", - "invalidFileType": "Неверный тип файла", - "uploadFailed": "Загрузка не удалась" - }, - "DeleteAccountTitle": "Удалить аккаунт", - "DeleteAccountButton": "Удалить аккаунт", - "publicLink": "Публичная ссылка", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "reset": "Сброс", - "ignoreTLSError": "Игнорировать ошибку TLS/SSL", - "tlsErrorIgnored": "Ошибки TLS/SSL игнорируются", - "ignoreTLSErrorDescription": "Игнорируйте ошибки TLS/SSL и продолжайте проверять доступность веб-сайта", - "createNew": "Создавать новые", - "greeting": { - "prepend": "Эй там", - "append": "Вторая половина дня — это ваша игровая площадка - давайте сделаем ее эпичной!", - "overview": "Вот обзор ваших мониторов {{type}}." - }, - "roles": { - "superAdmin": "Главный админ", - "admin": "Админ", - "teamMember": "Член команды", - "demoUser": "Демо пользователь" - }, - "teamPanel": { - "teamMembers": "Члены команды", - "filter": { - "all": "Все", - "member": "Член" - }, - "inviteTeamMember": "Пригласить члена команды", - "inviteNewTeamMember": "Пригласите нового члена команды", - "inviteDescription": "Когда вы добавите нового члена команды, он получит доступ ко всем мониторам.", - "email": "Email", - "selectRole": "Выберите роль", - "inviteLink": "Ссылка для приглашения", - "cancel": "Отменить", - "noMembers": "В команде нет членов с такой ролью", - "getToken": "Получить токен", - "emailToken": "E-mail токен", - "table": { - "name": "Имя", - "email": "Email", - "role": "Роль", - "created": "Создано" - }, - "addTeamMember": { - "addMemberMenu": "Добавить члена команды", - "title": "Зарегистрируйте нового члена команды", - "description": "Создайте нового пользователя и передайте ему учётные данные. Этот метод даёт участнику мгновенный доступ ко всем мониторам.", - "addButton": "Добавить участника" - }, - "register": "Зарегистрируйте члена команды", - "registerToast": { - "success": "Созданный пользователем, безопасный обмен учетными данными с участником.", - "dbUserExists": "Пользователь уже существует.", - "unknownError": "Произошла неизвестная ошибка." - }, - "registerTeamMember": { - "title": "Зарегистрировать члена команды", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "Пожалуйста, введите имя", - "pattern": "Имя должно содержать только буквы, пробелы, апострофы или дефисы." - } - }, - "lastName": { - "errors": { - "empty": "Пожалуйста, введите фамилию", - "pattern": "Фамилия должна содержать только буквы, пробелы, апострофы или дефисы." - } - }, - "email": { - "errors": { - "empty": "Чтобы продолжить, введите адрес электронной почты.", - "invalid": "Пожалуйста, перепроверьте правильность введенного адреса электронной почты." - } - }, - "role": { - "errors": { - "empty": "Требуется роль" - } - } - } - } - } - }, - "role": "Роль", - "changeTeamPassword": { - "changePasswordMenu": "Сбросить пароль", - "title": "Сбросить пароль члена команды", - "description": "Создайте новый пароль для этого члена команды. Вам необходимо будет сообщить ему пароль по секретному адресу.", - "success": "Пароль успешно сброшен. Убедитесь, что вы предоставили учётные данные участнику безопасным способом." - } - }, - "monitorState": { - "paused": "Остановлено", - "resumed": "Продолжено", - "active": "Активно" - }, - "menu": { - "uptime": "Аптайм", - "pagespeed": "Pagespeed", - "infrastructure": "Инфраструктура", - "incidents": "Инциденты", - "statusPages": "Страницы статуса", - "maintenance": "Обслуживание", - "integrations": "Интеграции", - "settings": "Настройки", - "support": "Поддержка", - "discussions": "Обсуждения", - "docs": "Документация", - "changelog": "Журнал изменений", - "profile": "Профиль", - "password": "Пароль", - "team": "Команда", - "logOut": "Выйти", - "notifications": "Уведомления", - "logs": "Логи" - }, - "settingsEmailUser": "Email пользователь", - "state": "Состояние", - "statusBreadCrumbsStatusPages": "Страницы статуса", - "statusBreadCrumbsDetails": "Подробности", - "commonSaving": "Сохранение...", - "navControls": "Управления", - "incidentsPageTitle": "Инциденты", - "passwordPanel": { - "passwordChangedSuccess": "Ваш пароль был успешно изменен.", - "passwordInputIncorrect": "Ваш пароль был введен неверно.", - "currentPassword": "Current пароль", - "enterCurrentPassword": "Введите свой текущий пароль", - "newPassword": "Новый пароль", - "enterNewPassword": "Введите свой новый пароль", - "confirmNewPassword": "Подтвердите новый пароль", - "passwordRequirements": "Новый пароль должен содержать не менее 8 символов и содержать как минимум одну заглавную букву, одну строчную букву, одну цифру и один специальный символ.", - "saving": "Сохранение..." - }, - "emailSent": "Письмо успешно отправлено", - "failedToSendEmail": "Не удалось отправить письмо", - "settingsTestEmailSuccess": "Тестовое письмо успешно отправлено", - "settingsTestEmailFailed": "Не удалось отправить тестовое письмо", - "settingsTestEmailFailedWithReason": "Не удалось отправить тестовое письмо: {{reason}}", - "settingsTestEmailUnknownError": "Неизвестная ошибка", - "statusMsg": { - "paused": "Мониторинг приостановлен", - "up": "Ваш сайт запущен.", - "down": "Сайт оффлайн.", - "pending": "Ожидание..." - }, - "uptimeGeneralInstructions": { - "http": "Введите URL-адрес или IP-адрес для мониторинга (например, https://example.com/ или 192.168.1.100) и добавьте понятное имя, которое будет отображаться на панели инструментов.", - "ping": "Ведите IP-адрес или имя хоста для пинга (например, 192.168.1.100 или example.com) и добавьте понятное имя, которое будет отображаться на панели инструментов.", - "docker": "Введите Docker ID вашего контейнера. Docker ID должен быть полным 64-символьным Docker ID. Вы можете запустить docker inspect , чтобы получить полный ID контейнера.", - "port": "Введите URL-адрес или IP-адрес сервера, номер порта и понятное имя, которое будет отображаться на панели инструментов.", - "game": "Введите IP-адрес или имя хоста и номер порта для пинга (например, 192.168.1.100 или example.com) и выберите тип игры.", - "https": "Введите URL-адрес или IP-адрес для мониторинга (например, https://example.com/ или 192.168.1.100) и добавьте понятное отображаемое имя, которое будет отображаться на панели управления." - }, "common": { - "appName": "Checkmate", - "monitoringAgentName": "Захватить", - "buttons": { - "toggleTheme": "Переключить тему" + "auth": { + "roles": { + "admin": "Администратор", + "demo": "Демо", + "superadmin": "Суперадминистратор", + "user": "Пользователь" + } }, - "toasts": { - "networkError": "Ошибка сети", - "checkConnection": "Пожалуйста, проверьте свое сетевое соединение", - "unknownError": "Неизвестная ошибка" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "Продолжить", - "back": "Назад" + "alerts": { + "pageSpeedApiKey": { + "content": "Внимание: Вы ещё не добавили ключ API Google PageSpeed. Перейдите в Настройки, чтобы добавить его. Без него монитор PageSpeed не будет работать." + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "Подробности", + "home": "Главная" + }, + "buttons": { + "addMember": "Добавить участника", + "cancel": "Отмена", + "close": "Закрыть", + "configure": "Настроить", + "confirm": "Подтвердить", + "create": "Создать", + "delete": "Удалить", + "generateToken": "Сгенерировать токен", + "incidents": "Инциденты", + "inviteMember": "Пригласить участника", + "pause": "Пауза", + "resume": "Продолжить", + "save": "Сохранить", + "sendInvite": "Отправить приглашение", + "test": "Тест", + "testNotifications": "Тестовые уведомления", + "toggleTheme": "Переключить тему", + "flushQueue": "Очистить очередь", + "notFound": "Перейти на главную панель", + "resetPassword": "Сбросить пароль", + "reset": "Сброс", + "clear": "Очистить", + "addDemo": "Добавить демо-мониторы", + "removeMonitors": "Удалить мониторы", + "sendTestEmail": "Отправить тестовое письмо", + "exportToJSON": "Экспорт в JSON", + "importFromJSON": "Импорт из JSON", + "clearFilters": "Очистить фильтры", + "removeUser": "Удалить пользователя" + }, + "charts": { + "labels": { + "averageResponseTime": "Среднее время ответа", + "downtime": "Простой", + "high": "высокий", + "low": "низкий", + "uptime": "Аптайм" }, - "inputs": { - "email": { - "label": "Электронная почта", - "placeholder": "jordan.ellis@domain.com", - "errors": { - "empty": "Для продолжения введите свой адрес электронной почты", - "invalid": "Пожалуйста, проверьте правильность введённого адреса электронной почты" - } - }, - "password": { - "label": "Пароль", - "rules": { - "length": { - "beginning": "Должен быть как минимум", - "highlighted": "8 символов в длину" - }, - "special": { - "beginning": "Должен содержать как минимум", - "highlighted": "один спецсимвол" - }, - "number": { - "beginning": "Должен содержать как минимум", - "highlighted": "одно число" - }, - "uppercase": { - "beginning": "Должен содержать как минимум", - "highlighted": "одну заглавную букву" - }, - "lowercase": { - "beginning": "Должен содержать как минимум", - "highlighted": "одну прописную букву" - }, - "match": { - "beginning": "Пароли", - "highlighted": "должны совпадать" - } - }, - "errors": { - "empty": "Пожалуйста, введите пароль", - "length": "Пароль должен быть длиной не менее 8 символов", - "uppercase": "Пароль должен содержать минимум 1 прописную букву", - "lowercase": "Пароль должен содержать минимум 1 строчную букву", - "number": "Пароль должен содержать минимум 1 цифру", - "special": "Пароль должен содержать минимум 1 спецсимвол", - "incorrect": "Предоставленный вами пароль не соответствует нашим записям" - } - }, - "passwordConfirm": { - "label": "Подтвердите пароль", - "placeholder": "Введите пароль еще раз для подтверждения", - "errors": { - "empty": "Пожалуйста, введите свой пароль еще раз для подтверждения (помогает избежать опечаток)", - "different": "Введённые пароли не совпадают, вероятно, один из них напечатан с ошибкой" - } - }, - "firstName": { - "label": "Имя", - "placeholder": "Jordan", - "errors": { - "empty": "Пожалуйста, введите свое имя", - "length": "Имя должно быть короче 50 символов", - "pattern": "Имя должно содержать только буквы, пробелы, апострофы или дефисы" - } - }, - "lastName": { - "label": "Фамилия", - "placeholder": "Ellis", - "errors": { - "empty": "Пожалуйста, введите свою фамилию", - "length": "Фамилия должна быть короче 50 символов", - "pattern": "Фамилия должна содержать только буквы, пробелы, апострофы или дефисы" + "histogram": { + "avg": "Сред: {{value}} мс", + "max": "Макс: {{value}} мс" + } + }, + "dialogs": { + "delete": { + "description": "Это действие нельзя отменить.", + "title": "Вы уверены, что хотите удалить это?" + } + }, + "labels": { + "active": "Активный", + "paused": "приостановлен", + "na": "Н/Д", + "resolved": "Решён", + "responseTime": "Время ответа" + }, + "form": { + "role": { + "option": { + "role": { + "label": "Роли" } } }, - "errors": { - "validation": "Ошибка при проверке данных." - }, - "fields": { - "password": { - "errors": { - "incorrect": "Введённый вами пароль неверный" + "name": { + "option": { + "firstName": { + "label": "Имя", + "placeholder": "Jordan" + }, + "lastName": { + "label": "Фамилия", + "placeholder": "Ellis" } - }, - "role": { - "errors": { - "min": "Требуется хотя бы одна роль" + } + }, + "email": { + "option": { + "email": { + "label": "Эл. почта", + "placeholder": "you@example.com" } } } }, - "login": { - "heading": "Войдите, чтобы продолжить", - "subheadings": { - "stepOne": "Введите свою электронную почту", - "stepTwo": "Введите свой пароль" - }, - "links": { - "forgotPassword": "Забыли пароль?", - "register": "У вас нет учетной записи?", - "forgotPasswordLink": "Сбросить пароль", - "registerLink": "Зарегистрируйтесь здесь" - }, - "toasts": { - "success": "С возвращением! Вы успешно вошли в систему.", - "incorrectPassword": "Неверный пароль" - }, + "table": { + "empty": "Здесь пусто", + "headers": { + "actions": "Действия", + "dateTime": "Дата и время", + "message": "Сообщение", + "monitor": "Монитор", + "monitorId": "ID монитора", + "name": "Имя", + "status": "Статус", + "type": "Тип", + "url": "URL", + "interval": "Интервал", + "active": "Активный", + "responseTime": "Время ответа" + } + } + }, + "components": { + "imageUpload": { + "clickToUpload": "Нажмите для загрузки", + "dragAndDrop": "drag and drop", + "supportedFormats": "Поддерживаемые форматы", + "maxSize": "Максимальный размер", + "orDragAndDrop": "или перетащите", "errors": { - "password": { - "incorrect": "Предоставленный вами пароль не соответствует нашим записям." - } - }, - "welcome": "Добро пожаловать обратно в Checkmate!" + "invalidFileSize": "Размер файла слишком велик!", + "invalidFileFormat": "Неподдерживаемый формат файла!" + } }, - "registration": { - "heading": { - "superAdmin": "Создать супер-администратора", - "user": "Зарегистрироваться" - }, - "subheadings": { - "stepOne": "Введите свои личные данные", - "stepTwo": "Введите вашу электронную почту", - "stepThree": "Создайте свой пароль" + "headerStatusPageControls": { + "publicLink": "Публичная ссылка" + }, + "headerTimeRange": { + "labels": { + "day": "День", + "month": "Месяц", + "recent": "Недавние", + "week": "Неделя" }, "description": { - "superAdmin": "Создайте свой аккаунт супер-администратора, чтобы начать", - "user": "Зарегистрируйтесь как пользователь и обратитесь к супер-администратору для доступа к вашим мониторам" + "recent": "Статистика за последние 2 часа.", + "day": "Статистика за последние 24 часа.", + "week": "Статистика за последние 7 дней.", + "month": "Статистика за последние 30 дней." + } + }, + "offlineBanner": { + "serverUnreachable": "Не удаётся подключиться к серверу", + "retry": "Повторить", + "retrying": "Повторная попытка...", + "reconnected": "Успешное повторное подключение к серверу." + }, + "sidebar": { + "menu": { + "uptime": "Аптайм", + "pagespeed": "Pagespeed", + "infrastructure": "Инфраструктура", + "notifications": "Уведомления", + "checks": "Проверки", + "incidents": "Инциденты", + "statusPages": "Страницы статуса", + "maintenance": "Обслуживание", + "logs": "Логи", + "settings": "Настройки" }, - "gettingStartedButton": { - "superAdmin": "Создать учетную запись супер-администратора", - "user": "Зарегистрироваться как обычный пользователь" + "bottomMenu": { + "support": "Поддержка", + "discussions": "Обсуждения", + "docs": "Документация", + "changelog": "Журнал изменений" }, - "termsAndPolicies": "Создавая учетную запись, вы соглашаетесь с нашими Условиями предоставления услуг и Политикой конфиденциальности.", + "accountMenu": { + "profile": "Профиль", + "password": "Пароль", + "team": "Команда" + }, + "starPrompt": { + "title": "Star Checkmate", + "description": "Следите за последними релизами и помогайте развивать сообщество на GitHub" + }, + "authFooter": { + "navControls": "Управления", + "logOut": "Выйти", + "roles": { + "superAdmin": "Суперадминистратор", + "admin": "Администратор", + "user": "Пользователь", + "demoUser": "Демо пользователь" + } + } + }, + "starPrompt": { + "title": "Star Checkmate", + "description": "Следите за последними релизами и помогайте развивать сообщество на GitHub" + }, + "pageHeader": { "links": { - "login": "Уже есть учетная запись? Войти" - }, - "toasts": { - "success": "Добро пожаловать! Ваша учетная запись была создана успешно." - }, - "welcome": "Добро пожаловать в Checkmate!" - }, - "forgotPassword": { - "heading": "Забыли пароль?", - "subheadings": { - "stepOne": "Не волнуйтесь, мы вышлем вам инструкции для сброса.", - "stepTwo": "Мы выслали ссылку для сброса пароля на ", - "stepThree": "Ваш новый пароль должен отличаться от ваших предыдущих пароей.", - "stepFour": "Ваш пароль был успешно сброшен. Нажмите ниже, чтобы волшебным образом войти в систему." - }, - "buttons": { - "openEmail": "Открыть Gmail", - "resetPassword": "Сбросить пароль" - }, - "imageAlts": { - "passwordKey": "Password key icon", - "email": "Email icon", - "lock": "Lock icon", - "passwordConfirm": "Password confirm icon" - }, - "links": { - "login": "Вернуться к Входу", - "resend": "Не получили письмо? Нажмите, чтобы отправить повторно" - }, - "toasts": { - "sent": "Инструкция отправлена на .", - "emailNotFound": "Email не найден.", - "redirect": "Перенаправление через ...", - "success": "Ваш пароль успешно сброшен.", - "error": "Не удалось сбросить пароль. Повторите попытку позже или обратитесь в службу поддержки." + "support": "Поддержка", + "discussions": "Обсуждения", + "docs": "Документация", + "changelog": "Журнал изменений" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "Успешное повторное подключение к серверу.", - "stillUnreachable": "Сервер по-прежнему недоступен. Повторите попытку позже." + "pages": { + "notFound": { + "title": "Ой! Вы уронили суши!", + "subtitle": "Либо URL не существует, либо у Вас нет к нему доступа." + }, + "account": { + "tabs": { + "profile": "Профиль", + "password": "Пароль", + "team": "Команда" }, - "alertBox": "Ошибка подключения к серверу", - "description": "Не удаётся подключиться к серверу. Проверьте подключение к Интернету или конфигурацию развёртывания, если проблема не устранена.", - "retryButton": { - "default": "Повторите попытку подключения", - "processing": "Подключение..." + "form": { + "name": { + "title": "Имя", + "description": "Обновите свою личную информацию" + }, + "photo": { + "title": "Фото профиля", + "description": "Загрузите фото профиля" + }, + "currentPassword": { + "title": "Текущий пароль", + "description": "Введите текущий пароль для подтверждения личности", + "option": { + "label": "Текущий пароль", + "placeholder": "Введите текущий пароль" + } + }, + "newPassword": { + "title": "Новый пароль", + "description": "Выберите надёжный пароль длиной не менее 8 символов", + "option": { + "newPassword": { + "label": "Новый пароль", + "placeholder": "Введите новый пароль" + }, + "confirm": { + "label": "Подтвердите пароль", + "placeholder": "Подтвердите новый пароль" + } + } + }, + "deleteAccount": { + "title": "Удалить аккаунт", + "description": "Это действие необратимо и не может быть отменено" + } + }, + "team": { + "filter": { + "placeholder": "Фильтр по роли", + "all": "Все", + "admin": "Администратор", + "member": "Член" + }, + "table": { + "headers": { + "email": "Эл. почта", + "role": "Роль", + "created": "Создано" + } + }, + "addMember": { + "title": "Зарегистрировать нового участника", + "description": "Создайте новый аккаунт. После создания безопасно передайте учётные данные." + }, + "invite": { + "title": "Пригласить участника команды", + "description": "Отправить приглашение в Вашу команду", + "email": { + "label": "Адрес эл. почты", + "placeholder": "Введите адрес эл. почты" + }, + "role": { + "label": "Роль", + "placeholder": "Выберите роль" + }, + "linkLabel": "Ссылка-приглашение" + }, + "fallback": { + "title": "Пока нет участников команды", + "description": "Пригласите коллег по электронной почте или добавьте их напрямую. Как только они присоединятся, они появятся здесь со своей ролью." + } + }, + "header": { + "title": "Учётная запись", + "description": "Управляйте профилем, паролем, командой и настройками рабочего пространства." } - } - }, - "createNotifications": { - "title": "Создать канал уведомлений", - "nameSettings": { - "title": "Имя", - "description": "Описательное имя для вашей интеграции.", - "nameLabel": "Имя", - "namePlaceholder": "например, уведомления Slack" }, - "typeSettings": { - "title": "Тип", - "description": "Выберите тип канала уведомлений, который вы хотите создать.", - "typeLabel": "Тип" - }, - "emailSettings": { - "title": "Электронная почта", - "description": "Адреса электронной почты получателей.", - "emailLabel": "Адрес электронной почты", - "emailPlaceholder": "например, john@example.com" - }, - "slackSettings": { - "title": "Slack", - "description": "Настройте свой вебхук Slack здесь", - "webhookLabel": "Slack вебхук URL", - "webhookPlaceholder": "https://hooks.slack.com/services/..." - }, - "pagerdutySettings": { - "title": "PagerDuty", - "description": "Настройте интеграцию PagerDuty здесь", - "integrationKeyLabel": "Интеграционный ключ", - "integrationKeyPlaceholder": "1234567890" - }, - "discordSettings": { - "title": "Discord", - "description": "Настройте вебхук Discord здесь", - "webhookLabel": "Discord вебхук URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "webhookSettings": { - "title": "Вебхук", - "description": "Настройте свой вебхук здесь", - "webhookLabel": "URL-адрес веб-перехватчика", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "testNotification": "Тестовое уведомление", - "dialogDeleteTitle": "Вы уверены, что хотите удалить это уведомление?", - "dialogDeleteConfirm": "Удалить" - }, - "notificationConfig": { - "title": "Уведомления", - "description": "Выберите каналы уведомлений, которые вы хотите использовать" - }, - "monitorStatus": { - "checkingEvery": "Проверка каждый {{interval}}", - "withCaptureAgent": "с агентом захвата {{version}}", - "up": "вверх", - "down": "вниз", - "paused": "приостановлено" - }, - "advancedMatching": "Расширенное сопоставление", - "sendTestNotifications": "Отправить тестовое уведомление", - "selectAll": "Выбрать все", - "showAdminLoginLink": "Показывать ссылку «Администратор? Войти здесь» на странице статуса", - "logsPage": { - "title": "Логи", - "description": "Системные логи - последние 1000 строк", - "tabs": { - "queue": "Очередь заданий", - "logs": "Серверные логи", - "diagnostics": "Диагностика" - }, - "toast": { - "fetchLogsSuccess": "Логи успешно получены" - }, - "logLevelSelect": { - "title": "Уровень логов", - "values": { - "all": "Все", - "info": "Информация", - "warn": "Предупреждение", - "error": "Ошибка", - "debug": "Дебаг" + "auth": { + "brandPanel": { + "eyebrow": "Мониторинг инфраструктуры с открытым исходным кодом", + "tagline": "Следите за каждым шагом ваших серверов.", + "description": "Мониторинг доступности, времени отклика и инцидентов в реальном времени - self-hosted и принадлежит вам." + }, + "common": { + "passwordRules": { + "length": { + "beginning": "Должен быть как минимум", + "highlighted": "8 символов в длину" + }, + "lowercase": { + "beginning": "Должен содержать как минимум", + "highlighted": "одну прописную букву" + }, + "match": { + "beginning": "Пароли", + "highlighted": "должны совпадать" + }, + "number": { + "beginning": "Должен содержать как минимум", + "highlighted": "одно число" + }, + "special": { + "beginning": "Должен содержать как минимум", + "highlighted": "один спецсимвол" + }, + "uppercase": { + "beginning": "Должен содержать как минимум", + "highlighted": "одну заглавную букву" + } + }, + "form": { + "option": { + "email": { + "label": "Эл. почта", + "placeholder": "me@example.com" + }, + "password": { + "label": "Пароль", + "placeholder": "********" + }, + "confirmPassword": { + "label": "Подтвердите пароль" + } + } + } + }, + "login": { + "title": "С возвращением в Checkmate!", + "subtitle": "Войдите, чтобы продолжить", + "submit": "Войти", + "links": { + "forgotPassword": { + "text": "Забыли пароль?", + "linkText": "Сбросить пароль" + }, + "register": { + "text": "Нет аккаунта?", + "linkText": "Зарегистрируйтесь" + } + } + }, + "register": { + "title": "С возвращением в Checkmate!", + "subtitle": "Зарегистрируйтесь, чтобы начать", + "submit": "Регистрация", + "setupNotice": "Эта страница доступна только во время первоначальной настройки. После регистрации суперадминистратора дополнительных пользователей можно пригласить из настроек команды." + }, + "forgotPassword": { + "title": "Забыли пароль?", + "subtitle": "Не беспокойтесь, мы отправим Вам инструкции по сбросу.", + "submit": "Запросить восстановление", + "links": { + "login": { + "text": "Вернуться к", + "linkText": "входу" + } + } + }, + "setNewPassword": { + "title": "Сбросить пароль", + "subtitle": "Новый пароль должен отличаться от ранее использованных паролей." } - } - }, - "queuePage": { - "title": "Очередь", - "refreshButton": "Обновить", - "flushButton": "Очистить очередь", - "jobTable": { - "title": "Задачи в настоящее время в очереди", - "idHeader": "ID монитора", - "urlHeader": "URL", - "typeHeader": "Тип", - "activeHeader": "Активный", - "lockedAtHeader": "Заблокировано на", - "runCountHeader": "Количество запусков", - "failCountHeader": "Количество неудач", - "lastRunHeader": "Последний запуск в", - "lastFinishedAtHeader": "Последний раз закончил в", - "lastRunTookHeader": "Последний забег занял", - "intervalHeader": "Интервал" }, - "metricsTable": { - "title": "Метрики очереди", - "metricHeader": "Метрическая", - "valueHeader": "Ценить" + "checks": { + "selects": { + "monitor": { + "all": "Все мониторы" + }, + "status": { + "all": "Все", + "down": "Недоступен", + "up": "Доступен" + } + }, + "table": { + "empty": "Нет проверок с ошибками за этот период", + "headers": { + "statusCode": "Код статуса", + "location": "Расположение" + } + }, + "fallback": { + "title": "Пока нет проверок", + "description": "Как только вы создадите монитор, каждая проверка состояния и время отклика появятся здесь." + }, + "header": { + "title": "Проверки", + "description": "Каждый зонд, который Checkmate выполняет к вашим мониторам, появится здесь с кодом состояния и временем отклика." + } }, - "failedJobTable": { - "title": "Неудачные задачи", - "monitorIdHeader": "ID монитора", - "monitorUrlHeader": "URL монитора", - "failCountHeader": "Количество падений", - "failedAtHeader": "Последнее падение в", - "failReasonHeader": "Причина падения" - } - }, - "export": { - "title": "Экспорт мониторов", - "success": "Мониторы успешно экспортированы!", - "failed": "Не удалось экспортировать мониторы" - }, - "monitorActions": { - "title": "Экспорт/Импорт", - "import": "Импортные мониторы", - "export": "Экспорт мониторов", - "deleteSuccess": "Монитор успешно удален", - "deleteFailed": "Не удалось удалить монитор", - "details": "Подробности" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "Разработано Bluewave Labs", - "labelVersion": "Версия", - "title": "Об" + "common": { + "monitors": { + "actions": { + "configure": "Настроить", + "delete": "Удалить", + "generateToken": "Сгенерировать токен", + "details": "Подробности", + "incidents": "Инциденты", + "inviteMember": "Пригласить участника", + "openSite": "Открыть сайт", + "pause": "Пауза", + "resume": "Продолжить", + "selectAll": "Выбрать все мониторы", + "selectMonitor": "Выбрать {{name}}", + "bulkSelected_one": "Выбран {{count}} монитор", + "bulkSelected_other": "Выбрано {{count}} мониторов" + }, + "statBoxes": { + "activeFor": "Активен", + "certificateExpiry": "Истечение сертификата", + "lastCheck": "Последняя проверка", + "lastResponseTime": "Последнее время ответа", + "serviceIsDown": "Сервис недоступен" + }, + "status": { + "down": "недоступен", + "breached": "порог превышен", + "initializing": "инициализация", + "maintenance": "обслуживание", + "paused": "приостановлен", + "total": "всего", + "up": "доступен" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "Игра", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "Порт", + "optionPagespeed": "PageSpeed", + "optionHardware": "Инфраструктура", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} монитор приостановлен", + "paused_other": "{{count}} мониторов приостановлено", + "resumed_one": "{{count}} монитор возобновлен", + "resumed_other": "{{count}} мониторов возобновлено", + "alreadyPaused_one": "Выбранный монитор уже приостановлен", + "alreadyPaused_other": "Все выбранные мониторы уже приостановлены", + "alreadyRunning_one": "Выбранный монитор уже запущен", + "alreadyRunning_other": "Все выбранные мониторы уже запущены" + } + } }, - "demoMonitorsSettings": { - "buttonAddMonitors": "Добавить демо мониторы", - "description": "Добавьте образцы мониторов для демонстрации.", - "title": "Демо мониторы" + "createMonitor": { + "form": { + "advanced": { + "description": "Дополнительные настройки для расширенных сценариев", + "option": { + "advancedMatching": { + "label": "Использовать расширенное сопоставление" + }, + "expectedValue": { + "label": "Ожидаемое значение" + }, + "jsonPath": { + "description": "Это выражение будет вычислено для JSON-данных ответа, и результат будет сопоставлен с ожидаемым значением. См. документацию по языку запросов на jmespath.org.", + "label": "Выражение JSONPath" + }, + "matchMethod": { + "label": "Метод сопоставления", + "equal": "Равный", + "include": "Включить", + "regex": "Regex" + } + }, + "title": "Расширенные настройки" + }, + "frequency": { + "description": "Как часто Вы хотите проверять статус этого монитора?", + "option": { + "frequency": { + "label": "Частота проверки", + "value": { + "fifteenMinutes": "15 минут", + "fifteenSeconds": "15 секунд", + "fiveMinutes": "5 минут", + "fourMinutes": "4 минуты", + "oneMinute": "1 минута", + "tenMinutes": "10 минут", + "thirtyMinutes": "30 минут", + "thirtySeconds": "30 секунд", + "threeMinutes": "3 минуты", + "twoMinutes": "2 минуты" + } + } + }, + "title": "Частота проверки" + }, + "general": { + "option": { + "container": { + "label": "Имя/ID контейнера", + "placeholder": "my-app или abcd1234" + }, + "host": { + "label": "Хост", + "placeholder": "192.168.1.100 или example.com" + }, + "name": { + "label": "Отображаемое имя", + "placeholder": "напр. Мой сайт" + }, + "secret": { + "label": "Секрет авторизации", + "placeholder": "Введите Ваш секретный ключ" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "URL для мониторинга", + "placeholder": "localhost" + }, + "port": { + "label": "Порт для мониторинга", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "Имя сервиса", + "placeholder": "напр. my.service.v1 (оставьте пустым для общей проверки)" + }, + "wsUrl": { + "label": "WebSocket URL", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Эндпоинт Capture", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "Основные настройки", + "description": { + "http": "Введите URL или IP для мониторинга (напр., https://example.com/ или 192.168.1.100) и добавьте понятное имя для отображения на панели.", + "ping": "Ведите IP-адрес или имя хоста для пинга (например, 192.168.1.100 или example.com) и добавьте понятное имя, которое будет отображаться на панели инструментов.", + "port": "Введите URL-адрес или IP-адрес сервера, номер порта и понятное имя, которое будет отображаться на панели инструментов.", + "docker": "Введите Docker ID вашего контейнера. Docker ID должен быть полным 64-символьным Docker ID. Вы можете запустить docker inspect , чтобы получить полный ID контейнера.", + "game": "Введите IP-адрес или имя хоста и номер порта для пинга (например, 192.168.1.100 или example.com) и выберите тип игры. Некоторым играм требуется включить серверные запросы в файле конфигурации — проверьте документацию вашего игрового сервера. См. список поддерживаемых игр для деталей протокола.", + "pagespeed": "Отслеживайте производительность загрузки, Core Web Vitals и показатели оптимизации для Вашего сайта.", + "grpc": "Введите имя хоста и порт gRPC-сервера, при необходимости укажите имя сервиса для проверки состояния и добавьте отображаемое имя.", + "websocket": "Введите WebSocket URL для мониторинга (напр., wss://example.com/socket) и добавьте отображаемое имя.", + "hardware": "Отслеживайте использование ЦП, памяти, диска и температуру вашей инфраструктуры. URL эндпоинта Capture должен включать полный путь /api/v1/metrics (например, http://192.168.1.10:5001/api/v1/metrics). В секрете авторизации укажите только строку токена — не включайте 'Bearer'." + } + }, + "ignoreTls": { + "description": "Настройте проверку сертификатов TLS/SSL для HTTPS-подключений.", + "option": { + "tls": { + "label": "Игнорировать ошибки TLS/SSL" + } + }, + "title": "Настройки TLS/SSL" + }, + "incidents": { + "description": "Скользящее окно используется для определения момента, когда монитор становится недоступным. Статус монитора изменится только когда процент проверок в скользящем окне достигнет указанного значения.", + "option": { + "checks": { + "label": "Количество проверок в скользящем окне" + }, + "percentage": { + "label": "Какой процент проверок в скользящем окне должен завершиться неудачей/успехом, прежде чем статус монитора изменится?" + } + }, + "title": "Инциденты" + }, + "notifications": { + "description": "Выберите каналы уведомлений, которые хотите использовать", + "title": "Уведомления" + }, + "type": { + "description": "Выберите тип проверки", + "optionDockerDescription": "Используйте Docker для мониторинга работы контейнера.", + "optionGameDescription": "Мониторинг доступности игрового сервера.", + "optionGrpcDescription": "Мониторинг gRPC-сервисов с использованием стандартного протокола проверки состояния.", + "optionWebSocketDescription": "Мониторинг WebSocket-эндпоинтов: состояние подключения и время ответа.", + "optionHttpDescription": "Используйте HTTP(S) для мониторинга Вашего сайта или API-эндпоинта.", + "optionPingDescription": "Используйте ICMP Ping для проверки доступности сервера.", + "optionPortDescription": "Мониторинг доступности определённого порта на сервере.", + "title": "Тип" + }, + "thresholds": { + "title": "Пороги оповещений", + "description": "Определите пороговые значения, при которых должны срабатывать оповещения для этого монитора оборудования.", + "option": { + "cpuThreshold": { + "label": "Порог оповещения CPU (%)" + }, + "memoryThreshold": { + "label": "Порог оповещения памяти (%)" + }, + "diskThreshold": { + "label": "Порог оповещения диска (%)" + }, + "tempThreshold": { + "label": "Порог оповещения температуры (°C)" + } + } + }, + "geoChecks": { + "title": "Гео-распределённые проверки", + "description": "Запускайте проверки из нескольких географических точек для мониторинга глобальной доступности и производительности.", + "option": { + "enabled": { + "label": "Включить гео-распределённые проверки" + }, + "locations": { + "label": "Расположения", + "placeholder": "Выберите расположения", + "options": { + "EU": "Европа", + "NA": "Северная Америка", + "AS": "Азия", + "SA": "Южная Америка", + "AF": "Африка", + "OC": "Океания" + } + }, + "interval": { + "label": "Интервал проверки", + "value": { + "fiveMinutes": "5 минут", + "tenMinutes": "10 минут", + "fifteenMinutes": "15 минут", + "thirtyMinutes": "30 минут" + } + } + } + }, + "url": { + "title": "IP/URL монитора на странице статуса", + "description": "Отображать IP-адрес или URL монитора на публичной странице статуса. Если отключено, будет показано только имя монитора для защиты конфиденциальной информации.", + "option": { + "showURL": { + "label": "Отображать IP/URL на странице статуса", + "enabled": "Включено", + "disabled": "Отключено" + } + } + }, + "stats": { + "title": "История мониторинга", + "description": "Очистить всю историю мониторинга и статистику Вашей команды. Это действие необратимо.", + "buttonText": "Очистить всю статистику", + "dialog": { + "title": "Очистить всю историю мониторинга?", + "description": "Это навсегда удалит всю историю мониторинга, статистику и данные проверок Вашей команды. Это действие нельзя отменить.", + "confirm": "Да, очистить всю статистику" + } + } + } }, - "emailSettings": { - "buttonSendTestEmail": "Отправить тестовое письмо", - "description": "Настройте параметры электронной почты в вашей системе. Она используется для отправки уведомлений и оповещений.", - "descriptionTransport": "Это создает SMTP-транспорт для NodeMailer.", - "labelAddress": "Адрес электронной почты — используется для аутентификации.", - "labelConnectionHost": "Хост подключения электронной почты — имя хоста для использования в приветствии HELO/EHLO.", - "labelHost": "Хост электронной почты — имя хоста или IP-адрес для подключения.", - "labelIgnoreTLS": "Отключить STARTTLS: не использовать TLS, даже если сервер его поддерживает.", - "labelPassword": "Пароль электронной почты - Пароль для аутентификации", - "labelPasswordSet": "Пароль установлен. Нажмите «Сброс», чтобы изменить его.", - "labelPool": "Включить пул соединений: повторно использовать существующие соединения для повышения производительности.", - "labelPort": "Порт электронной почты — порт для подключения", - "labelRejectUnauthorized": "Отклонять недействительные сертификаты: отклонять соединения с самоподписанными или ненадежными сертификатами.", - "labelRequireTLS": "Принудительный STARTTLS: требуется обновление TLS, выдается ошибка, если не поддерживается", - "labelSecure": "Использовать SSL (рекомендуется): зашифровать соединение с помощью SSL/TLS.", - "labelTLSServername": "Имя сервера TLS — необязательное имя хоста для проверки TLS, если хост — это IP-адрес.", - "labelUser": "Пользователь электронной почты — имя пользователя для аутентификации, переопределяет адрес электронной почты, если указан", - "linkTransport": "Технические характеристики смотрите здесь.", - "placeholderUser": "Оставьте пустым, если не требуется", - "title": "Электронная почта", - "toastEmailRequiredFieldsError": "Требуется адрес электронной почты, хост, порт и пароль" + "editUser": { + "form": { + "roles": { + "title": "Роли", + "description": "Назначьте роли пользователю. Можно выбрать несколько ролей." + } + }, + "dialog": { + "removeUser": { + "title": "Удалить пользователя", + "content": "Вы уверены, что хотите удалить {{name}} из Вашей команды? Это действие нельзя отменить." + } + } }, - "pageSpeedSettings": { - "description": "Введите ключ API Google PageSpeed, чтобы включить мониторинг Google PageSpeed. Нажмите «Сбросить», чтобы обновить ключ.", - "labelApiKeySet": "API-ключ установлен. Нажмите «Сброс», чтобы изменить его.", - "labelApiKey": "API-ключ PageSpeed", - "title": "API-ключ Google PageSpeed" + "incidents": { + "fallback": { + "title": "Инцидентов нет — все системы исправны", + "description": "Когда монитор перестаёт работать или восстанавливается, инцидент появится здесь со временем, деталями монитора и историей решения." + }, + "dialog": { + "resolveIncident": { + "title": "Решить инцидент", + "option": { + "comment": { + "label": "Комментарий (необязательно)", + "placeholder": "Добавьте комментарий о решении..." + } + } + }, + "details": { + "analysis": "Анализ инцидента", + "comment": "Комментарий:", + "detailsLabel": "Подробности", + "downtime": "Простой:", + "message": "Сообщение:", + "monitor": "Монитор:", + "overview": "Обзор", + "resolutionDetails": "Подробности решения", + "resolutionType": "Тип:", + "resolutionTypes": { + "automatic": "Автоматически", + "manual": "Вручную" + }, + "resolve": "Решить инцидент", + "resolvedAt": "Решён в:", + "resolvedBy": "Решён:", + "startedAt": "Начался в:", + "status": "Статус:", + "statusCode": "Код статуса:", + "timeline": "Хронология", + "title": "Подробности инцидента", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "Все мониторы", + "monitor": "Монитор", + "resolutionType": "Тип решения", + "resolutionTypes": { + "manual": "Вручную", + "automatic": "Автоматически", + "all": "Все" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "Активные инциденты", + "active_zero": "Нет активных инцидентов", + "active_one": "{{count}} активный инцидент", + "active_other": "{{count}} активных инцидентов" + }, + "incidentStats": { + "avgResolutionTime": "Среднее время решения", + "mostAffectedMonitor": "Наиболее затронутый монитор", + "title": "Статистика инцидентов", + "totalIncidents": "Всего инцидентов" + }, + "latestIncidents": { + "title": "Последние инциденты" + } + }, + "table": { + "actions": { + "details": "Подробности", + "goToMonitor": "Перейти к монитору", + "resolveManually": "Решить вручную" + }, + "activeIncidents": "Активные инциденты", + "headers": { + "endTime": "Время окончания", + "resolutionType": "Тип решения", + "startTime": "Время начала", + "statusCode": "Код статуса" + }, + "resolvedIncidents": "Решённые инциденты", + "status": { + "active": "Активный", + "resolved": "Решён" + } + }, + "header": { + "title": "Инциденты", + "description": "Когда монитор перестаёт работать или восстанавливается, инцидент появляется здесь со временем, деталями монитора и историей решения." + } }, - "saveButtonLabel": "Сохранить", - "statsSettings": { - "clearAllStatsButton": "Очистить всю статистику", - "clearAllStatsDescription": "Очистить всю статистику. Это необратимо.", - "clearAllStatsDialogConfirm": "Да, очистить всю статистику", - "clearAllStatsDialogDescription": "После удаления история и статистика мониторинга не могут быть восстановлены.", - "clearAllStatsDialogTitle": "Вы хотите очистить всю статистику?", - "description": "Укажите, как долго вы хотите хранить данные. Вы также можете удалить все существующие данные.", - "labelTTL": "Дни, за которые вы хотите посмотреть историю.", - "labelTTLOptional": "0 для бесконечности", - "title": "История монитора" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "Удалить все мониторы", - "description": "Удалите все мониторы из вашей системы.", - "dialogConfirm": "Да, удалить все мониторы", - "dialogDescription": "После удаления, мониторы нельзя будет воостановить.", - "dialogTitle": "Вы хотите удалить все мониторы?", - "title": "Сброс системы" - }, - "timezoneSettings": { - "description": "Выберите часовой пояс, используемый для отображения даты и времени в приложении.", - "label": "Отображение часового пояса", - "title": "Отображение часового пояса" - }, - "title": "Настройки", - "uiSettings": { - "description": "Переключайтесь между светлым и темным режимами или меняйте язык пользовательского интерфейса.", - "labelLanguage": "Язык", - "labelTheme": "Режим темы", - "title": "Появление" - }, - "urlSettings": { - "description": "Отображение IP-адреса или URL-адреса монитора на общедоступной странице состояния. Если эта опция отключена, для защиты конфиденциальной информации будет отображаться только имя монитора.", - "label": "Отображать IP/URL на странице статуса", - "selectDisabled": "Выключено", - "selectEnabled": "Включено", - "title": "Мониторинг IP/URL на странице статуса" - }, - "globalThresholds": { - "title": "Глобальные пороги", - "description": "Настройте глобальные пороговые значения для процессора, памяти, диска и температуры. Если значение указано, оно будет автоматически включено для мониторинга." - } - }, - "statusPageCreate": { - "buttonSave": "Сохранить" - }, - "incidentsOptionsHeaderFilterResolved": "Решено", - "settingsSave": "Сохранить", - "statusPageCreateAppearanceTitle": "Внешний вид", - "confirmPassword": "Подтвердите пароль", - "monitorHooks": { - "failureAddDemoMonitors": "Не удалось добавить демонстрационные мониторы.", - "successAddDemoMonitors": "Демонстрационные мониторы успешно добавлены" - }, - "settingsAppearance": "Внешний вид", - "settingsDisplayTimezone": "Отображение часового пояса", - "settingsGeneralSettings": "Общие настройки", - "incidentsOptionsHeaderTotalIncidents": "Всего инцидентов", - "statusPage": { - "deleteSuccess": "Страница статуса успешно удалена", - "deleteFailed": "Не удалось удалить страницу статуса", - "createSuccess": "Страница статуса успешно создана", - "updateSuccess": "Страница статуса успешно обновлена", - "generalSettings": "Общие настройки", - "contents": "Содержание", - "fallback": { - "checks": [ - "Мониторинг и отображение состояния ваших сервисов в режиме реального времени", - "Отслеживайте состояние нескольких сервисов и делитесь их статусом", - "Дайте пользователям информацию о сбоях и производительности" - ], - "title": "Страница статуса используется для:", - "actionButton": "Давайте создадим вашу первую страницу статуса!" - } - }, - "testNotificationsDisabled": "Для этого монитора не настроены уведомления. Вам нужно добавить их, нажав кнопку «Настроить».", - "incidentsTableResolvedAt": "Решено в", - "incidentsTableActionResolve": "Решено", - "checkHooks": { - "failureResolveOne": "Не удалось разрешить инцидент.", - "failureResolveAll": "Не удалось разрешить все инциденты.", - "failureResolveMonitor": "Не удалось устранить инциденты с монитором." - }, - "checkFormError": "Пожалуйста, проверьте форму на наличие ошибок.", - "diagnosticsPage": { - "diagnosticDescription": "Системная диагностика", - "statsDescription": "Системная статистика", - "gauges": { - "heapAllocationTitle": "Использовано кучи", - "heapAllocationSubtitle": "% доступной памяти", - "heapUsageTitle": "Выделено памяти в куче", - "heapUsageSubtitle": "% доступно памяти", - "heapUtilizationTitle": "Использовано кучи", - "heapUtilizationSubtitle": "% от доступного", - "instantCpuUsageTitle": "Загруженность CPU", - "instantCpuUsageSubtitle": "% загрузка CPU за 1s" - }, - "stats": { - "eventLoopDelayTitle": "Задержка цикла событий", - "uptimeTitle": "Аптайм", - "usedHeapSizeTitle": "Размер используемой кучи", - "totalHeapSizeTitle": "Общий размер кучи", - "osMemoryLimitTitle": "Ограничение памяти ОS" - } - }, - "pageSpeedLighthouseAPI": "Используйте API Lighthouse PageSpeed для мониторинга вашего сайта", - "time": { - "threeMinutes": "3 минуты", - "fiveMinutes": "5 минут", - "tenMinutes": "10 минут", - "twentyMinutes": "20 минут", - "oneHour": "1 час", - "oneDay": "1 день", - "oneWeek": "1 неделя", - "fourMinutes": "4 минуты", - "oneMinute": "1 минута", - "twoMinutes": "2 минуты", - "fifteenSeconds": "15 секунд", - "thirtySeconds": "30 секунд" - }, - "general": { - "noOptionsFound": "{{unit}} не найдено" - }, - "infrastructureMonitor": { - "fallback": { - "checks": [ - "Отслеживайте производительность серверов", - "Выявляйте узкие места и оптимизируйте использование", - "Обеспечивайте надежность с помощью мониторинга в режиме реального времени" - ], - "title": "Монитор инфраструктуры используется для:", - "actionButton": "Давайте создадим ваш первый монитор инфраструктуры!" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [ - "Отметьте периоды технического обслуживания.", - "Устраните любые недопонимания.", - "Прекратите отправку оповещений в периоды технического обслуживания." - ], - "title": "Период технического обслуживания используется для:", - "actionButton": "Давайте создадим ваше первое окно технического обслуживания!" - } - }, - "pageSpeed": { - "fallback": { - "checks": [ - "Отчет об удобстве использования страницы", - "Помощь в анализе скорости загрузки веб-страницы", - "Предложение о том, как можно улучшить страницу" - ], - "title": "Монитор PageSpeed используется для:", - "actionButton": "Давайте создадим ваш первый монитор PageSpeed!" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [ - "Проверьте, доступны ли веб-сайты или серверы и отвечают ли они", - "Оповещайте команды о сбоях или проблемах с производительностью", - "Мониторинг конечных точек HTTP, пингов, контейнеров и портов", - "Отслеживайте динамику времени безотказной работы и надежности" - ], - "title": "Монитор работоспособности используется для:", - "actionButton": "Давайте создадим ваш первый монитор времени безотказной работы!" - } - }, - "editUserPage": { - "form": { - "email": "Электронная почта", - "firstName": "Имя", - "lastName": "Фамилия", - "role": "Роли", - "save": "Сохранить" - }, - "table": { - "actionHeader": "Действие", - "roleHeader": "Роль" - }, - "title": "Редактировать пользователя", - "toast": { - "successUserUpdate": "Пользователь успешно обновлен", - "validationErrors": "Ошибки проверки" - } - }, - "incidentsPageActionResolveMonitor": "Разрешение инцидентов мониторинга", - "incidentsPageActionResolveAll": "Решить все инциденты", - "matchMethodOptions": { - "equal": "Равный", - "equalPlaceholder": "успех", - "include": "Включить", - "includePlaceholder": "хорошо", - "regex": "Regex", - "regexPlaceholder": "^(success|ok)$", - "text": "Метод сопоставления" - }, - "monitorType": { - "docker": { - "label": "Имя/ID контейнера", - "namePlaceholder": "Мой контейнер", - "placeholder": "my-app или abcd1234" - }, - "http": { - "label": "URL для мониторинга", - "namePlaceholder": "Google", - "placeholder": "google.com" - }, - "ping": { - "label": "IP-адрес для мониторинга", - "namePlaceholder": "Google", - "placeholder": "1.1.1.1" - }, - "port": { - "label": "URL для мониторинга", - "namePlaceholder": "Локальный хост:5173", - "placeholder": "локальный хост" - }, - "game": { - "label": "URL для мониторинга", - "namePlaceholder": "localhost:5173", - "placeholder": "localhost" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "путь к JSON" - }, - "bytesPerSecond": "Байты в секунду", - "bytesReceived": "Получено байтов", - "bytesSent": "Отправлено байтов", - "chooseGame": "Выбрать игру", - "createMonitorPage": { - "incidentConfigDescription": "Мониторинг использует скользящее окно, чтобы понять, когда ресурс недоступен. Статус изменится, если нужный процент проверок покажет сбой.", - "incidentConfigStatusWindowLabel": "Сколько проверок должно быть в скользящем окне?", - "incidentConfigStatusWindowThresholdLabel": "Какой процент проверок в скользящем окне завершается неудачей/успешностью до изменения статуса монитора?", - "incidentConfigTitle": "Инциденты", - "incidentConfigDescriptionV2": "Количество последовательных проверок, необходимых для изменения статуса монитора. Максимум 25.", - "incidentConfigStatusCheckNumber": "Сколько проверок перед сменой статуса?", - "intervalTitle": "Интервал проверки", - "intervalDescription": "Как часто следует проверять монитор." - }, - "dataRate": "Скорость передачи данных", - "dataReceived": "Данные получены", - "dataSent": "Данные отправлены", - "details": "Подробности", - "drops": "Падений", - "errors": "Ошибки", - "errorsIn": "Ошибки в", - "errorsOut": "Ошибки вне", - "gameServerMonitoring": "Мониторинг игрового сервера", - "gameServerMonitoringDescription": "Проверьте, запущен ли ваш игровой сервер.", - "network": "Сеть", - "networkDrops": "Сетевые падения", - "networkErrors": "Ошибки сети", - "networkInterface": "Сетевой интерфейс", - "noNetworkStatsAvailable": "Статистика сети отсутствует.", - "packetsPerSecond": "Пакетов в секунду", - "packetsReceived": "Получено пакетов", - "packetsReceivedRate": "Скорость получения пакетов", - "packetsSent": "Отправлено пакетов", - "rate": "коэффициент", - "selectInterface": "Выберите интерфейс", - "v1": { "infrastructure": { - "disk_selection_title": "Выбор дисков", - "disk_selection_info": "На данный момент диск не обнаружен.", - "disk_selection_description": "Выберите конкретные диски, которые вы хотите отслеживать." + "charts": { + "labels": { + "cpu": "Загрузка CPU", + "disk": "Использование диска", + "memory": "Использование памяти", + "netBytesRecv": "{{name}} - байт получено", + "netBytesSent": "{{name}} - байт отправлено", + "netNoTraffic": "Capture сообщает о нулевом трафике на всех интерфейсах. Это нормально, когда Capture работает внутри контейнера, который видит только loopback-интерфейсы — запустите Capture непосредственно на хосте, чтобы увидеть реальную сетевую активность.", + "temp": "Температура" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "Макс. частота", + "title": "Загрузка CPU", + "upperLabel": "Текущая частота" + }, + "disk": { + "lowerLabel": "Свободно", + "title": "Использование диска {{idx}}", + "upperLabel": "Использовано" + }, + "memory": { + "lowerLabel": "Свободно", + "title": "Использование памяти", + "upperLabel": "Использовано" + } + }, + "statBoxes": { + "avgCpuTemperature": "Средняя температура CPU", + "cpuFrequency": "Частота CPU", + "cpuLogical": "CPU (логический)", + "cpuPhysical": "CPU (физический)", + "disk": "Диск", + "memory": "Память", + "os": "ОС" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "Диск", + "memory": "Память" + } + }, + "tabs": { + "labels": { + "network": "Сеть", + "overview": "Обзор" + } + }, + "fallback": { + "actionButton": "Создать монитор!", + "title": "Пока нет инфраструктурных мониторов", + "description": "Отслеживайте производительность серверов, выявляйте узкие места и обеспечивайте надёжность с мониторингом в реальном времени." + }, + "header": { + "title": "Инфраструктурные мониторы", + "description": "Отслеживайте производительность серверов, выявляйте узкие места и обеспечивайте надёжность с мониторингом в реальном времени." + } + }, + "logs": { + "fallback": { + "title": "Пока нет журналов", + "description": "Активность сервера, предупреждения и ошибки появятся здесь во время работы Checkmate." + }, + "tabs": { + "diagnostics": "Диагностика", + "logs": "Серверные логи", + "queue": "Очередь заданий" + }, + "logLevelSelect": { + "label": "Уровень логов" + }, + "jobQueue": "Очередь заданий", + "jobQueueExplainer": "Выделенные строки — это задания, которые в данный момент обрабатываются воркером.", + "failedJobs": "Неудачные задания", + "failedJobsExplainer": "Недавние сбои по мониторам — время последнего сбоя, количество сбоев и причина.", + "noLogs": "Логи не найдены", + "metrics": { + "jobs": "Задания", + "activeJobs": "Активные задания", + "failingJobs": "Неудачные задания", + "totalRuns": "Всего запусков", + "totalFailures": "Всего ошибок" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "Задержка цикла событий", + "uptime": "Аптайм", + "usedHeapSize": "Используемый размер кучи", + "totalHeapSize": "Общий размер кучи", + "osMemoryLimit": "Лимит памяти ОС" + }, + "gauges": { + "heapAllocation": "Выделение кучи", + "heapUsage": "Использование кучи", + "heapUtilization": "Утилизация кучи", + "instantCpuUsage": "Мгновенная загрузка CPU", + "availableMemoryPercentage": "% доступной памяти", + "allocatedPercentage": "% выделено", + "usedSPercentage": "% 1с использовано CPU", + "total": "Всего", + "used": "Использовано" + }, + "sections": { + "runtimeStats": { + "title": "Статистика выполнения", + "description": "Снимок процесса Node.js и хост-машины. Обновляется каждые 5 секунд." + }, + "memoryCpu": { + "title": "Память и ЦП", + "description": "Как процесс использует выделенную память и ЦП хоста." + } + } + }, + "table": { + "headers": { + "timestamp": "Метка времени", + "level": "Уровень", + "service": "Сервис", + "method": "Метод", + "monitorId": "ID монитора", + "runCount": "Кол-во запусков", + "failCount": "Кол-во ошибок", + "lastRunAt": "Последний запуск", + "lockedAt": "Заблокировано в", + "lastFinishedAt": "Последнее завершение", + "lastRunTook": "Последний запуск занял", + "lastFailedAt": "Последняя ошибка", + "failReason": "Причина ошибки" + } + }, + "queueFallback": { + "title": "В очереди нет заданий", + "description": "Запланированные или выполняемые проверки мониторов будут отображаться здесь. Пустая очередь означает, что всё актуально." + }, + "header": { + "title": "Журналы и очередь", + "description": "Изучайте журналы сервера, очередь заданий мониторов и диагностику в одном месте." + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "Создать окно обслуживания!", + "title": "Нет запланированных окон обслуживания", + "description": "Планируйте плановые простои, чтобы оповещения были подавлены, а команда и пользователи знали, чего ожидать." + }, + "table": { + "headers": { + "nextWindow": "Следующее окно", + "repeat": "Повторить" + } + }, + "form": { + "general": { + "title": "Основные настройки", + "description": "Задайте имя и параметры повтора для окна обслуживания.", + "option": { + "name": { + "label": "Имя", + "placeholder": "напр. Еженедельное обслуживание" + }, + "repeat": { + "label": "Повтор" + } + } + }, + "startDate": { + "title": "Дата начала", + "description": "Выберите дату начала окна обслуживания.", + "option": { + "startDate": { + "label": "Дата начала" + } + } + }, + "startTime": { + "title": "Время начала", + "description": "Задайте время начала и продолжительность окна обслуживания. Все значения указаны в UTC", + "option": { + "duration": { + "label": "Продолжительность" + }, + "startTime": { + "label": "Время начала" + } + }, + "monitors": { + "title": "Мониторы", + "description": "Мониторы, к которым применяется окно обслуживания", + "option": { + "addMonitors": { + "label": "Добавить мониторы" + } + } + } + } + }, + "header": { + "title": "Окна обслуживания", + "description": "Планируйте плановые простои, чтобы оповещения были подавлены, а команда и пользователи знали, чего ожидать." + } + }, + "notifications": { + "fallback": { + "actionButton": "Создать канал", + "title": "Пока нет каналов уведомлений", + "description": "Подключите Slack, Discord, email, вебхуки и другое, чтобы нужные люди узнавали об инцидентах сразу же." + }, + "form": { + "accessToken": { + "optionAccessToken": "Токен доступа", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "Адрес, на который будут отправляться уведомления.", + "optionAddress": "Адрес", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "Адрес" + }, + "homeServer": { + "optionHomeServer": "Домашний сервер", + "placeholder": "example.com" + }, + "matrix": { + "description": "Настройте подключение к Matrix-серверу для уведомлений.", + "title": "Настройка Matrix" + }, + "notificationName": { + "description": "Описательное имя канала уведомлений", + "optionName": "Имя канала", + "placeholder": "напр. Оповещения продакшена", + "title": "Имя канала" + }, + "pagerDuty": { + "description": "Ваш ключ интеграции PagerDuty для получения оповещений.", + "optionIntegrationKey": "Ключ интеграции", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "Ключ интеграции" + }, + "roomId": { + "optionRoomId": "ID комнаты", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "Выберите тип канала уведомлений для создания.", + "optionType": "Тип", + "title": "Тип канала" + }, + "telegram": { + "title": "Настройка Telegram", + "description": "Чтобы включить уведомления Telegram, создайте бота Telegram с помощью BotFather, официального бота для создания и управления ботами Telegram. Затем получите токен API и идентификатор чата и запишите их здесь.", + "optionBotToken": "Токен бота", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "ID чата", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Настройка Pushover", + "description": "Введите токен приложения и ключ пользователя Pushover.", + "optionAppToken": "Токен приложения", + "placeholderAppToken": "Введите токен приложения", + "optionUserKey": "Ключ пользователя", + "placeholderUserKey": "Введите ключ пользователя" + }, + "twilio": { + "title": "Настройка Twilio", + "description": "Введите учётные данные аккаунта Twilio для отправки SMS.", + "optionAccountSid": "SID аккаунта", + "placeholderAccountSid": "Введите SID аккаунта", + "optionAuthToken": "Токен аутентификации", + "placeholderAuthToken": "Введите токен аутентификации", + "optionFromNumber": "Номер отправителя", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "Номер получателя", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "Назначение" + } + }, + "header": { + "title": "Уведомления", + "description": "Подключите Slack, Discord, email, вебхуки и другое, чтобы нужные люди узнавали об инцидентах сразу же." + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "Cumulative Layout Shift (CLS)", + "fcp": "First Contentful Paint (FCP)", + "lcp": "Largest Contentful Paint (LCP)", + "si": "Speed Index (SI)", + "tbt": "Total Blocking Time (TBT)" + }, + "legend": { + "title": "Отчёт PageSpeed", + "weight": "Вес" + }, + "pie": { + "title": "Отчёт о производительности" + } + }, + "table": { + "headers": { + "pageSpeedScore": "Оценка PageSpeed" + } + }, + "fallback": { + "actionButton": "Создать монитор!", + "title": "Пока нет мониторов PageSpeed", + "description": "Отслеживайте Core Web Vitals и оценки Lighthouse во времени, чтобы заметить регрессии раньше пользователей." + }, + "header": { + "title": "Мониторы PageSpeed", + "description": "Отслеживайте Core Web Vitals и оценки Lighthouse во времени, чтобы заметить регрессии раньше пользователей." + } + }, + "settings": { + "form": { + "timezone": { + "title": "Часовой пояс отображения", + "description": "Выберите часовой пояс для отображения дат и времени в приложении.", + "option": { + "timezone": { + "label": "Часовой пояс отображения" + } + } + }, + "ui": { + "title": "Внешний вид", + "description": "Переключайтесь между светлой и тёмной темой, меняйте язык или настраивайте тип отображения графиков.", + "option": { + "theme": { + "label": "Режим темы", + "light": "Светлая", + "dark": "Тёмная" + }, + "language": { + "label": "Язык" + }, + "chartType": { + "label": "Тип графика", + "histogram": "Гистограмма", + "heatmap": "Тепловая карта" + } + } + }, + "pagespeed": { + "title": "Ключ API Google PageSpeed", + "description": "Введите ключ API Google PageSpeed для включения мониторинга Google PageSpeed. Нажмите «Сброс», чтобы обновить ключ.", + "option": { + "apiKey": { + "label": "Ключ API PageSpeed", + "labelSet": "Ключ API установлен. Нажмите «Сброс», чтобы изменить его.", + "placeholder": "Введите Ваш ключ API Google PageSpeed" + } + } + }, + "url": { + "title": "IP/URL монитора на странице статуса", + "description": "Отображать IP-адрес или URL монитора на публичной странице статуса. Если отключено, будет показано только имя монитора для защиты конфиденциальной информации.", + "option": { + "showURL": { + "label": "Отображать IP/URL на странице статуса", + "enabled": "Включено", + "disabled": "Отключено" + } + } + }, + "stats": { + "title": "История мониторинга", + "description": "Очистить всю историю мониторинга и статистику Вашей команды. Это действие необратимо.", + "option": { + "clear": { + "label": "Очистить всю статистику. Это действие необратимо." + } + }, + "dialog": { + "title": "Очистить всю историю мониторинга?", + "description": "Это действие нельзя отменить" + } + }, + "retention": { + "title": "Хранение проверок", + "description": "Укажите, как долго данные проверок хранятся перед автоматической очисткой.", + "option": { + "days": { + "label": "Период хранения (дни)", + "unlimited": "Без ограничений" + } + } + }, + "thresholds": { + "title": "Глобальные пороги", + "description": "Задайте глобальные пороги CPU, памяти, дисков и температуры для мониторинга инфраструктуры. Они применяются ко всем мониторам оборудования, если не переопределены.", + "option": { + "cpu": { + "label": "Порог CPU (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "Порог памяти (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "Порог диска (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "Порог температуры (°C)", + "placeholder": "1 - 150" + } + } + }, + "email": { + "title": "Настройки эл. почты", + "description": "Настройте параметры эл. почты для Вашей системы. Используется для отправки уведомлений и оповещений.", + "descriptionTransport": "Это создает SMTP-транспорт для NodeMailer.", + "descriptionTransportLink": "Смотрите спецификации здесь", + "option": { + "host": { + "label": "Хост эл. почты — имя хоста или IP-адрес для подключения", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "Порт эл. почты — порт для подключения", + "placeholder": "587" + }, + "address": { + "label": "Адрес эл. почты — используется для аутентификации", + "placeholder": "you@example.com" + }, + "user": { + "label": "Пользователь эл. почты — имя пользователя для аутентификации, если указано, заменяет адрес", + "placeholder": "Оставьте пустым, если не требуется" + }, + "password": { + "label": "Пароль эл. почты — пароль для аутентификации", + "labelSet": "Пароль установлен. Нажмите «Сброс», чтобы изменить его.", + "placeholder": "Введите пароль" + }, + "tlsServername": { + "label": "Имя TLS-сервера — необязательное имя хоста для проверки TLS, когда хост является IP", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "Хост подключения — имя хоста для приветствия HELO/EHLO", + "placeholder": "localhost" + }, + "secure": { + "label": "Использовать SSL (рекомендуется): шифрование подключения с помощью SSL/TLS" + }, + "pool": { + "label": "Включить пул соединений: повторное использование соединений для повышения производительности" + }, + "ignoreTLS": { + "label": "Отключить STARTTLS: не использовать TLS, даже если сервер его поддерживает" + }, + "requireTLS": { + "label": "Принудительный STARTTLS: требовать обновление до TLS, ошибка если не поддерживается" + }, + "rejectUnauthorized": { + "label": "Отклонять недействительные сертификаты: отклонять соединения с самоподписанными или ненадёжными сертификатами" + } + } + }, + "demoMonitors": { + "title": "Демо-мониторы", + "description": "Добавить примеры мониторов для демонстрации." + }, + "removeMonitors": { + "title": "Сброс системы", + "description": "Удалить все мониторы из вашей системы.", + "dialog": { + "title": "Удалить все мониторы?", + "description": "Это действие нельзя отменить.", + "paragraph": "Также будут удалены вся история проверок, инциденты, запланированные окна обслуживания и страницы состояния, использовавшие эти мониторы. Сначала экспортируйте мониторы, если хотите сохранить резервную копию их данных." + } + }, + "exportMonitors": { + "title": "Экспорт мониторов" + }, + "importExportMonitors": { + "title": "Импорт / Экспорт мониторов", + "description": "Импортируйте или экспортируйте данные мониторов в формате JSON для резервного копирования или переноса." + }, + "about": { + "title": "О программе", + "developedBy": "Разработано Bluewave Labs" + }, + "validation": { + "errorMessage": "Пожалуйста, исправьте следующие ошибки валидации:" + } + }, + "header": { + "title": "Настройки", + "description": "Настройте SMTP, интеграции, сроки хранения и общее поведение приложения." + } + }, + "statusPages": { + "deleteSuccess": "Страница статуса успешно удалена", + "fallback": { + "title": "Пока нет страниц состояния", + "actionButton": "Создать страницу статуса!", + "description": "Опубликуйте публичную страницу, показывающую клиентам и заинтересованным сторонам реальное время работы и историю инцидентов." + }, + "monitorsList": { + "chartTypeHeatmap": "Тепловая карта", + "chartTypeHistogram": "Гистограмма", + "noData": "Нет доступных данных", + "infrastructure": { + "title": "Инфраструктура", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "Память", + "disk": "Диск", + "usage": "Использование", + "used": "Использовано", + "total": "Всего" + }, + "uptime": { + "title": "Аптайм", + "responseTime": "Время ответа" + } + }, + "statusBar": { + "allDown": "Все системы недоступны", + "allUp": "Все системы работают", + "degraded": "Некоторые системы испытывают проблемы", + "unknown": "Не удаётся определить статус системы", + "allBreached": "Все системы превысили пороговые значения", + "allMaintenance": "Все системы на техническом обслуживании", + "allPaused": "Все системы приостановлены", + "allInitializing": "Все системы инициализируются", + "breachedAndDown": "Некоторые системы превысили пороги и недоступны", + "breached": "Некоторые системы превысили пороги", + "maintenanceAndDown": "Некоторые системы недоступны во время техобслуживания", + "maintenance": "Некоторые системы на техобслуживании", + "partiallyPaused": "Некоторые системы приостановлены", + "initializing": "Некоторые системы инициализируются", + "noMonitors": "Мониторы не настроены" + }, + "table": { + "headers": { + "name": "Имя страницы статуса", + "url": "Публичный URL" + }, + "published": "Опубликовано", + "unpublished": "Не опубликовано" + }, + "title": "Страницы статуса", + "details": { + "empty": { + "title": "Здесь пока ничего нет", + "addMonitor": "Добавьте монитор, чтобы начать" + } + }, + "form": { + "access": { + "title": "Доступ", + "description": "Если Ваша страница статуса готова, Вы можете отметить её как опубликованную.", + "option": { + "published": { + "name": "Опубликовано и доступно публично" + } + } + }, + "basicInfo": { + "title": "Основная информация", + "description": "Укажите название компании и субдомен, на который указывает Ваша страница статуса.", + "option": { + "name": { + "label": "Название компании", + "placeholder": "Acme Inc." + }, + "url": { + "label": "Адрес Вашей страницы статуса", + "placeholder": "my-status-page" + } + } + }, + "monitors": { + "title": "Мониторы", + "description": "Выберите мониторы для отображения на Вашей странице статуса.", + "noMonitors": "Мониторы не выбраны", + "option": { + "monitors": { + "label": "Выберите мониторы", + "placeholder": "Поиск и выбор мониторов..." + } + } + }, + "timezone": { + "title": "Часовой пояс", + "description": "Выберите часовой пояс для отображения Вашей страницы статуса.", + "option": { + "timezone": { + "label": "Часовой пояс", + "placeholder": "Выберите часовой пояс..." + } + } + }, + "appearance": { + "title": "Внешний вид", + "description": "Определите внешний вид Вашей публичной страницы статуса.", + "option": { + "logo": { + "label": "Логотип" + }, + "color": { + "label": "Цвет бренда" + } + } + }, + "features": { + "title": "Функции", + "description": "Настройте, какая информация отображается на Вашей странице статуса.", + "option": { + "showCharts": { + "label": "Показывать графики времени ответа" + }, + "showUptimePercentage": { + "label": "Показывать процент аптайма" + }, + "showAdminLoginLink": { + "label": "Показывать ссылку входа администратора" + }, + "showInfrastructure": { + "label": "Показывать метрики инфраструктуры" + } + } + } + }, + "header": { + "title": "Страницы состояния", + "description": "Публикуйте публичные страницы со временем работы и историей инцидентов для клиентов и заинтересованных сторон." + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "Поиск мониторов..." + } + }, + "table": { + "headers": { + "responseTime": "Время ответа" + } + }, + "fallback": { + "actionButton": "Создать монитор!", + "title": "Пока нет мониторов работоспособности", + "description": "Наблюдайте за HTTP-конечными точками, пингами, контейнерами и портами — получайте уведомления в момент отказа." + }, + "header": { + "title": "Мониторы работоспособности", + "description": "Наблюдайте за HTTP-конечными точками, пингами, контейнерами и портами — получайте уведомления в момент отказа." + } } } } diff --git a/client/src/locales/th.json b/client/src/locales/th.json index d5f9ad137..14e7d5947 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -1,1140 +1,1447 @@ { - "submit": "ยอมรับ", - "title": "หัวข้อ", - "distributedStatusHeaderText": "ตรวจสอบแบบเรียลไทม์บนอุปกรณ์จริง", - "distributedStatusSubHeaderText": "ระบบทำงานด้วยอุปกรณ์นับล้านทั่วโลก ให้คุณตรวจสอบประสิทธิภาพระบบตามภูมิภาค ประเทศ หรือเมืองได้", - "settingsDisabled": "ปิดการใช้งาน", - "settingsSuccessSaved": "บันทึกการตั้งค่าเรียบร้อยแล้ว", - "settingsFailedToSave": "ไม่สามารถบันทึกการตั้งค่าได้", - "settingsStatsCleared": "สถิติถูกล้างเรียบร้อยแล้ว", - "settingsFailedToClearStats": "ไม่สามารถล้างสถิติได้", - "settingsMonitorsDeleted": "ลบมอนิเตอร์ออกทั้งหมดเรียบร้อยแล้ว", - "settingsFailedToDeleteMonitors": "ไม่สามารถลบมอนิเตอร์ออกทั้งหมดได้", - "starPromptTitle": "ให้ดาวกับฉัน", - "starPromptDescription": "ดูการอัปเดตล่าสุด และมีส่วนร่วมในการขยายชุมชนบน GitHub", - "https": "HTTPS", - "http": "HTTP", - "monitor": "monitor", - "aboutus": "เกี่ยวกับฉัน", - "now": "ตอนนี้", - "delete": "ลบ", - "configure": "ตั้งค่า", - "responseTime": "ระยะเวลาการตอบสนอง", - "ms": "ms", - "bar": "Bar", - "area": "พื้นที่", - "country": "ประเทศ", - "city": "จังหวัด", - "response": "การตอบสนอง", - "monitorStatusUp": "มอนิเตอร์ {name} ({url}) สถานะตอนนี้ออนไลน์และตอบสนองปกติ", - "monitorStatusDown": "มอนิเตอร์ {name} ({url}) สถานะตอนนี้ล่มและไม่ตอบสนอง", - "webhookSendSuccess": "การแจ้งเตือนผ่าน Webhook ถูกส่งเรียบร้อยแล้ว", - "webhookSendError": "ไม่สามารถส่งการแจ้งเตือนผ่าน Webhook ไปที่ {platform} ได้", - "webhookUnsupportedPlatform": "ระบบไม่รองรับแพลตฟอร์ม {platform} นี้", - "distributedRightCategoryTitle": "มอนิเตอร์", - "distributedStatusServerMonitors": "มอนิเตอร์เชิร์ฟเวอร์", - "distributedStatusServerMonitorsDescription": "ตรวจสอบสถานะของเซิร์ฟเวอร์ที่เกี่ยวข้อง", - "distributedUptimeCreateSelectURL": "คุณสามารถระบุ URL ของโฮสต์และเลือกประเภทของตัวตรวจสอบได้ที่นี่", - "distributedUptimeCreateChecks": "รายการตรวจสอบ", - "distributedUptimeCreateChecksDescription": "คุณสามารถเพิ่มหรือลบการตรวจสอบได้เสมอหลังจากเพิ่มเว็บไซต์ของคุณแล้ว", - "distributedUptimeCreateIncidentNotification": "การแจ้งเตือนปัญหา", - "distributedUptimeCreateIncidentDescription": "เมื่อเกิดเหตุการณ์ผิดปกติ ให้แจ้งเตือนผู้ใช้งาน", - "distributedUptimeCreateAdvancedSettings": "ตั้งค่าขั้นสูง", - "distributedUptimeDetailsNoMonitorHistory": "ยังไม่มีประวัติการตรวจสอบสำหรับมอนิเตอร์นี้", - "distributedUptimeDetailsStatusHeaderUptime": "เวลาระบบทำงาน:", - "distributedUptimeDetailsStatusHeaderLastUpdate": "แก้ไขล่าสุด", - "notifications": { - "enableNotifications": "เปิดการแจ้งเตือนของแพลตฟอร์ม {{platform}} นี้", - "testNotification": "ทดสอบระบบการแจ้งเตือน", - "addOrEditNotifications": "เพิ่มและแก้ไขระบบการแจ้งเตือน", - "slack": { - "label": "Slack", - "description": "เพื่อเปิดใช้งานการแจ้งเตือนผ่าน Slack ให้สร้างแอป Slack และเปิดใช้งาน Incoming Webhooks หลังจากนั้น เพียงใส่ URL ของ Webhook ที่นี่", - "webhookLabel": "URL ของ Webhook/เว็บฮุค", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "ต้องระบุ URL ของ Slack webhook" - }, - "discord": { - "label": "Discord", - "description": "เพื่อส่งข้อมูลไปยังช่อง Discord ผ่าน Discord notifications โดยใช้ Webhooks ให้เปิดใช้งาน Incoming Webhooks ของ Discord", - "webhookLabel": "URL ของ Webhook/เว็บฮุค สำหรับ Discord", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "ต้องระบุ URL ของ Discord webhook" - }, - "telegram": { - "label": "Telegram", - "description": "เพื่อเปิดใช้งานการแจ้งเตือนผ่าน Telegram ให้สร้างบอท Telegram โดยใช้ BotFather ซึ่งเป็นบอททางการสำหรับสร้างและจัดการบอท Telegram จากนั้นนำ API token และ Chat ID มาใส่ที่นี่", - "tokenLabel": "Token/โทเคน ของบอท", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "Chat ID", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "ต้องระบุโทเค็นและรหัสแชทของ Telegram" - }, - "webhook": { - "label": "Webhooks", - "description": "คุณสามารถตั้งค่าเว็บฮุคแบบกำหนดเองเพื่อรับการแจ้งเตือนเมื่อเกิดเหตุการณ์ผิดปกติ", - "urlLabel": "URL ของ Webhook", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "ต้องระบุ URL ของ Webhook" - }, - "testNotificationDevelop": "ทดสอบการแจ้งเตือน 2", - "integrationButton": "การรวมการแจ้งเตือน", - "testSuccess": "ส่งการแจ้งเตือนทดสอบเรียบร้อยแล้ว!", - "testFailed": "ไม่สามารถส่งการแจ้งเตือนทดสอบได้", - "unsupportedType": "การแจ้งเตือนไม่ได้รับการสนับสนุนประเภทนี้", - "networkError": "เกิดข้อผิดพลาดของเครือข่าย", - "fallback": { - "title": "ช่องทางแจ้งเตือนใช้เพื่อ:", - "checks": [ - "แจ้งทีมเกี่ยวกับการหยุดทำงานหรือปัญหาประสิทธิภาพ", - "แจ้งวิศวกรเมื่อเกิดเหตุการณ์", - "ให้ผู้ดูแลระบบทราบเกี่ยวกับการเปลี่ยนแปลงของระบบ" - ], - "actionButton": "มาสร้างช่องทางการแจ้งเตือนแรกของคุณกันเถอะ!" - }, - "createButton": "สร้างช่องทางแจ้งเตือน", - "createTitle": "ช่องทางแจ้งเตือน", - "create": { - "success": "สร้างช่องทางแจ้งเตือนสำเร็จแล้ว", - "failed": "ไม่สามารถสร้างช่องทางแจ้งเตือนได้" - }, - "fetch": { - "success": "ดึงข้อมูลการแจ้งเตือนสำเร็จแล้ว", - "failed": "ไม่สามารถดึงข้อมูลการแจ้งเตือนได้" - }, - "delete": { - "success": "ลบช่องทางแจ้งเตือนสำเร็จแล้ว", - "failed": "ไม่สามารถลบช่องทางแจ้งเตือนได้" - }, - "edit": { - "success": "ปรับปรุงช่องทางแจ้งเตือนสำเร็จแล้ว", - "failed": "ไม่สามารถปรับปรุงการแจ้งเตือนได้" - }, - "test": { - "success": "ส่งการแจ้งเตือนทดสอบสำเร็จแล้ว", - "failed": "ไม่สามารถส่งการแจ้งเตือนทดสอบได้" - } - }, - "testLocale": "testLocale", - "add": "เพิ่ม", - "monitors": "monitors", - "distributedUptimeStatusCreateStatusPage": "หน้าตรวจสอบสถานะระบบ", - "distributedUptimeStatusCreateStatusPageAccess": "สิทธิ์เข้าถึง", - "distributedUptimeStatusCreateStatusPageReady": "เมื่อหน้าสถานะพร้อม คุณสามารถตั้งค่าเป็นเผยแพร่สู่สาธารณะได้", - "distributedUptimeStatusBasicInfoHeader": "ข้อมูลทั่วไป", - "distributedUptimeStatusBasicInfoDescription": "ระบุชื่อบริษัทและซับโดเมนสำหรับหน้าสถานะของคุณ", - "distributedUptimeStatusLogoHeader": "โลโก้", - "distributedUptimeStatusLogoDescription": "อัปโหลดโลโก้ให้ Status Page ของคุณ", - "distributedUptimeStatusLogoUploadButton": "อัพโหลดโลโก้", - "distributedUptimeStatusStandardMonitorsHeader": "มอนิเตอร์พื้นฐาน", - "distributedUptimeStatusStandardMonitorsDescription": "เพิ่มตัวตรวจสอบมาตรฐานไปยัง Status Page ของคุณ", - "distributedUptimeStatusCreateYour": "Create your", - "distributedUptimeStatusEditYour": "Edit your", - "distributedUptimeStatusPublishedLabel": "เผยแพร่และมองเห็นได้ต่อสาธารณะ", - "distributedUptimeStatusCompanyNameLabel": "ชื่อบริษัท", - "distributedUptimeStatusPageAddressLabel": "ที่อยู่หน้าสถานะของคุณ", - "distributedUptimeStatus30Days": "30 วัน", - "distributedUptimeStatus60Days": "60 วัน", - "distributedUptimeStatus90Days": "90 วัน", - "distributedUptimeStatusPageNotSetUp": "หน้าสถานะยังไม่ถูกสร้างขึ้น", - "distributedUptimeStatusContactAdmin": "กรุณาติดต่อผู้ดูแลระบบของคุณ เพื่อขอความช่วยเหลือ", - "distributedUptimeStatusPageNotPublic": "หน้าสถานะนี้ไม่เปิดเผยต่อสาธารณะ", - "distributedUptimeStatusPageDeleteDialog": "คุณต้องการลบหน้าสถานะนี้หรือไม่?", - "distributedUptimeStatusPageDeleteConfirm": "ใช่ ฉันต้องการลบหน้าสถานะนี้", - "distributedUptimeStatusPageDeleteDescription": "เมื่อถูกลบแล้ว หน้าสถานะของคุณจะไม่สามารถกู้คืนได้", - "distributedUptimeStatusDevices": "อุปกรณ์", - "distributedUptimeStatusUpt": "UPT", - "distributedUptimeStatusUptBurned": "UPT Burned", - "distributedUptimeStatusUptLogo": "Upt Logo", - "incidentsTableNoIncidents": "ยังไม่มีเหตุการณ์ผิดปกติบันทึกไว้", - "incidentsTablePaginationLabel": "เหตุการณ์ผิดปกติ", - "incidentsTableMonitorName": "ชื่อมอนิเตอร์", - "incidentsTableStatus": "สถานะ", - "incidentsTableDateTime": "วันและเวลา", - "incidentsTableStatusCode": "รหัสสถานะ", - "incidentsTableMessage": "ข้อความ", - "incidentsOptionsHeader": "เหตุการณ์ผิดปกติสำหรับ:", - "incidentsOptionsHeaderFilterBy": "กรองโดย:", - "incidentsOptionsHeaderFilterAll": "ท้ังหมด", - "incidentsOptionsHeaderFilterDown": "ล่ม", - "incidentsOptionsHeaderFilterCannotResolve": "ไม่แก้ไข/หาสาเหตุไม่ได้", - "incidentsOptionsHeaderShow": "แสดง:", - "incidentsOptionsHeaderLastHour": "ชั่วโมงที่แล้ว", - "incidentsOptionsHeaderLastDay": "วันที่แล้ว", - "incidentsOptionsHeaderLastWeek": "สัปดาห์ที่แล้ว", - "incidentsOptionsPlaceholderAllServers": "เชิร์ฟเวอร์ทั้งหมด", - "infrastructureCreateYour": "สร้าง", - "infrastructureCreateGeneralSettingsDescription": "คุณสามารถเลือก URL ของโฮสต์ พร้อมกับชื่อที่เป็นมิตร Friendly Name และรหัสลับการอนุญาตเพื่อเชื่อมต่อกับตัวแทนเซิร์ฟเวอร์ Server Agent", - "infrastructureServerRequirement": "เซิร์ฟเวอร์ที่คุณมอนิเตอร์ต้องกำลังทำงานด้วย", - "infrastructureCustomizeAlerts": "ปรับแต่งการแจ้งเตือน", - "infrastructureAlertNotificationDescription": "ส่งการแจ้งเตือนถึงผู้ใช้เมื่อค่าขีดจำกัดเกินเปอร์เซ็นต์ที่กำหนด", - "infrastructureCreateMonitor": "สร้าง Infrastructure Monitor", - "infrastructureProtocol": "โปรโตคอล", - "infrastructureServerUrlLabel": "URL ของเซิร์ฟเวอร์", - "infrastructureDisplayNameLabel": "ชื่อที่แสดง", - "infrastructureAuthorizationSecretLabel": "Authorization secret / คีย์ลับสำหรับการยืนยันตัวตน", - "gb": "กิกะไบต์ GB", - "mb": "เมกะไบต์ MB", - "mem": "แรม", - "memoryUsage": "การใช้งานแรม", - "cpu": "ซีพียู", - "cpuUsage": "การใช้งาน CPU", - "cpuTemperature": "อุณหภูมิ CPU", - "diskUsage": "การใช้งาน Disk", - "used": "ใช้งาน", - "total": "ทั้งหมด", - "cores": "คอร์", - "frequency": "ความถี่", - "status": "สถานะ", - "cpuPhysical": "ซีพียู (คอร์หลัก)", - "cpuLogical": "ซีพียู (คอร์เสมือน)", - "cpuFrequency": "ความถี่ของ CPU", - "avgCpuTemperature": "อุณหภูมิเฉลี่ยของซีพียู", - "memory": "แรม", - "disk": "ดิส/พื้นที่", - "uptime": "", - "os": "ระบบปฏิบัติการ", - "host": "โฮส", - "actions": "จัดการ", - "integrations": "การรวมระบบ", - "integrationsPrism": "เชื่อมต่อ Prism กับบริการที่คุณชื่นชอบ", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "เชื่อมต่อกับ Slack และดูเหตุการณ์ผิดปกติในช่องทาง", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "เชื่อมต่อกับ Discord และดูเหตุการณ์ผิดปกติได้โดยตรงในช่องทาง", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "ส่งเหตุการณ์ทั้งหมดไปยัง Zapier แล้วสามารถดูได้ทุกที่", - "commonSave": "บันทึก", - "createYour": "Create your", - "createMonitor": "", - "pause": "พักชั่วคราว", - "resume": "ดำเนินการต่อ", - "editing": "กำลังปรับปรุง…", - "url": "URL", - "access": "การเข้าถึง", - "timezone": "เขตเวลา", - "features": "ฟีเจอร์", - "administrator": "ผู้ดูแลระบบ", - "loginHere": "กรุณาเข้าสู่ระบบที่นี่", - "displayName": "ชื่อที่แสดง", - "urlMonitor": "ลิงก์ที่จะมอนิเตอร์", - "portToMonitor": "พอร์ตที่จะมอนิเตอร์", - "websiteMonitoring": "เว็บไชต์ที่จะมอนิเตอร์", - "websiteMonitoringDescription": "ใช้ HTTP(s) เพื่อตรวจสอบเว็บไซต์หรือ API endpoint ของคุณ", - "pingMonitoring": "มอนิเตอร์แบบ Ping", - "pingMonitoringDescription": "ตรวจสอบว่าเซิร์ฟเวอร์ของคุณพร้อมใช้งานหรือไม่", - "dockerContainerMonitoring": "มอนิเตอร์คอนเทนเนอร์ใน Docker", - "dockerContainerMonitoringDescription": "ตรวจสอบว่าคอนเทนเนอร์ Docker ของคุณกำลังทำงานอยู่หรือไม่", - "portMonitoring": "มอนิเตอร์แบบ Ping", - "portMonitoringDescription": "ตรวจสอบว่าพอร์ตของคุณเปิดอยู่หรือไม่", - "createMaintenanceWindow": "สร้างช่วงเวลาบำรุงรักษา", - "createMaintenance": "สร้างการบำรุงรักษา", - "editMaintenance": "แก้ไขการบำรุงรักษา", - "maintenanceWindowName": "ชื่อหน้าบำรุงรักษา", - "friendlyNameInput": "ชื่อแสดงผล", - "friendlyNamePlaceholder": "Maintenance เริ่มต้นเวลา __ : __ และใช้เวลา ___ นาที", - "maintenanceRepeat": "ทำซ้ำการบำรุงรักษา", - "maintenance": "การบำรุงรักษา", - "duration": "ระยะเวลา", - "addMonitors": "เพิ่มมอนิเตอร์", - "window": "ช่วงเวลา", - "cancel": "ยกเลิก", - "message": "ข้อความ", - "low": "Low", - "high": "High", - "statusCode": "รหัสสถานะ", - "date&Time": "วันที่และเวลา", - "type": "ประเภท", - "statusPageName": "ชื่อ Status Page", - "publicURL": "URL สาธารณะ", - "repeat": "ทำซ้ำ", - "edit": "แก้ไข", - "createA": "สร้าง A", - "remove": "ลบ", - "maintenanceWindowDescription": "ช่วงเวลาการบำรุงรักษา การตรวจสอบทั้งหมดของมอนิเตอร์ที่ถูกเลือกจะถูกระงับ ไม่ทำการตรวจสอบเครือข่ายใด ๆ ทำให้ไม่มีการอัปเดตสถานะหรือแจ้งเตือนเกิดขึ้น มอนิเตอร์ของคุณจะคงแสดงสถานะล่าสุดที่ทราบ และหน้าสถานะจะมีสัญลักษณ์บ่งบอกว่ากำลังอยู่ระหว่างการบำรุงรักษา เมื่อเวลาการบำรุงรักษาสิ้นสุด การตรวจสอบจะกลับมาทำงานโดยอัตโนมัติ และหากพบปัญหา การแจ้งเตือนจะถูกส่งออก ช่วงเวลาการบำรุงรักษาจะไม่ถูกนับรวมในการคำนวณความพร้อมใช้งาน", - "startTime": "เวลาเริ่มต้น", - "timeZoneInfo": "วันที่และเวลาทั้งหมดเป็นเวลาตามโซน GMT+0", - "monitorsToApply": "มอนิเตอร์ที่จะใช้ช่วงเวลาการบำรุงรักษา", - "nextWindow": "ช่วงเวลาถัดไป", - "notFoundButton": "ไปยังหน้าจอแดชบอร์ดหลัก", - "pageSpeedConfigureSettingsDescription": "คุณสามารถเลือก URL ของโฮสต์ พร้อมกับประเภทของมอนิเตอร์ได้", - "monitorDisplayName": "ชื่อที่แสดงของมอนิเตอร์", - "whenNewIncident": "เมื่อเกิดเหตุการณ์ใหม่", - "notifySMS": "แจ้งเตือนทางSMS (กำลังจะมา)", - "notifyEmails": "แจ้งเตือนทางอีเมลไปยังหลายที่อยู่ด้วย (กำลังจะมา)", - "seperateEmails": "คุณสามารถแยกอีเมลหลายอันด้วยเครื่องหมายจุลภาค , ได้", - "checkFrequency": "ความถี่ในการตรวจสอบ", - "matchMethod": "วิธีการจับคู่", - "expectedValue": "ค่าที่คาดหวัง", - "deleteDialogTitle": "คุณแน่ใจหรือว่าต้องการลบมอนิเตอร์นี้ไหม", - "deleteDialogDescription": "เมื่อลบแล้ว มอนิเตอร์นี้ไม่สามารถกู้คืนได้", - "pageSpeedMonitor": "มอนิเตอร์ความเร็วหน้าเว็บ", - "shown": "แสดง", - "ago": "ที่ผ่านมา", - "companyName": "ชื่อบริษัท", - "pageSpeedDetailsPerformanceReport": "เป็นค่าโดยประมาณและอาจแตกต่างกันไป", - "pageSpeedDetailsPerformanceReportCalculator": "ดูเครื่องคิดเลข", - "checkingEvery": "ตรวจสอบทุก", - "statusPageCreateSettings": "หากหน้าสถานะของคุณพร้อมแล้ว คุณสามารถทำเครื่องหมายว่าเผยแพร่แล้วได้", - "basicInformation": "ข้อมูลพื้นฐาน", - "statusPageCreateBasicInfoDescription": "กำหนดชื่อบริษัทและซับโดเมนที่หน้าสถานะของคุณชี้ไป", - "statusPageCreateSelectTimeZoneDescription": "เลือกเขตเวลาที่หน้าสถานะของคุณจะแสดง", - "statusPageCreateAppearanceDescription": "กําหนดรูปลักษณ์เริ่มต้นของหน้าสถานะสาธารณะของคุณ", - "statusPageCreateSettingsCheckboxLabel": "เผยแพร่และเปิดเผยต่อสาธารณะ", - "statusPageCreateBasicInfoStatusPageAddress": "ที่อยู่หน้าสถานะของคุณ", - "statusPageCreateTabsContent": "หน้าสถานะเซิร์ฟเวอร์", - "statusPageCreateTabsContentDescription": "คุณสามารถเพิ่มเซิร์ฟเวอร์ที่คุณตรวจสอบเข้าไปยังหน้าสถานะของคุณได้จำนวนเท่าใดก็ได้ และสามารถจัดลำดับใหม่เพื่อการแสดงผลที่ดีที่สุด", - "statusPageCreateTabsContentFeaturesDescription": "แสดงรายละเอียดเพิ่มเติมบนหน้าสถานะ", - "showCharts": "แสดงแผนภูมิ", - "showUptimePercentage": "แสดงเปอร์เซ็นต์ความพร้อมใช้งาน", - "removeLogo": "ลบโลโก้", - "statusPageStatus": "ยังไม่ได้ตั้งค่าหน้าสถานะสาธารณะ", - "statusPageStatusContactAdmin": "กรุณาติดต่อผู้ดูแลระบบของคุณ", - "statusPageStatusNotPublic": "หน้าสถานะนี้ไม่เป็นสาธารณะ", - "statusPageStatusNoPage": "ที่นี่ยังไม่มีหน้าสถานะ", - "statusPageStatusServiceStatus": "สถานะบริการ", - "deleteStatusPage": "คุณต้องการลบหน้าสถานะนี้หรือไม่ไหม?", - "deleteStatusPageConfirm": "ใช่ ลบหน้าสถานะ", - "deleteStatusPageDescription": "เมื่อลบแล้ว หน้าสถานะของคุณไม่สามารถกู้คืนได้", - "uptimeCreate": "ค่าที่คาดหวังถูกใช้เพื่อตรวจสอบผลลัพธ์การตอบกลับ และการจับคู่จะเป็นตัวกำหนดสถานะ", - "uptimeCreateJsonPath": "นิพจน์นี้จะถูกประเมินกับข้อมูล JSON ที่ตอบกลับ และผลลัพธ์จะถูกใช้เพื่อตรวจสอบกับค่าที่คาดหวัง ดู", - "uptimeCreateJsonPathQuery": "for query language documentation.", - "maintenanceTableActionMenuDialogTitle": "คุณแน่ใจหรือว่าต้องการลบช่วงเวลาการบำรุงรักษานี้?", - "infrastructureEditYour": "Edit your", - "infrastructureEditMonitor": "บันทึก Infrastructure มอนิเตอร์", - "infrastructureMonitorCreated": "สร้างมอนิเตอร์ Infrastructure เรียบร้อยแล้ว!", - "infrastructureMonitorUpdated": "อัพเดทมอนิเตอร์ Infrastructure เรียบร้อยแล้ว!", - "errorInvalidTypeId": "ประเภทการแจ้งเตือนที่ระบุไม่ถูกต้อง", - "errorInvalidFieldId": "รหัสฟิลด์ที่ระบุไม่ถูกต้อง", - "inviteNoTokenFound": "ไม่พบโทเค็นคำเชิญ", - "pageSpeedWarning": "คำเตือน: คุณยังไม่ได้เพิ่มคีย์ Google PageSpeed API หากไม่มีคีย์นี้ มอนิเตอร์ความเร็วหน้าเว็บจะไม่ทำงาน", - "pageSpeedLearnMoreLink": "คลิกที่นี่", - "pageSpeedAddApiKey": "เพื่อเพิ่มคีย์ API ของคุณ", - "update": "อัปเดต", - "invalidFileFormat": "รูปแบบไฟล์ไม่รองรับ!", - "invalidFileSize": "ขนาดไฟล์ใหญ่เกินไป!", - "ClickUpload": "คลิกเพื่ออัปโหลด", - "DragandDrop": "ลากและวางเลย", - "MaxSize": "ขนาดสูงสุด", - "SupportedFormats": "รูปแบบที่รองรับ", - "FirstName": "ชื่อจริง", - "LastName": "นามสกุล", - "EmailDescriptionText": "นี่คือที่อยู่อีเมลปัจจุบันของคุณ — ไม่สามารถแก้ไขได้", - "YourPhoto": "รูปโปรไฟล์", - "PhotoDescriptionText": "รูปนี้จะแสดงบนหน้าประวัติของคุณ", - "save": "บันทึก", - "DeleteDescriptionText": "การทำเช่นนี้จะลบบัญชีและข้อมูลทั้งหมดที่เกี่ยวข้องบนเซิร์ฟเวอร์ ไม่สามารถกู้คืนได้", - "DeleteAccountWarning": "การลบบัญชีหมายความว่าคุณจะไม่สามารถเข้าสู่ระบบอีก และข้อมูลทั้งหมดของคุณจะถูกลบ ไม่สามารถกู้คืนได้", - "DeleteWarningTitle": "คุณแน่ใจหรือว่าต้องการลบบัญชีนี้?", - "bulkImport": { - "title": "นำเข้าจำนวนมาก", - "selectFileTips": "เลือกไฟล์ CSV เพื่อนำเข้า", - "selectFileDescription": "คุณสามารถดาวน์โหลด หรือ sample ของเราได้", - "selectFile": "เลือกไฟล์", - "parsingFailed": "การแยกวิเคราะห์ล้มเหลว", - "uploadSuccess": "สร้างมอนิเตอร์เรียบร้อยแล้ว!", - "validationFailed": "การตรวจสอบล้มเหลว", - "noFileSelected": "ยังไม่ได้เลือกไฟล์", - "fallbackPage": "นำเข้าไฟล์เพื่ออัปโหลดรายการเซิร์ฟเวอร์เป็นกลุ่ม", - "invalidFileType": "ประเภทไฟล์ไม่ถูกต้อง", - "uploadFailed": "อัปโหลดล้มเหลว" - }, - "DeleteAccountTitle": "ลบบัญชี", - "DeleteAccountButton": "ลบบัญชี", - "publicLink": "ลิงก์สาธารณะ", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "reset": "รีเช็ต", - "ignoreTLSError": "ละเว้นข้อผิดพลาด TLS/SSL", - "tlsErrorIgnored": "ละเว้นข้อผิดพลาด TLS/SSL แล้ว", - "ignoreTLSErrorDescription": "ละเว้นข้อผิดพลาด TLS/SSL และดำเนินการตรวจสอบความพร้อมใช้งานของเว็บไซต์ต่อ", - "createNew": "สร้างใหม่", - "greeting": { - "prepend": "สวัสดี", - "append": "ช่วงบ่ายคือเวลาเล่นของคุณ—มาทำให้สุดยอดกันเถอะ!", - "overview": "นี่คือภาพรวมระบบมอนิเตอร์ประเภท {{type}} ของคุณ" - }, - "roles": { - "superAdmin": "Super admin", - "admin": "แอดมิน", - "teamMember": "สมาชิกในทีม", - "demoUser": "ผู้ใช้ Demo" - }, - "teamPanel": { - "teamMembers": "สมาชิกในทีม", - "filter": { - "all": "ทั้งหมด", - "member": "สมาชิก" - }, - "inviteTeamMember": "เชิญสมาชิกเข้าทีม", - "inviteNewTeamMember": "เชิญสมาชิกทีมใหม่", - "inviteDescription": "เมื่อคุณเพิ่มสมาชิกทีมใหม่ เขาจะสามารถเข้าถึงมอนิเตอร์ทั้งหมดได้", - "email": "อีเมล", - "selectRole": "เลือกบทบาท", - "inviteLink": "ลิงก์เชิญ", - "cancel": "ยกเลิก", - "noMembers": "ยังไม่มีสมาชิกทีมในบทบาทนี้", - "getToken": "รับโทเค็น", - "emailToken": "Token ทางอีเมล", - "table": { - "name": "ชื่อ", - "email": "อีเมล", - "role": "บทบาท", - "created": "สร้างเมื่อ" - }, - "addTeamMember": { - "addMemberMenu": "เพิ่มสมาชิกทีม", - "title": "ลงทะเบียนสมาชิกทีมใหม่", - "description": "สร้างผู้ใช้ใหม่และแชร์ข้อมูลการเข้าสู่ระบบให้กับพวกเขา วิธีนี้จะทำให้สมาชิกสามารถเข้าถึงตัวตรวจสอบทั้งหมดได้ทันที", - "addButton": "เพิ่มสมาชิก" - }, - "register": "ลงทะเบียนสมาชิกทีม", - "registerToast": { - "success": "สร้างผู้ใช้เรียบร้อยแล้ว กรุณาแชร์ข้อมูลการเข้าสู่ระบบให้สมาชิกอย่างปลอดภัย", - "dbUserExists": "ผู้ใช้นี้มีอยู่แล้ว", - "unknownError": "เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ" - }, - "registerTeamMember": { - "title": "ลงทะเบียนสมาชิกทีม", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "กรุณากรอกชื่อ", - "pattern": "ชื่อต้องมีเฉพาะตัวอักษร เว้นวรรค เครื่องหมายอัญประกาศ หรือขีดกลาง" - } - }, - "lastName": { - "errors": { - "empty": "กรุณากรอกนามสกุล", - "pattern": "นามสกุลต้องมีเฉพาะตัวอักษร เว้นวรรค เครื่องหมายอัญประกาศ หรือขีดกลาง" - } - }, - "email": { - "errors": { - "empty": "กรุณากรอกอีเมลเพื่อดำเนินการต่อ", - "invalid": "กรุณาตรวจสอบความถูกต้องของอีเมลอีกครั้ง" - } - }, - "role": { - "errors": { - "empty": "ต้องระบุบทบาท" - } - } - } - } - } - }, - "role": "บทบาท", - "changeTeamPassword": { - "changePasswordMenu": "", - "title": "", - "description": "", - "success": "" - } - }, - "monitorState": { - "paused": "หยุดชั่วคราว", - "resumed": "ดำเนินการต่อ", - "active": "ใช้งานอยู่" - }, - "menu": { - "uptime": "ความพร้อมใช้งาน", - "pagespeed": "ความเร็วหน้าเว็บ", - "infrastructure": "โครงสร้างพื้นฐาน", - "incidents": "เหตุการณ์", - "statusPages": "หน้าสถานะ", - "maintenance": "การบำรุงรักษา", - "integrations": "การรวมระบบ", - "settings": "การตั้งค่า", - "support": "การสนับสนุน", - "discussions": "การสนทนา", - "docs": "เอกสาร", - "changelog": "บันทึกการเปลี่ยนแปลง", - "profile": "โปรไฟล์", - "password": "รหัสผ่าน", - "team": "ทีม", - "logOut": "ออกจากระบบ", - "notifications": "การแจ้งเตือน", - "logs": "Logs" - }, - "settingsEmailUser": "อีเมลผู้ใช้ - ชื่อผู้ใช้สำหรับการยืนยันตัวตน จะใช้แทนอีเมลหากระบุไว้", - "state": "สถานะ", - "statusBreadCrumbsStatusPages": "หน้าสถานะ", - "statusBreadCrumbsDetails": "รายละเอียด", - "commonSaving": "กำลังบันทึก...", - "navControls": "การควบคุม", - "incidentsPageTitle": "เหตุการณ์", - "passwordPanel": { - "passwordChangedSuccess": "รหัสผ่านของคุณถูกเปลี่ยนเรียบร้อยแล้ว", - "passwordInputIncorrect": "คุณป้อนรหัสผ่านที่ไม่ถูกต้อง", - "currentPassword": "รหัสผ่านปัจจุบัน", - "enterCurrentPassword": "กรอกรหัสผ่านปัจจุบันของคุณ", - "newPassword": "รหัสผ่านใหม่", - "enterNewPassword": "กรอกรหัสผ่านใหม่ของคุณ", - "confirmNewPassword": "ยืนยันรหัสผ่านใหม่อีกครั้ง", - "passwordRequirements": "รหัสผ่านใหม่ต้องมีอย่างน้อย 8 ตัวอักษร และต้องมีตัวอักษรใหญ่ ตัวอักษรเล็ก ตัวเลข และอักขระพิเศษอย่างละอย่าง 1", - "saving": "กำลังบันทึก..." - }, - "emailSent": "ส่งอีเมลเรียบร้อยแล้ว", - "failedToSendEmail": "ส่งอีเมลล้มเหลว", - "settingsTestEmailSuccess": "ส่งอีเมลทดสอบเรียบร้อยแล้ว", - "settingsTestEmailFailed": "ส่งอีเมลทดสอบล้มเหลว", - "settingsTestEmailFailedWithReason": "ส่งอีเมลทดสอบล้มเหลว: {{reason}}", - "settingsTestEmailUnknownError": "ข้อผิดพลาดไม่ทราบสาเหตุ", - "statusMsg": { - "paused": "การตรวจสอบถูกพักชั่วคราว", - "up": "เว็บไซต์ของคุณใช้งานได้", - "down": "เว็บไซต์ของคุณล่ม", - "pending": "รอดำเนินการ..." - }, - "uptimeGeneralInstructions": { - "http": "กรอก URL หรือ IP ที่ต้องการตรวจสอบ (เช่น https://example.com/\n หรือ 192.168.1.100) และตั้งชื่อที่ชัดเจนให้แสดงบนแดชบอร์ด", - "ping": "กรอกที่อยู่ IP หรือชื่อโฮสต์ที่ต้องการ ping (เช่น 192.168.1.100 หรือ example.com) และตั้งชื่อที่ชัดเจนให้แสดงบนแดชบอร์ด", - "docker": "กรอกชื่อหรือ ID ของคอนเทนเนอร์ Docker คุณสามารถใช้ชื่อคอนเทนเนอร์ (เช่น my-app) หรือ ID ของคอนเทนเนอร์ (ID เต็ม 64 ตัวอักษร หรือ ID สั้น)", - "port": "กรอก URL หรือ IP ของเซิร์ฟเวอร์ หมายเลขพอร์ต และตั้งชื่อที่ชัดเจนให้แสดงบนแดชบอร์ด", - "game": "กรอกที่อยู่ IP หรือชื่อโฮสต์และหมายเลขพอร์ตเพื่อทำการ ping (เช่น 192.168.1.100 หรือ example.com) และเลือกประเภทเกม", - "https": "" - }, "common": { - "appName": "Checkmate", - "monitoringAgentName": "Capture", - "buttons": { - "toggleTheme": "สลับโหมดสว่าง & มืด" + "auth": { + "roles": { + "admin": "ผู้ดูแลระบบ", + "demo": "สาธิต", + "superadmin": "ผู้ดูแลระบบสูงสุด", + "user": "ผู้ใช้" + } }, - "toasts": { - "networkError": "ข้อผิดพลาดเครือข่าย", - "checkConnection": "กรุณาตรวจสอบการเชื่อมต่อของคุณ", - "unknownError": "ข้อผิดพลาดไม่ทราบสาเหตุ" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "ดำเนินการต่อ", - "back": "ย้อนกลับ" + "alerts": { + "pageSpeedApiKey": { + "content": "คำเตือน: คุณยังไม่ได้เพิ่ม API key ของ Google PageSpeed ไปที่การตั้งค่าเพื่อเพิ่ม หากไม่มี ระบบตรวจสอบ PageSpeed จะไม่ทำงาน" + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "รายละเอียด", + "home": "หน้าหลัก" + }, + "buttons": { + "addMember": "เพิ่มสมาชิก", + "cancel": "ยกเลิก", + "close": "ปิด", + "configure": "ตั้งค่า", + "confirm": "ยืนยัน", + "create": "สร้าง", + "delete": "ลบ", + "generateToken": "สร้างโทเค็น", + "incidents": "เหตุการณ์", + "inviteMember": "เชิญสมาชิก", + "pause": "หยุดชั่วคราว", + "resume": "ดำเนินการต่อ", + "save": "บันทึก", + "sendInvite": "ส่งคำเชิญ", + "test": "ทดสอบ", + "testNotifications": "ทดสอบการแจ้งเตือน", + "toggleTheme": "สลับโหมดสว่าง & มืด", + "flushQueue": "ล้างคิว", + "notFound": "ไปยังแดชบอร์ดหลัก", + "resetPassword": "รีเซ็ตรหัสผ่าน", + "reset": "รีเช็ต", + "clear": "ล้าง", + "addDemo": "เพิ่มมอนิเตอร์สาธิต", + "removeMonitors": "ลบมอนิเตอร์", + "sendTestEmail": "ส่งอีเมลทดสอบ", + "exportToJSON": "ส่งออกเป็น JSON", + "importFromJSON": "นำเข้าจาก JSON", + "clearFilters": "ล้างตัวกรอง", + "removeUser": "ลบผู้ใช้" + }, + "charts": { + "labels": { + "averageResponseTime": "เวลาตอบสนองเฉลี่ย", + "downtime": "ดาวน์ไทม์", + "high": "สูง", + "low": "ต่ำ", + "uptime": "อัปไทม์" }, - "inputs": { - "email": { - "label": "อีเมล", - "placeholder": "jordan.ellis@domain.com", - "errors": { - "empty": "เพื่อดำเนินการต่อ กรุณากรอกที่อยู่อีเมลของคุณ", - "invalid": "กรุณาตรวจสอบความถูกต้องของอีเมลที่กรอก" - } - }, - "password": { - "label": "รหัสผ่าน", - "rules": { - "length": { - "beginning": "ต้องมีความยาวอย่างน้อย", - "highlighted": "8 ตัวอักษร" - }, - "special": { - "beginning": "ต้องมีอย่างน้อย", - "highlighted": "อักขระพิเศษ 1 ตัว" - }, - "number": { - "beginning": "ต้องมีอย่างน้อย", - "highlighted": "ตัวเลข 1 ตัว" - }, - "uppercase": { - "beginning": "ต้องมีอย่างน้อย", - "highlighted": "ตัวอักษรใหญ่ 1 ตัว" - }, - "lowercase": { - "beginning": "ต้องมีอย่างน้อย", - "highlighted": "ตัวอักษรเล็ก 1 ตัว" - }, - "match": { - "beginning": "รหัสผ่าน", - "highlighted": "ต้องตรงกัน" - } - }, - "errors": { - "empty": "กรุณากรอกรหัสผ่านของคุณ", - "length": "รหัสผ่านต้องมีความยาวอย่างน้อย 8 ตัวอักษร", - "uppercase": "รหัสผ่านต้องมีตัวอักษรใหญ่อย่างน้อย 1 ตัว", - "lowercase": "รหัสผ่านต้องมีตัวอักษรเล็กอย่างน้อย 1 ตัว", - "number": "รหัสผ่านต้องมีตัวเลขอย่างน้อย 1 ตัว", - "special": "รหัสผ่านต้องมีอักขระพิเศษอย่างน้อย 1 ตัว", - "incorrect": "รหัสผ่านที่คุณกรอกไม่ตรงกับข้อมูลของเรา" - } - }, - "passwordConfirm": { - "label": "ยืนยันรหัสผ่าน", - "placeholder": "กรอกรหัสผ่านอีกครั้งเพื่อยืนยัน", - "errors": { - "empty": "กรุณากรอกรหัสผ่านอีกครั้งเพื่อยืนยัน (ช่วยลดข้อผิดพลาดจากการพิมพ์ผิด)", - "different": "รหัสผ่านที่กรอกไม่ตรงกัน อาจมีตัวใดตัวหนึ่งพิมพ์ผิด" - } - }, - "firstName": { - "label": "ชื่อ", - "placeholder": "Brownyroll", - "errors": { - "empty": "กรุณากรอกชื่อของคุณ", - "length": "ชื่อต้องมีความยาวไม่เกิน 50 ตัวอักษร", - "pattern": "ชื่อต้องประกอบด้วยตัวอักษร ช่องว่าง เครื่องหมาย ' หรือ - เท่านั้น" - } - }, - "lastName": { - "label": "นามสกุล", - "placeholder": ".", - "errors": { - "empty": "กรุณากรอกนามสกุลของคุณ", - "length": "นามสกุลต้องมีความยาวไม่เกิน 50 ตัวอักษร", - "pattern": "นามสกุลต้องประกอบด้วยตัวอักษร ช่องว่าง เครื่องหมาย ' หรือ - เท่านั้น" + "histogram": { + "avg": "เฉลี่ย: {{value}} ms", + "max": "สูงสุด: {{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "การกระทำนี้ไม่สามารถย้อนกลับได้", + "title": "คุณแน่ใจหรือไม่ว่าต้องการลบรายการนี้?" + } + }, + "labels": { + "active": "ใช้งานอยู่", + "paused": "หยุดชั่วคราว", + "na": "ไม่มีข้อมูล", + "resolved": "แก้ไขแล้ว", + "responseTime": "เวลาตอบสนอง" + }, + "form": { + "role": { + "option": { + "role": { + "label": "บทบาท" } } }, - "errors": { - "validation": "เกิดข้อผิดพลาดในการตรวจสอบข้อมูล" - }, - "fields": { - "password": { - "errors": { - "incorrect": "รหัสผ่านที่คุณป้อนไม่ตรงกับข้อมูลของเรา" + "name": { + "option": { + "firstName": { + "label": "ชื่อ", + "placeholder": "Brownyroll" + }, + "lastName": { + "label": "นามสกุล", + "placeholder": "." } - }, - "role": { - "errors": { - "min": "ต้องมีอย่างน้อยหนึ่งบทบาท" + } + }, + "email": { + "option": { + "email": { + "label": "อีเมล", + "placeholder": "you@example.com" } } } }, - "login": { - "heading": "เข้าสู่ระบบเพื่อดำเนินการต่อ", - "subheadings": { - "stepOne": "กรอกอีเมลของคุณ", - "stepTwo": "กรอกรหัสผ่านของคุณ" - }, - "links": { - "forgotPassword": "ลืมรหัสผ่าน?", - "register": "ยังไม่มีบัญชีใช่ไหม?", - "forgotPasswordLink": "รีเซ็ตรหัสผ่าน", - "registerLink": "สมัครสมาชิกที่นี่" - }, - "toasts": { - "success": "ยินดีต้อนรับกลับ! คุณเข้าสู่ระบบเรียบร้อยแล้ว", - "incorrectPassword": "รหัสผ่านไม่ถูกต้อง" - }, + "table": { + "empty": "ไม่มีข้อมูล", + "headers": { + "actions": "จัดการ", + "dateTime": "วันที่และเวลา", + "message": "ข้อความ", + "monitor": "มอนิเตอร์", + "monitorId": "รหัสมอนิเตอร์", + "name": "ชื่อ", + "status": "สถานะ", + "type": "ประเภท", + "url": "URL", + "interval": "ช่วงเวลา", + "active": "ใช้งานอยู่", + "responseTime": "เวลาตอบสนอง" + } + } + }, + "components": { + "imageUpload": { + "clickToUpload": "คลิกเพื่ออัปโหลด", + "dragAndDrop": "ลากและวางเลย", + "supportedFormats": "รูปแบบที่รองรับ", + "maxSize": "ขนาดสูงสุด", + "orDragAndDrop": "หรือลากและวาง", "errors": { - "password": { - "incorrect": "รหัสผ่านที่คุณป้อนไม่ตรงกับข้อมูลของเรา" - } - }, - "welcome": "ยินดีต้อนรับกลับสู่ Checkmate!" + "invalidFileSize": "ขนาดไฟล์ใหญ่เกินไป!", + "invalidFileFormat": "รูปแบบไฟล์ไม่รองรับ!" + } }, - "registration": { - "heading": { - "superAdmin": "สร้าง Super Admin", - "user": "สมัครสมาชิก" - }, - "subheadings": { - "stepOne": "กรอกรายละเอียดส่วนตัวของคุณ", - "stepTwo": "กรอกอีเมลของคุณ", - "stepThree": "สร้างรหัสผ่านของคุณ" + "headerStatusPageControls": { + "publicLink": "ลิงก์สาธารณะ" + }, + "headerTimeRange": { + "labels": { + "day": "วัน", + "month": "เดือน", + "recent": "ล่าสุด", + "week": "สัปดาห์" }, "description": { - "superAdmin": "สร้างบัญชี Super Admin เพื่อเริ่มต้นใช้งาน", - "user": "สมัครสมาชิกผู้ใช้และขอสิทธิ์เข้าถึงมอนิเตอร์จาก Super Admin" + "recent": "แสดงสถิติย้อนหลัง 2 ชั่วโมง", + "day": "แสดงสถิติย้อนหลัง 24 ชั่วโมง", + "week": "แสดงสถิติย้อนหลัง 7 วัน", + "month": "แสดงสถิติย้อนหลัง 30 วัน" + } + }, + "offlineBanner": { + "serverUnreachable": "ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์ได้", + "retry": "ลองอีกครั้ง", + "retrying": "กำลังลองอีกครั้ง...", + "reconnected": "เชื่อมต่อกับเซิร์ฟเวอร์สำเร็จแล้ว" + }, + "sidebar": { + "menu": { + "uptime": "ความพร้อมใช้งาน", + "pagespeed": "ความเร็วหน้าเว็บ", + "infrastructure": "โครงสร้างพื้นฐาน", + "notifications": "การแจ้งเตือน", + "checks": "การตรวจสอบ", + "incidents": "เหตุการณ์", + "statusPages": "หน้าสถานะ", + "maintenance": "การบำรุงรักษา", + "logs": "บันทึก", + "settings": "การตั้งค่า" }, - "gettingStartedButton": { - "superAdmin": "สร้างบัญชี Super Admin", - "user": "สมัครสมาชิกผู้ใช้ทั่วไป" + "bottomMenu": { + "support": "การสนับสนุน", + "discussions": "การสนทนา", + "docs": "เอกสาร", + "changelog": "บันทึกการเปลี่ยนแปลง" }, - "termsAndPolicies": "การสร้างบัญชีถือว่าคุณยอมรับ ข้อกำหนดการให้บริการ และ นโยบายความเป็นส่วนตัว ของเรา", + "accountMenu": { + "profile": "โปรไฟล์", + "password": "รหัสผ่าน", + "team": "ทีม" + }, + "starPrompt": { + "title": "ให้ดาว Checkmate", + "description": "ดูรุ่นล่าสุดและช่วยเติบโตชุมชนบน GitHub" + }, + "authFooter": { + "navControls": "การควบคุม", + "logOut": "ออกจากระบบ", + "roles": { + "superAdmin": "ผู้ดูแลระบบสูงสุด", + "admin": "ผู้ดูแลระบบ", + "user": "ผู้ใช้", + "demoUser": "ผู้ใช้ Demo" + } + } + }, + "starPrompt": { + "title": "ให้ดาว Checkmate", + "description": "ดูรุ่นล่าสุดและช่วยเติบโตชุมชนบน GitHub" + }, + "pageHeader": { "links": { - "login": "มีบัญชีแล้ว? เข้าสู่ระบบ" - }, - "toasts": { - "success": "ยินดีต้อนรับ! สร้างบัญชีเรียบร้อยแล้ว" - }, - "welcome": "ยินดีต้อนรับสู่ Checkmate!" - }, - "forgotPassword": { - "heading": "ลืมรหัสผ่าน?", - "subheadings": { - "stepOne": "ไม่ต้องกังวล เราจะส่งคำแนะนำเพื่อรีเซ็ตรหัสผ่านให้คุณ", - "stepTwo": "เราได้ส่งลิงก์รีเซ็ตรหัสผ่านไปยัง ", - "stepThree": "รหัสผ่านใหม่ต้องแตกต่างจากรหัสผ่านที่ใช้ก่อนหน้านี้", - "stepFour": "รีเซ็ตรหัสผ่านเรียบร้อยแล้ว คลิกด้านล่างเพื่อเข้าสู่ระบบทันที" - }, - "buttons": { - "openEmail": "เปิดแอปอีเมล", - "resetPassword": "รีเซ็ตรหัสผ่าน" - }, - "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" - }, - "links": { - "login": "กลับไปที่ เข้าสู่ระบบ", - "resend": "ไม่ได้รับอีเมล? คลิกเพื่อส่งอีกครั้ง" - }, - "toasts": { - "sent": "คำแนะนำถูกส่งไปยัง .", - "emailNotFound": "ไม่พบอีเมลนี้", - "redirect": "กำลังเปลี่ยนหน้าใน ...", - "success": "รีเซ็ตรหัสผ่านสำเร็จแล้ว", - "error": "ไม่สามารถรีเซ็ตรหัสผ่านได้ กรุณาลองอีกครั้งภายหลังหรือติดต่อฝ่ายสนับสนุน" + "support": "ฝ่ายสนับสนุน", + "discussions": "การสนทนา", + "docs": "เอกสาร", + "changelog": "บันทึกการเปลี่ยนแปลง" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "เชื่อมต่อกับเซิร์ฟเวอร์สำเร็จแล้ว", - "stillUnreachable": "เซิร์ฟเวอร์ยังไม่สามารถเข้าถึงได้ กรุณาลองอีกครั้งภายหลัง" + "pages": { + "notFound": { + "title": "โอ้ไม่! ซูชิของคุณหล่นแล้ว!", + "subtitle": "URL ไม่มีอยู่ หรือคุณไม่มีสิทธิ์เข้าถึง" + }, + "account": { + "tabs": { + "profile": "โปรไฟล์", + "password": "รหัสผ่าน", + "team": "ทีม" }, - "alertBox": "เกิดข้อผิดพลาดการเชื่อมต่อเซิร์ฟเวอร์", - "description": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้ กรุณาตรวจสอบการเชื่อมต่ออินเทอร์เน็ตของคุณหรือยืนยันการตั้งค่า deployment หากปัญหายังคงอยู่", - "retryButton": { - "default": "ลองเชื่อมต่ออีกครั้ง", - "processing": "กำลังเชื่อมต่อ…" + "form": { + "name": { + "title": "ชื่อ", + "description": "อัปเดตข้อมูลส่วนตัวของคุณ" + }, + "photo": { + "title": "รูปโปรไฟล์", + "description": "อัปโหลดรูปโปรไฟล์" + }, + "currentPassword": { + "title": "รหัสผ่านปัจจุบัน", + "description": "กรอกรหัสผ่านปัจจุบันเพื่อยืนยันตัวตน", + "option": { + "label": "รหัสผ่านปัจจุบัน", + "placeholder": "กรอกรหัสผ่านปัจจุบัน" + } + }, + "newPassword": { + "title": "รหัสผ่านใหม่", + "description": "เลือกรหัสผ่านที่รัดกุมอย่างน้อย 8 ตัวอักษร", + "option": { + "newPassword": { + "label": "รหัสผ่านใหม่", + "placeholder": "กรอกรหัสผ่านใหม่" + }, + "confirm": { + "label": "ยืนยันรหัสผ่าน", + "placeholder": "ยืนยันรหัสผ่านใหม่" + } + } + }, + "deleteAccount": { + "title": "ลบบัญชี", + "description": "การกระทำนี้เป็นการถาวรและไม่สามารถย้อนกลับได้" + } + }, + "team": { + "filter": { + "placeholder": "กรองตามบทบาท", + "all": "ทั้งหมด", + "admin": "ผู้ดูแลระบบ", + "member": "สมาชิก" + }, + "table": { + "headers": { + "email": "อีเมล", + "role": "บทบาท", + "created": "สร้างเมื่อ" + } + }, + "addMember": { + "title": "ลงทะเบียนสมาชิกทีมใหม่", + "description": "สร้างบัญชีผู้ใช้ใหม่ แชร์ข้อมูลรับรองอย่างปลอดภัยหลังสร้าง" + }, + "invite": { + "title": "เชิญสมาชิกทีม", + "description": "ส่งคำเชิญเข้าร่วมทีม", + "email": { + "label": "ที่อยู่อีเมล", + "placeholder": "กรอกที่อยู่อีเมล" + }, + "role": { + "label": "บทบาท", + "placeholder": "เลือกบทบาท" + }, + "linkLabel": "ลิงก์เชิญ" + }, + "fallback": { + "title": "ยังไม่มีสมาชิกทีม", + "description": "เชิญเพื่อนร่วมทีมทางอีเมลหรือเพิ่มพวกเขาโดยตรง เมื่อพวกเขาเข้าร่วม พวกเขาจะปรากฏที่นี่พร้อมบทบาทของพวกเขา" + } + }, + "header": { + "title": "บัญชี", + "description": "จัดการโปรไฟล์ รหัสผ่าน ทีม และการตั้งค่าพื้นที่ทำงานของคุณ" } - } - }, - "createNotifications": { - "title": "สร้างช่องทางแจ้งเตือน", - "nameSettings": { - "title": "ชื่อ", - "description": "ชื่อที่บ่งบอกถึงการตั้งค่าการรวมระบบของคุณ", - "nameLabel": "ชื่อ", - "namePlaceholder": "เช่น การแจ้งเตือนผ่าน Slack" }, - "typeSettings": { - "title": "ประเภท", - "description": "เลือกประเภทของช่องทางแจ้งเตือนที่คุณต้องการสร้าง", - "typeLabel": "ประเภท" - }, - "emailSettings": { - "title": "อีเมล", - "description": "ที่อยู่อีเมลปลายทาง", - "emailLabel": "อีเมล", - "emailPlaceholder": "e.g. john@example.com" - }, - "slackSettings": { - "title": "Slack", - "description": "ตั้งค่า Slack webhook ของคุณที่นี่", - "webhookLabel": "URL ของ Slack webhook", - "webhookPlaceholder": "https://hooks.slack.com/services/..." - }, - "pagerdutySettings": { - "title": "PagerDuty", - "description": "ตั้งค่าการรวมระบบ PagerDuty ของคุณที่นี่", - "integrationKeyLabel": "คีย์การรวมระบบ", - "integrationKeyPlaceholder": "1234567890" - }, - "discordSettings": { - "title": "Discord", - "description": "ตั้งค่า Discord webhook ของคุณที่นี่", - "webhookLabel": "URL ของ Discord Webhook", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "webhookSettings": { - "title": "Webhook", - "description": "ตั้งค่า webhook ของคุณที่นี่", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "testNotification": "ทดสอบการแจ้งเตือน", - "dialogDeleteTitle": "คุณแน่ใจหรือไม่ว่าต้องการลบการแจ้งเตือนนี้?", - "dialogDeleteConfirm": "ลบ" - }, - "notificationConfig": { - "title": "การแจ้งเตือน", - "description": "เลือกช่องทางแจ้งเตือนที่คุณต้องการใช้" - }, - "monitorStatus": { - "checkingEvery": "ตรวจสอบทุก {{interval}}", - "withCaptureAgent": "โดยใช้ Capture agent {{version}}", - "up": "ออนไลน์", - "down": "ออฟไลน์", - "paused": "หยุดชั่วคราว" - }, - "advancedMatching": "การจับคู่ขั้นสูง", - "sendTestNotifications": "ส่งการแจ้งเตือนทดสอบ", - "selectAll": "Select all", - "showAdminLoginLink": "แสดงลิงก์ \"ผู้ดูแลระบบ? เข้าสู่ระบบที่นี่\" บนหน้าสถานะ", - "logsPage": { - "title": "Logs", - "description": "ระบบ Logs 1000 บรรทัดล่าสุด", - "tabs": { - "queue": "คิวงาน", - "logs": "Logs เซิร์ฟเวอร์", - "diagnostics": "วินิจฉัย" - }, - "toast": { - "fetchLogsSuccess": "ดึง Logs สำเร็จแล้ว" - }, - "logLevelSelect": { - "title": "Logs level", - "values": { - "all": "All", - "info": "Info", - "warn": "Warn", - "error": "Error", - "debug": "Debug" + "auth": { + "brandPanel": { + "eyebrow": "การมอนิเตอร์โครงสร้างพื้นฐานโอเพนซอร์ส", + "tagline": "คอยจับตาดูทุกการเคลื่อนไหวของเซิร์ฟเวอร์ของคุณ", + "description": "ติดตามสถานะ เวลาตอบสนอง และเหตุการณ์ผิดปกติแบบเรียลไทม์ - โฮสต์เองและเป็นของคุณ" + }, + "common": { + "passwordRules": { + "length": { + "beginning": "ต้องมีความยาวอย่างน้อย", + "highlighted": "8 ตัวอักษร" + }, + "lowercase": { + "beginning": "ต้องมีอย่างน้อย", + "highlighted": "ตัวอักษรเล็ก 1 ตัว" + }, + "match": { + "beginning": "รหัสผ่าน", + "highlighted": "ต้องตรงกัน" + }, + "number": { + "beginning": "ต้องมีอย่างน้อย", + "highlighted": "ตัวเลข 1 ตัว" + }, + "special": { + "beginning": "ต้องมีอย่างน้อย", + "highlighted": "อักขระพิเศษ 1 ตัว" + }, + "uppercase": { + "beginning": "ต้องมีอย่างน้อย", + "highlighted": "ตัวอักษรใหญ่ 1 ตัว" + } + }, + "form": { + "option": { + "email": { + "label": "อีเมล", + "placeholder": "me@example.com" + }, + "password": { + "label": "รหัสผ่าน", + "placeholder": "********" + }, + "confirmPassword": { + "label": "ยืนยันรหัสผ่าน" + } + } + } + }, + "login": { + "title": "ยินดีต้อนรับกลับสู่ Checkmate!", + "subtitle": "เข้าสู่ระบบเพื่อดำเนินการต่อ", + "submit": "เข้าสู่ระบบ", + "links": { + "forgotPassword": { + "text": "ลืมรหัสผ่าน?", + "linkText": "รีเซ็ตรหัสผ่าน" + }, + "register": { + "text": "ยังไม่มีบัญชี?", + "linkText": "ลงทะเบียนที่นี่" + } + } + }, + "register": { + "title": "ยินดีต้อนรับกลับสู่ Checkmate!", + "subtitle": "สมัครสมาชิกเพื่อเริ่มต้น", + "submit": "ลงทะเบียน", + "setupNotice": "หน้านี้พร้อมใช้งานเฉพาะระหว่างการตั้งค่าเริ่มต้นเท่านั้น เมื่อลงทะเบียนผู้ดูแลระบบสูงสุดแล้ว สามารถเชิญผู้ใช้เพิ่มเติมได้จากการตั้งค่าทีม" + }, + "forgotPassword": { + "title": "ลืมรหัสผ่านใช่ไหม?", + "subtitle": "ไม่ต้องกังวล เราจะส่งคำแนะนำการรีเซ็ตให้คุณ", + "submit": "ขอกู้คืนรหัสผ่าน", + "links": { + "login": { + "text": "กลับไปยัง", + "linkText": "เข้าสู่ระบบ" + } + } + }, + "setNewPassword": { + "title": "รีเซ็ตรหัสผ่านของคุณ", + "subtitle": "รหัสผ่านใหม่ต้องแตกต่างจากรหัสผ่านที่เคยใช้ก่อนหน้า" } - } - }, - "queuePage": { - "title": "Queue", - "refreshButton": "Refresh", - "flushButton": "Flush queue", - "jobTable": { - "title": "งานที่อยู่ในคิวปัจจุบัน", - "idHeader": "รหัสมอนิเตอร์", - "urlHeader": "URL", - "typeHeader": "Type", - "activeHeader": "ใช้งานอยู่", - "lockedAtHeader": "ล็อกเมื่อ", - "runCountHeader": "จำนวนรัน", - "failCountHeader": "จำนวนล้มเหลว", - "lastRunHeader": "รันล่าสุดเมื่อ", - "lastFinishedAtHeader": "เสร็จสิ้นล่าสุดเมื่อ", - "lastRunTookHeader": "รันครั้งล่าสุดใช้เวลา", - "intervalHeader": "ช่วงเวลา" }, - "metricsTable": { - "title": "คิวเมตริก", - "metricHeader": "เมตริก", - "valueHeader": "Value" + "checks": { + "selects": { + "monitor": { + "all": "มอนิเตอร์ทั้งหมด" + }, + "status": { + "all": "ทั้งหมด", + "down": "ล่ม", + "up": "ปกติ" + } + }, + "table": { + "empty": "ไม่พบการตรวจสอบที่ล้มเหลวในช่วงเวลานี้", + "headers": { + "statusCode": "รหัสสถานะ", + "location": "ตำแหน่ง" + } + }, + "fallback": { + "title": "ยังไม่มีการตรวจสอบ", + "description": "เมื่อคุณสร้างตัวตรวจสอบ การตรวจสอบสถานะและเวลาตอบสนองทุกครั้งจะปรากฏที่นี่เป็นการตรวจสอบ" + }, + "header": { + "title": "การตรวจสอบ", + "description": "การตรวจสอบทุกครั้งที่ Checkmate ทำกับตัวตรวจสอบของคุณจะปรากฏที่นี่พร้อมรหัสสถานะและเวลาตอบสนอง" + } }, - "failedJobTable": { - "title": "งานที่ล้มเหลว", - "monitorIdHeader": "รหัสมอนิเตอร์", - "monitorUrlHeader": "URL มอนิเตอร์", - "failCountHeader": "จำนวนล้มเหลว", - "failedAtHeader": "ล้มเหลวล่าสุดเมื่อ", - "failReasonHeader": "สาเหตุที่ล้มเหลว" - } - }, - "export": { - "title": "ส่งออกมอนิเตอร์", - "success": "ส่งออกมอนิเตอร์สำเร็จแล้ว", - "failed": "ไม่สามารถส่งออกมอนิเตอร์ได้" - }, - "monitorActions": { - "title": "ส่งออก/นำเข้า", - "import": "นำเข้ามอนิเตอร์", - "export": "ส่งออกมอนิเตอร์", - "deleteSuccess": "ลบมอนิเตอร์สำเร็จ", - "deleteFailed": "ลบมอนิเตอร์ล้มเหลว", - "details": "รายละเอียด" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "พัฒนาโดย Bluewave Labs", - "labelVersion": "เวอร์ชัน", - "title": "เกี่ยวกับ" + "common": { + "monitors": { + "actions": { + "configure": "ตั้งค่า", + "delete": "ลบ", + "generateToken": "สร้างโทเค็น", + "details": "รายละเอียด", + "incidents": "เหตุการณ์", + "inviteMember": "เชิญสมาชิก", + "openSite": "เปิดเว็บไซต์", + "pause": "หยุดชั่วคราว", + "resume": "ดำเนินการต่อ", + "selectAll": "เลือกจอภาพทั้งหมด", + "selectMonitor": "เลือก {{name}}", + "bulkSelected_one": "เลือกแล้ว {{count}} จอภาพ", + "bulkSelected_other": "เลือกแล้ว {{count}} จอภาพ" + }, + "statBoxes": { + "activeFor": "ทำงานมาแล้ว", + "certificateExpiry": "วันหมดอายุใบรับรอง", + "lastCheck": "ตรวจสอบล่าสุด", + "lastResponseTime": "เวลาตอบสนองล่าสุด", + "serviceIsDown": "บริการขัดข้อง" + }, + "status": { + "down": "ล่ม", + "breached": "เกินเกณฑ์", + "initializing": "กำลังเริ่มต้น", + "maintenance": "บำรุงรักษา", + "paused": "หยุดชั่วคราว", + "total": "ทั้งหมด", + "up": "ปกติ" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "เกม", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "พอร์ต", + "optionPagespeed": "PageSpeed", + "optionHardware": "โครงสร้างพื้นฐาน", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "หยุดจอภาพชั่วคราว {{count}} จอ", + "paused_other": "หยุดจอภาพชั่วคราว {{count}} จอ", + "resumed_one": "กลับมาทำงานต่อ {{count}} จอ", + "resumed_other": "กลับมาทำงานต่อ {{count}} จอ", + "alreadyPaused_one": "จอภาพที่เลือกถูกหยุดชั่วคราวแล้ว", + "alreadyPaused_other": "จอภาพที่เลือกทั้งหมดถูกหยุดชั่วคราวแล้ว", + "alreadyRunning_one": "จอภาพที่เลือกกำลังทำงานอยู่แล้ว", + "alreadyRunning_other": "จอภาพที่เลือกทั้งหมดกำลังทำงานอยู่แล้ว" + } + } }, - "demoMonitorsSettings": { - "buttonAddMonitors": "เพิ่มมอนิเตอร์ตัวอย่าง", - "description": "เพิ่มมอนิเตอร์ตัวอย่างเพื่อการสาธิต", - "title": "มอนิเตอร์สาธิต" + "createMonitor": { + "form": { + "advanced": { + "description": "ตั้งค่าเพิ่มเติมสำหรับกรณีใช้งานขั้นสูง", + "option": { + "advancedMatching": { + "label": "ใช้การจับคู่ขั้นสูง" + }, + "expectedValue": { + "label": "ค่าที่คาดหวัง" + }, + "jsonPath": { + "description": "นิพจน์นี้จะถูกประเมินกับข้อมูล JSON ที่ตอบกลับ และผลลัพธ์จะถูกใช้จับคู่กับค่าที่คาดหวัง ดูเอกสาร query language ที่ jmespath.org", + "label": "นิพจน์ JSONPath" + }, + "matchMethod": { + "label": "วิธีการจับคู่", + "equal": "เท่ากับ", + "include": "รวม", + "regex": "Regex" + } + }, + "title": "ตั้งค่าขั้นสูง" + }, + "frequency": { + "description": "คุณต้องการตรวจสอบสถานะของมอนิเตอร์นี้บ่อยแค่ไหน?", + "option": { + "frequency": { + "label": "ความถี่การตรวจสอบ", + "value": { + "fifteenMinutes": "15 นาที", + "fifteenSeconds": "15 วินาที", + "fiveMinutes": "5 นาที", + "fourMinutes": "4 นาที", + "oneMinute": "1 นาที", + "tenMinutes": "10 นาที", + "thirtyMinutes": "30 นาที", + "thirtySeconds": "30 วินาที", + "threeMinutes": "3 นาที", + "twoMinutes": "2 นาที" + } + } + }, + "title": "ความถี่การตรวจสอบ" + }, + "general": { + "option": { + "container": { + "label": "ชื่อ/ID คอนเทนเนอร์", + "placeholder": "my-app หรือ abcd1234" + }, + "host": { + "label": "โฮสต์", + "placeholder": "192.168.1.100 หรือ example.com" + }, + "name": { + "label": "ชื่อที่แสดง", + "placeholder": "เช่น เว็บไซต์ของฉัน" + }, + "secret": { + "label": "รหัสลับสำหรับยืนยันตัวตน", + "placeholder": "กรอกรหัสลับของคุณ" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "URL ที่ต้องการมอนิเตอร์", + "placeholder": "localhost" + }, + "port": { + "label": "พอร์ตที่ต้องการตรวจสอบ", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "ชื่อบริการ", + "placeholder": "เช่น my.service.v1 (เว้นว่างสำหรับสถานะสุขภาพโดยรวม)" + }, + "wsUrl": { + "label": "URL ของ WebSocket", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "ปลายทาง Capture", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "ตั้งค่าทั่วไป", + "description": { + "http": "กรอก URL หรือ IP ที่ต้องการตรวจสอบ (เช่น https://example.com/ หรือ 192.168.1.100) และตั้งชื่อที่แสดงบนแดชบอร์ด", + "ping": "กรอกที่อยู่ IP หรือชื่อโฮสต์ที่ต้องการ ping (เช่น 192.168.1.100 หรือ example.com) และตั้งชื่อที่ชัดเจนให้แสดงบนแดชบอร์ด", + "port": "กรอก URL หรือ IP ของเซิร์ฟเวอร์ หมายเลขพอร์ต และตั้งชื่อที่ชัดเจนให้แสดงบนแดชบอร์ด", + "docker": "กรอกชื่อหรือ ID ของคอนเทนเนอร์ Docker คุณสามารถใช้ชื่อคอนเทนเนอร์ (เช่น my-app) หรือ ID ของคอนเทนเนอร์ (ID เต็ม 64 ตัวอักษร หรือ ID สั้น)", + "game": "กรอกที่อยู่ IP หรือชื่อโฮสต์และหมายเลขพอร์ตเพื่อทำการ ping (เช่น 192.168.1.100 หรือ example.com) และเลือกประเภทเกม เกมบางเกมต้องเปิดใช้งานการสอบถามเซิร์ฟเวอร์ในไฟล์การตั้งค่า — โปรดดูเอกสารของเซิร์ฟเวอร์เกมของคุณ ดูรายการเกมที่รองรับสำหรับรายละเอียดของโปรโตคอล", + "pagespeed": "ติดตามประสิทธิภาพการโหลดหน้า Core Web Vitals และคะแนนการเพิ่มประสิทธิภาพของเว็บไซต์", + "grpc": "กรอกชื่อโฮสต์และพอร์ตของเซิร์ฟเวอร์ gRPC ระบุชื่อบริการสำหรับ health check (ไม่บังคับ) และตั้งชื่อที่แสดง", + "websocket": "กรอก URL ของ WebSocket ที่ต้องการตรวจสอบ (เช่น wss://example.com/socket) และตั้งชื่อที่แสดง", + "hardware": "ตรวจสอบการใช้ CPU หน่วยความจำ ดิสก์ และอุณหภูมิของโครงสร้างพื้นฐานของคุณ URL ของปลายทาง Capture ต้องรวมเส้นทางแบบเต็ม /api/v1/metrics (ตัวอย่าง: http://192.168.1.10:5001/api/v1/metrics) สำหรับรหัสลับสำหรับยืนยันตัวตน ให้วางเฉพาะสตริงโทเค็น — อย่ารวมคำว่า 'Bearer'" + } + }, + "ignoreTls": { + "description": "กำหนดค่าการตรวจสอบใบรับรอง TLS/SSL สำหรับการเชื่อมต่อ HTTPS", + "option": { + "tls": { + "label": "ข้ามข้อผิดพลาด TLS/SSL" + } + }, + "title": "ตั้งค่า TLS/SSL" + }, + "incidents": { + "description": "ใช้หน้าต่างเลื่อนเพื่อกำหนดว่ามอนิเตอร์ล่มเมื่อใด สถานะของมอนิเตอร์จะเปลี่ยนเมื่อเปอร์เซ็นต์การตรวจสอบในหน้าต่างเลื่อนตรงตามค่าที่กำหนด", + "option": { + "checks": { + "label": "จำนวนการตรวจสอบในหน้าต่างเลื่อน" + }, + "percentage": { + "label": "เปอร์เซ็นต์การตรวจสอบในหน้าต่างเลื่อนที่ล้มเหลว/สำเร็จก่อนเปลี่ยนสถานะมอนิเตอร์?" + } + }, + "title": "เหตุการณ์" + }, + "notifications": { + "description": "เลือกช่องทางการแจ้งเตือนที่คุณต้องการใช้", + "title": "การแจ้งเตือน" + }, + "type": { + "description": "เลือกประเภทการตรวจสอบที่ต้องการ", + "optionDockerDescription": "ใช้ Docker เพื่อตรวจสอบว่าคอนเทนเนอร์กำลังทำงานอยู่", + "optionGameDescription": "ตรวจสอบว่าเซิร์ฟเวอร์เกมเฉพาะออนไลน์อยู่หรือไม่", + "optionGrpcDescription": "ตรวจสอบบริการ gRPC โดยใช้ Health Checking Protocol มาตรฐาน", + "optionWebSocketDescription": "ตรวจสอบ WebSocket endpoint สำหรับสุขภาพการเชื่อมต่อและเวลาตอบสนอง", + "optionHttpDescription": "ใช้ HTTP(S) เพื่อตรวจสอบเว็บไซต์หรือ API endpoint", + "optionPingDescription": "ใช้ ICMP Ping เพื่อตรวจสอบว่าเซิร์ฟเวอร์ออนไลน์อยู่หรือไม่", + "optionPortDescription": "ตรวจสอบว่าพอร์ตเฉพาะบนเซิร์ฟเวอร์เปิดอยู่หรือไม่", + "title": "ประเภท" + }, + "thresholds": { + "title": "เกณฑ์การแจ้งเตือน", + "description": "กำหนดเกณฑ์ที่ควรทริกเกอร์การแจ้งเตือนสำหรับมอนิเตอร์ฮาร์ดแวร์นี้", + "option": { + "cpuThreshold": { + "label": "เกณฑ์แจ้งเตือน CPU (%)" + }, + "memoryThreshold": { + "label": "เกณฑ์แจ้งเตือนหน่วยความจำ (%)" + }, + "diskThreshold": { + "label": "เกณฑ์แจ้งเตือนดิสก์ (%)" + }, + "tempThreshold": { + "label": "เกณฑ์แจ้งเตือนอุณหภูมิ (°C)" + } + } + }, + "geoChecks": { + "title": "การตรวจสอบแบบกระจายทางภูมิศาสตร์", + "description": "เรียกใช้การตรวจสอบจากหลายตำแหน่งทางภูมิศาสตร์เพื่อตรวจสอบความพร้อมใช้งานและประสิทธิภาพทั่วโลก", + "option": { + "enabled": { + "label": "เปิดใช้งานการตรวจสอบแบบกระจายทางภูมิศาสตร์" + }, + "locations": { + "label": "ตำแหน่ง", + "placeholder": "เลือกตำแหน่ง", + "options": { + "EU": "ยุโรป", + "NA": "อเมริกาเหนือ", + "AS": "เอเชีย", + "SA": "อเมริกาใต้", + "AF": "แอฟริกา", + "OC": "โอเชียเนีย" + } + }, + "interval": { + "label": "ช่วงเวลาตรวจสอบ", + "value": { + "fiveMinutes": "5 นาที", + "tenMinutes": "10 นาที", + "fifteenMinutes": "15 นาที", + "thirtyMinutes": "30 นาที" + } + } + } + }, + "url": { + "title": "แสดง IP/URL ของมอนิเตอร์บนหน้าสถานะ", + "description": "แสดง IP หรือ URL ของมอนิเตอร์บนหน้าสถานะสาธารณะ หากปิดใช้งาน จะแสดงเฉพาะชื่อมอนิเตอร์เพื่อปกป้องข้อมูลที่ละเอียดอ่อน", + "option": { + "showURL": { + "label": "แสดง IP/URL บนหน้าสถานะ", + "enabled": "เปิดใช้งาน", + "disabled": "ปิดใช้งาน" + } + } + }, + "stats": { + "title": "ประวัติการตรวจสอบ", + "description": "ล้างประวัติการตรวจสอบและสถิติทั้งหมดของทีม การกระทำนี้ไม่สามารถย้อนกลับได้", + "buttonText": "ล้างสถิติทั้งหมด", + "dialog": { + "title": "ล้างประวัติการตรวจสอบทั้งหมด?", + "description": "การดำเนินการนี้จะลบประวัติการตรวจสอบ สถิติ และข้อมูลการตรวจสอบทั้งหมดของทีมอย่างถาวร ไม่สามารถย้อนกลับได้", + "confirm": "ใช่ ล้างสถิติทั้งหมด" + } + } + } }, - "emailSettings": { - "buttonSendTestEmail": "ส่งอีเมลทดสอบ", - "description": "ตั้งค่าอีเมลสำหรับระบบของคุณ ใช้สำหรับส่งการแจ้งเตือนและเตือนภัย", - "descriptionTransport": "สร้าง SMTP transport สำหรับ NodeMailer", - "labelAddress": "ที่อยู่อีเมล - ใช้สำหรับการยืนยันตัวตน", - "labelConnectionHost": "โฮสต์เชื่อมต่ออีเมล - ชื่อโฮสต์ที่ใช้ในคำทักทาย HELO/EHLO", - "labelHost": "โฮสต์อีเมล - ชื่อโฮสต์หรือ IP สำหรับเชื่อมต่อ", - "labelIgnoreTLS": "ปิดใช้งาน STARTTLS: ไม่ใช้ TLS แม้เซิร์ฟเวอร์รองรับ", - "labelPassword": "รหัสผ่านอีเมล - สำหรับยืนยันตัวตน", - "labelPasswordSet": "ตั้งรหัสผ่านเรียบร้อยแล้ว คลิก รีเซ็ต เพื่อเปลี่ยน", - "labelPool": "เปิดใช้ connection pooling: ใช้การเชื่อมต่อเดิมซ้ำเพื่อเพิ่มประสิทธิภาพ", - "labelPort": "พอร์ตอีเมล - พอร์ตสำหรับเชื่อมต่อ", - "labelRejectUnauthorized": "ปฏิเสธใบรับรองไม่ถูกต้อง: ปฏิเสธการเชื่อมต่อที่ใช้ใบรับรอง self-signed หรือไม่เชื่อถือ", - "labelRequireTLS": "บังคับ STARTTLS: ต้องการอัปเกรด TLS ล้มเหลวหากไม่รองรับ", - "labelSecure": "ใช้ SSL (แนะนำ): เข้ารหัสการเชื่อมต่อด้วย SSL/TLS", - "labelTLSServername": "ชื่อเซิร์ฟเวอร์ TLS - ชื่อโฮสต์เสริมสำหรับตรวจสอบ TLS เมื่อโฮสต์เป็น IP", - "labelUser": "ผู้ใช้อีเมล - ชื่อผู้ใช้สำหรับยืนยันตัวตน ใช้แทนที่อีเมลถ้ามีการระบุ", - "linkTransport": "ดูข้อกำหนดที่นี่", - "placeholderUser": "ปล่อยว่างหากไม่จำเป็น", - "title": "อีเมล", - "toastEmailRequiredFieldsError": "ต้องระบุอีเมล, โฮสต์, พอร์ต และรหัสผ่าน" + "editUser": { + "form": { + "roles": { + "title": "บทบาท", + "description": "กำหนดบทบาทให้กับผู้ใช้ สามารถเลือกได้หลายบทบาท" + } + }, + "dialog": { + "removeUser": { + "title": "ลบผู้ใช้", + "content": "คุณแน่ใจหรือไม่ว่าต้องการลบ {{name}} ออกจากทีม? การกระทำนี้ไม่สามารถย้อนกลับได้" + } + } }, - "pageSpeedSettings": { - "description": "ใส่ Google PageSpeed API Key ของคุณเพื่อเปิดใช้งานการตรวจสอบ PageSpeed ของ Google คลิก รีเซ็ต เพื่ออัปเดต Key", - "labelApiKeySet": "ตั้งค่า API key แล้ว คลิก รีเซ็ต เพื่อเปลี่ยน", - "labelApiKey": "PageSpeed API key", - "title": "Google PageSpeed API key" + "incidents": { + "fallback": { + "title": "ไม่มีเหตุการณ์ — ระบบทั้งหมดทำงานปกติ", + "description": "เมื่อตัวตรวจสอบล่มหรือฟื้นตัว เหตุการณ์จะปรากฏที่นี่พร้อมเวลา รายละเอียดตัวตรวจสอบ และประวัติการแก้ไข" + }, + "dialog": { + "resolveIncident": { + "title": "แก้ไขเหตุการณ์", + "option": { + "comment": { + "label": "ความคิดเห็น (ไม่บังคับ)", + "placeholder": "เพิ่มความคิดเห็นเกี่ยวกับการแก้ไข..." + } + } + }, + "details": { + "analysis": "การวิเคราะห์เหตุการณ์", + "comment": "ความคิดเห็น:", + "detailsLabel": "รายละเอียด", + "downtime": "ดาวน์ไทม์:", + "message": "ข้อความ:", + "monitor": "มอนิเตอร์:", + "overview": "ภาพรวม", + "resolutionDetails": "รายละเอียดการแก้ไข", + "resolutionType": "ประเภท:", + "resolutionTypes": { + "automatic": "อัตโนมัติ", + "manual": "ด้วยตนเอง" + }, + "resolve": "แก้ไขเหตุการณ์", + "resolvedAt": "แก้ไขเมื่อ:", + "resolvedBy": "แก้ไขโดย:", + "startedAt": "เริ่มเมื่อ:", + "status": "สถานะ:", + "statusCode": "รหัสสถานะ:", + "timeline": "ไทม์ไลน์", + "title": "รายละเอียดเหตุการณ์", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "มอนิเตอร์ทั้งหมด", + "monitor": "มอนิเตอร์", + "resolutionType": "ประเภทการแก้ไข", + "resolutionTypes": { + "manual": "ด้วยตนเอง", + "automatic": "อัตโนมัติ", + "all": "ทั้งหมด" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "เหตุการณ์ที่ยังดำเนินอยู่", + "active_zero": "ไม่มีเหตุการณ์ที่ยังดำเนินอยู่", + "active_one": "{{count}} เหตุการณ์ที่ยังดำเนินอยู่", + "active_other": "{{count}} เหตุการณ์ที่ยังดำเนินอยู่" + }, + "incidentStats": { + "avgResolutionTime": "เวลาแก้ไขเฉลี่ย", + "mostAffectedMonitor": "มอนิเตอร์ที่ได้รับผลกระทบมากที่สุด", + "title": "สถิติเหตุการณ์", + "totalIncidents": "เหตุการณ์ทั้งหมด" + }, + "latestIncidents": { + "title": "เหตุการณ์ล่าสุด" + } + }, + "table": { + "actions": { + "details": "รายละเอียด", + "goToMonitor": "ไปยังมอนิเตอร์", + "resolveManually": "แก้ไขด้วยตนเอง" + }, + "activeIncidents": "เหตุการณ์ที่ยังดำเนินอยู่", + "headers": { + "endTime": "เวลาสิ้นสุด", + "resolutionType": "ประเภทการแก้ไข", + "startTime": "เวลาเริ่มต้น", + "statusCode": "รหัสสถานะ" + }, + "resolvedIncidents": "เหตุการณ์ที่แก้ไขแล้ว", + "status": { + "active": "ใช้งานอยู่", + "resolved": "แก้ไขแล้ว" + } + }, + "header": { + "title": "เหตุการณ์", + "description": "เมื่อตัวตรวจสอบล่มหรือฟื้นตัว เหตุการณ์จะปรากฏที่นี่พร้อมเวลา รายละเอียดตัวตรวจสอบ และประวัติการแก้ไข" + } }, - "saveButtonLabel": "บันทึก", - "statsSettings": { - "clearAllStatsButton": "ล้างสถิติทั้งหมด", - "clearAllStatsDescription": "ล้างสถิติทั้งหมด การกระทำนี้ไม่สามารถย้อนกลับได้", - "clearAllStatsDialogConfirm": "ใช่ ล้างสถิติทั้งหมด", - "clearAllStatsDialogDescription": "เมื่อถูกลบแล้ว ประวัติการตรวจสอบและสถิติไม่สามารถกู้คืนได้", - "clearAllStatsDialogTitle": "คุณต้องการล้างสถิติทั้งหมดหรือไม่?", - "description": "กำหนดระยะเวลาที่ต้องการเก็บข้อมูลประวัติ คุณยังสามารถล้างข้อมูลทั้งหมดที่มีอยู่ได้", - "labelTTL": "จำนวนวันที่ต้องการเก็บประวัติการตรวจสอบ", - "labelTTLOptional": "0 หมายถึงไม่จำกัด", - "title": "ประวัติการตรวจสอบ" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "ลบมอนิเตอร์ทั้งหมด", - "description": "ลบมอนิเตอร์ทั้งหมดออกจากระบบของคุณ", - "dialogConfirm": "ใช่ ลบมอนิเตอร์ทั้งหมด", - "dialogDescription": "เมื่อถูกลบแล้ว มอนิเตอร์ไม่สามารถกู้คืนได้", - "dialogTitle": "คุณต้องการลบมอนิเตอร์ทั้งหมดหรือไม่?", - "title": "รีเซ็ตระบบ" - }, - "timezoneSettings": { - "description": "เลือกโซนเวลาที่ใช้แสดงวันที่และเวลาในแอปทั้งหมด", - "label": "โซนเวลาที่แสดง", - "title": "โซนเวลาที่แสดง" - }, - "title": "การตั้งค่า", - "uiSettings": { - "description": "สลับโหมดสว่าง/มืด หรือเปลี่ยนภาษาอินเทอร์เฟซผู้ใช้", - "labelLanguage": "ภาษา", - "labelTheme": "โหมดธีม", - "title": "รูปลักษณ์" - }, - "urlSettings": { - "description": "แสดง IP/URL ของมอนิเตอร์บนหน้าสถานะสาธารณะ หากปิดใช้งาน จะแสดงเฉพาะชื่อมอนิเตอร์เพื่อป้องกันข้อมูลสำคัญ", - "label": "แสดง IP/URL บนหน้าสถานะ", - "selectDisabled": "ปิดใช้งาน", - "selectEnabled": "เปิดใช้งาน", - "title": "IP/URL ของมอนิเตอร์บนหน้าสถานะ" - }, - "globalThresholds": { - "title": "เกณฑ์การตรวจสอบแบบรวม", - "description": "กำหนดค่าเกณฑ์ของ CPU, หน่วยความจำ, ดิสก์ และอุณหภูมิ หากระบุค่าไว้ ระบบจะเปิดใช้งานการตรวจสอบโดยอัตโนมัติ" - } - }, - "statusPageCreate": { - "buttonSave": "บันทึก" - }, - "incidentsOptionsHeaderFilterResolved": "แก้ไขแล้ว", - "settingsSave": "บันทึก", - "statusPageCreateAppearanceTitle": "รูปลักษณ์", - "confirmPassword": "ยืนยันรหัสผ่าน", - "monitorHooks": { - "failureAddDemoMonitors": "ไม่สามารถเพิ่มมอนิเตอร์ตัวอย่างได้", - "successAddDemoMonitors": "เพิ่มมอนิเตอร์ตัวอย่างสำเร็จ" - }, - "settingsAppearance": "รูปลักษณ์", - "settingsDisplayTimezone": "โซนเวลาที่แสดง", - "settingsGeneralSettings": "การตั้งค่าทั่วไป", - "incidentsOptionsHeaderTotalIncidents": "จำนวนเหตุการณ์ทั้งหมด", - "statusPage": { - "deleteSuccess": "ลบหน้าสถานะสำเร็จ", - "deleteFailed": "ลบหน้าสถานะล้มเหลว", - "createSuccess": "สร้างหน้าสถานะสำเร็จ", - "updateSuccess": "อัปเดตหน้าสถานะสำเร็จ", - "generalSettings": "การตั้งค่าทั่วไป", - "contents": "เนื้อหา", - "fallback": { - "checks": [ - "ตรวจสอบและแสดงสุขภาพของบริการแบบเรียลไทม์", - "ติดตามหลายบริการและแชร์สถานะ", - "แจ้งผู้ใช้ให้ทราบเกี่ยวกับการหยุดทำงานและประสิทธิภาพ" - ], - "title": "หน้าสถานะใช้สำหรับ:", - "actionButton": "มาสร้างหน้าสถานะแรกกันเถอะ!" - } - }, - "testNotificationsDisabled": "ยังไม่มีการตั้งค่าการแจ้งเตือนสำหรับมอนิเตอร์นี้ คุณต้องเพิ่มโดยคลิกปุ่ม 'ตั้งค่า'", - "incidentsTableResolvedAt": "แก้ไขเมื่อ", - "incidentsTableActionResolve": "แก้ไข", - "checkHooks": { - "failureResolveOne": "แก้ไขเหตุการณ์ล้มเหลว", - "failureResolveAll": "แก้ไขเหตุการณ์ทั้งหมดล้มเหลว", - "failureResolveMonitor": "ไม่สามารถแก้ไขเหตุการณ์ของตัวตรวจสอบได้" - }, - "checkFormError": "กรุณาตรวจสอบแบบฟอร์มสำหรับข้อผิดพลาด", - "diagnosticsPage": { - "diagnosticDescription": "วินิจฉัยระบบ", - "statsDescription": "สถิติระบบ", - "gauges": { - "heapAllocationTitle": "การจัดสรร Heap", - "heapAllocationSubtitle": "% ของหน่วยความจำที่ใช้ได้", - "heapUsageTitle": "การใช้งาน Heap", - "heapUsageSubtitle": "% ของหน่วยความจำที่ใช้ได้", - "heapUtilizationTitle": "การใช้งาน Heap", - "heapUtilizationSubtitle": "% ของที่จัดสรรแล้ว", - "instantCpuUsageTitle": "การใช้งาน CPU ทันที", - "instantCpuUsageSubtitle": "% ของเวลา 1 วินาทีที่ CPU ใช้" - }, - "stats": { - "eventLoopDelayTitle": "ความล่าช้าวงจร Event Loop", - "uptimeTitle": "เวลาทำงาน", - "usedHeapSizeTitle": "ขนาดที่ใช้ Heap", - "totalHeapSizeTitle": "รวมขนาด Heap", - "osMemoryLimitTitle": "ขีดจำกัดหน่วยความจำ OS" - } - }, - "pageSpeedLighthouseAPI": "ใช้ Lighthouse PageSpeed API เพื่อตรวจสอบเว็บไซต์ของคุณ", - "time": { - "threeMinutes": "3 นาที", - "fiveMinutes": "5 นาที", - "tenMinutes": "10 นาที", - "twentyMinutes": "20 นาที", - "oneHour": "1 ชั่วโมง", - "oneDay": "1 วัน", - "oneWeek": "1 สัปดาห์", - "fourMinutes": "4 นาที", - "oneMinute": "1 นาที", - "twoMinutes": "2 นาที", - "fifteenSeconds": "15 วินาที", - "thirtySeconds": "30 วินาที" - }, - "general": { - "noOptionsFound": "ไม่พบ {{unit}}" - }, - "infrastructureMonitor": { - "fallback": { - "checks": [ - "ติดตามประสิทธิภาพของเซิร์ฟเวอร์ของคุณ", - "ระบุจุดคอขวดและเพิ่มประสิทธิภาพการใช้งาน", - "มั่นใจในความเสถียรด้วยการตรวจสอบแบบเรียลไทม์" - ], - "title": "ตัวตรวจสอบโครงสร้างพื้นฐานใช้สำหรับ:", - "actionButton": "มาสร้างตัวตรวจสอบโครงสร้างพื้นฐานตัวแรกกันเถอะ!" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [ - "กำหนดช่วงเวลาบำรุงรักษา", - "ลดความเข้าใจผิด", - "หยุดส่งการแจ้งเตือนในช่วงเวลาบำรุงรักษา" - ], - "title": "ช่วงเวลาบำรุงรักษาใช้สำหรับ:", - "actionButton": "มาสร้างช่วงเวลาบำรุงรักษาแรกกันเถอะ!" - } - }, - "pageSpeed": { - "fallback": { - "checks": [ - "รายงานเกี่ยวกับประสบการณ์ผู้ใช้ของหน้าเว็บ", - "ช่วยวิเคราะห์ความเร็วของหน้าเว็บ", - "ให้คำแนะนำในการปรับปรุงหน้าเว็บ" - ], - "title": "ตัวตรวจสอบ PageSpeed ใช้สำหรับ:", - "actionButton": "มาสร้างตัวตรวจสอบ PageSpeed ตัวแรกกันเถอะ!" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [ - "ตรวจสอบว่าเว็บไซต์หรือเซิร์ฟเวอร์ออนไลน์และตอบสนองหรือไม่", - "แจ้งทีมเมื่อเกิดปัญหาการหยุดทำงานหรือประสิทธิภาพ", - "ตรวจสอบ HTTP endpoints, ping, คอนเทนเนอร์ และพอร์ต", - "ติดตามสถิติ uptime และความเสถียรย้อนหลัง" - ], - "title": "ตัวตรวจสอบ Uptime ใช้สำหรับ:", - "actionButton": "มาสร้างตัวตรวจสอบ Uptime ตัวแรกกันเถอะ!" - } - }, - "editUserPage": { - "form": { - "email": "อีเมล", - "firstName": "ชื่อจริง", - "lastName": "นามสกุล", - "role": "บทบาท", - "save": "บันทึก" - }, - "table": { - "actionHeader": "การดำเนินการ", - "roleHeader": "บทบาท" - }, - "title": "แก้ไขผู้ใช้", - "toast": { - "successUserUpdate": "อัปเดตผู้ใช้เรียบร้อยแล้ว", - "validationErrors": "ข้อผิดพลาดในการตรวจสอบข้อมูล" - } - }, - "incidentsPageActionResolveMonitor": "แก้ไขเหตุการณ์ของตัวตรวจสอบ", - "incidentsPageActionResolveAll": "แก้ไขเหตุการณ์ทั้งหมด", - "matchMethodOptions": { - "equal": "Equal", - "equalPlaceholder": "success", - "include": "Include", - "includePlaceholder": "ok", - "regex": "Regex", - "regexPlaceholder": "^(success|ok)$", - "text": "วิธีการจับคู่" - }, - "monitorType": { - "docker": { - "label": "ชื่อ/ID คอนเทนเนอร์", - "namePlaceholder": "คอนเทนเนอร์ของฉัน", - "placeholder": "my-app or abcd1234" - }, - "http": { - "label": "URL ที่ต้องการตรวจสอบ", - "namePlaceholder": "Google", - "placeholder": "google.com" - }, - "ping": { - "label": "ที่อยู่ IP ที่ต้องการตรวจสอบ", - "namePlaceholder": "Google", - "placeholder": "1.1.1.1" - }, - "port": { - "label": "ที่อยู่ IP ที่ต้องการตรวจสอบ", - "namePlaceholder": "Localhost:5173", - "placeholder": "localhost" - }, - "game": { - "label": "URL ที่ต้องการมอนิเตอร์", - "namePlaceholder": "localhost:5173", - "placeholder": "localhost" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "JSON Path" - }, - "bytesPerSecond": "ไบต์ต่อวินาที", - "bytesReceived": "ไบต์ที่รับ", - "bytesSent": "ไบต์ที่ส่ง", - "chooseGame": "เลือกเกม", - "createMonitorPage": { - "incidentConfigDescription": "การใช้หน้าต่างเลื่อนจะช่วยกำหนดเวลาที่ตัวตรวจสอบล้มเหลว สถานะของตัวตรวจสอบจะเปลี่ยนก็ต่อเมื่อเปอร์เซ็นต์ของการตรวจสอบในหน้าต่างเลื่อนตรงตามค่าที่กำหนด", - "incidentConfigStatusWindowLabel": "ต้องมีการตรวจสอบกี่ครั้งในหน้าต่างเลื่อน?", - "incidentConfigStatusWindowThresholdLabel": "ต้องการให้กี่เปอร์เซ็นต์ของการตรวจสอบในหน้าต่างเลื่อนล้มเหลว/สำเร็จ ก่อนที่จะเปลี่ยนสถานะของตัวตรวจสอบ?", - "incidentConfigTitle": "เหตุการณ์", - "incidentConfigDescriptionV2": "", - "incidentConfigStatusCheckNumber": "", - "intervalTitle": "", - "intervalDescription": "" - }, - "dataRate": "อัตราการส่งข้อมูล", - "dataReceived": "ข้อมูลที่รับ", - "dataSent": "ข้อมูลที่ส่ง", - "details": "รายละเอียด", - "drops": "แพ็กเก็ตที่หล่น", - "errors": "ข้อผิดพลาด", - "errorsIn": "ข้อผิดพลาดขาเข้า", - "errorsOut": "ข้อผิดพลาดขาออก", - "gameServerMonitoring": "มอนิเตอร์เซิร์ฟเวอร์เกม", - "gameServerMonitoringDescription": "ตรวจสอบว่าเซิร์ฟเวอร์เกมของคุณกำลังทำงานอยู่หรือไม่", - "network": "เน็ตเวอร์", - "networkDrops": "การดรอปของเน็ตเวอร์", - "networkErrors": "เน็ตเวอร์ ล้มเหลว", - "networkInterface": "เน็ตเวอร์ อินเทอร์เฟซ", - "noNetworkStatsAvailable": "ไม่มีสถิติของเน็ตเวอร์", - "packetsPerSecond": "แพ็กเก็ตต่อวินาที", - "packetsReceived": "แพ็กเก็ตที่รับ", - "packetsReceivedRate": "อัตราการรับแพ็กเก็ต", - "packetsSent": "แพ็กเก็ตที่ส่ง", - "rate": "อัตรา", - "selectInterface": "เลือกอินเทอร์เฟซ", - "v1": { "infrastructure": { - "disk_selection_title": "การเลือกดิสก์", - "disk_selection_info": "ไม่พบดิสก์ในขณะนี้", - "disk_selection_description": "เลือกดิสก์ที่คุณต้องการตรวจสอบ" + "charts": { + "labels": { + "cpu": "การใช้งาน CPU", + "disk": "การใช้งานดิสก์", + "memory": "การใช้งานหน่วยความจำ", + "netBytesRecv": "{{name}} - ไบต์ที่รับ", + "netBytesSent": "{{name}} - ไบต์ที่ส่ง", + "netNoTraffic": "Capture รายงานว่าไม่มีการรับส่งข้อมูลบนอินเทอร์เฟซใดๆ เลย ซึ่งเป็นเรื่องปกติเมื่อ Capture ทำงานอยู่ในคอนเทนเนอร์ที่มองเห็นเพียงอินเทอร์เฟซ loopback เท่านั้น — รัน Capture บนโฮสต์โดยตรงเพื่อดูกิจกรรมเครือข่ายจริง", + "temp": "อุณหภูมิ" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "ความถี่สูงสุด", + "title": "การใช้งาน CPU", + "upperLabel": "ความถี่ปัจจ��บัน" + }, + "disk": { + "lowerLabel": "ว่าง", + "title": "การใช้งานดิสก์ {{idx}}", + "upperLabel": "ใช้แล้ว" + }, + "memory": { + "lowerLabel": "ว่าง", + "title": "การใช้งานหน่วยความจำ", + "upperLabel": "ใช้แล้ว" + } + }, + "statBoxes": { + "avgCpuTemperature": "อุณหภูมิเฉลี่ยของซีพียู", + "cpuFrequency": "ความถี่ของ CPU", + "cpuLogical": "ซีพียู (คอร์เสมือน)", + "cpuPhysical": "ซีพียู (คอร์หลัก)", + "disk": "ดิสก์", + "memory": "หน่วยความจำ", + "os": "ระบบปฏิบัติการ" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "ดิสก์", + "memory": "หน่วยความจำ" + } + }, + "tabs": { + "labels": { + "network": "เน็ตเวอร์", + "overview": "ภาพรวม" + } + }, + "fallback": { + "actionButton": "สร้างมอนิเตอร์!", + "title": "ยังไม่มีตัวตรวจสอบโครงสร้างพื้นฐาน", + "description": "ติดตามประสิทธิภาพเซิร์ฟเวอร์ ระบุคอขวด และมั่นใจในความน่าเชื่อถือด้วยการตรวจสอบโครงสร้างพื้นฐานแบบเรียลไทม์" + }, + "header": { + "title": "ตัวตรวจสอบโครงสร้างพื้นฐาน", + "description": "ติดตามประสิทธิภาพเซิร์ฟเวอร์ ระบุคอขวด และมั่นใจในความน่าเชื่อถือด้วยการตรวจสอบโครงสร้างพื้นฐานแบบเรียลไทม์" + } + }, + "logs": { + "fallback": { + "title": "ยังไม่มีบันทึก", + "description": "กิจกรรมเซิร์ฟเวอร์ คำเตือน และข้อผิดพลาดจะปรากฏที่นี่ในขณะที่ Checkmate ทำงาน" + }, + "tabs": { + "diagnostics": "วินิจฉัย", + "logs": "Logs เซิร์ฟเวอร์", + "queue": "คิวงาน" + }, + "logLevelSelect": { + "label": "ระดับบันทึก" + }, + "jobQueue": "คิวงาน", + "jobQueueExplainer": "แถวที่ไฮไลต์ คืองานที่กำลังถูกประมวลผลโดยเวิร์กเกอร์อยู่ในขณะนี้", + "failedJobs": "งานที่ล้มเหลว", + "failedJobsExplainer": "ความล้มเหลวล่าสุดตามมอนิเตอร์ — เวลาที่ล้มเหลวล่าสุด จำนวนครั้งที่ล้มเหลว และเหตุผล", + "noLogs": "ไม่พบบันทึก", + "metrics": { + "jobs": "งาน", + "activeJobs": "งานที่กำลังดำเนินการ", + "failingJobs": "งานที่ล้มเหลว", + "totalRuns": "จำนวนรันทั้งหมด", + "totalFailures": "จำนวนล้มเหลวทั้งหมด" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "ดีเลย์ของ Event loop", + "uptime": "อัปไทม์", + "usedHeapSize": "ขนาด heap ที่ใช้", + "totalHeapSize": "ขนาด heap ทั้งหมด", + "osMemoryLimit": "ขีดจำกัดหน่วยความจำ OS" + }, + "gauges": { + "heapAllocation": "การจัดสรร Heap", + "heapUsage": "การใช้งาน Heap", + "heapUtilization": "การใช้ประโยชน์ Heap", + "instantCpuUsage": "การใช้งาน CPU ขณะนี้", + "availableMemoryPercentage": "% ของหน่วยความจำที่มี", + "allocatedPercentage": "% ที่จัดสรร", + "usedSPercentage": "% ของ 1 วินาทีที่ CPU ใช้", + "total": "ทั้งหมด", + "used": "ใช้แล้ว" + }, + "sections": { + "runtimeStats": { + "title": "สถิติรันไทม์", + "description": "ภาพรวมของโปรเซส Node.js และเครื่องโฮสต์ อัปเดตทุก 5 วินาที" + }, + "memoryCpu": { + "title": "หน่วยความจำและ CPU", + "description": "กระบวนการใช้หน่วยความจำที่จัดสรรและ CPU ของโฮสต์อย่างไร" + } + } + }, + "table": { + "headers": { + "timestamp": "เวลา", + "level": "ระดับ", + "service": "บริการ", + "method": "เมธอด", + "monitorId": "รหัสมอนิเตอร์", + "runCount": "จำนวนรัน", + "failCount": "จำนวนล้มเหลว", + "lastRunAt": "รันล่าสุดเมื่อ", + "lockedAt": "ล็อกเมื่อ", + "lastFinishedAt": "เสร็จสิ้นล่าสุดเมื่อ", + "lastRunTook": "รันล่าสุดใช้เวลา", + "lastFailedAt": "ล้มเหลวล่าสุดเมื่อ", + "failReason": "สาเหตุที่ล้มเหลว" + } + }, + "queueFallback": { + "title": "ไม่มีงานในคิว", + "description": "การตรวจสอบตัวตรวจสอบที่กำหนดเวลาหรือกำลังทำงานจะปรากฏที่นี่ คิวที่ว่างหมายความว่าทุกอย่างเป็นปัจจุบัน" + }, + "header": { + "title": "บันทึกและคิว", + "description": "ตรวจสอบบันทึกเซิร์ฟเวอร์ คิวงานตัวตรวจสอบ และการวินิจฉัยรันไทม์ในที่เดียว" + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "สร้างช่วงเวลาบำรุงรักษา!", + "title": "ไม่มีช่วงเวลาบำรุงรักษาที่กำหนด", + "description": "กำหนดเวลาหยุดทำงานเพื่อระงับการแจ้งเตือน และให้ทีมและผู้ใช้รู้ถึงสิ่งที่จะเกิดขึ้น" + }, + "table": { + "headers": { + "nextWindow": "ช่วงเวลาถัดไป", + "repeat": "ทำซ้ำ" + } + }, + "form": { + "general": { + "title": "ตั้งค่าทั่วไป", + "description": "ตั้งชื่อและตัวเลือกการทำซ้ำสำหรับช่วงเวลาบำรุงรักษา", + "option": { + "name": { + "label": "ชื่อ", + "placeholder": "เช่น บำรุงรักษาประจำสัปดาห์" + }, + "repeat": { + "label": "ทำซ้ำ" + } + } + }, + "startDate": { + "title": "วันที่เริ่มต้น", + "description": "เลือกวันที่เริ่มต้นสำหรับช่วงเวลาบำรุงรักษา", + "option": { + "startDate": { + "label": "วันที่เริ่มต้น" + } + } + }, + "startTime": { + "title": "เวลาเริ่มต้น", + "description": "ตั้งเวลาเริ่มต้นและระยะเวลาสำหรับช่วงเวลาบำรุงรักษา ค่าทั้งหมดเป็น UTC", + "option": { + "duration": { + "label": "ระยะเวลา" + }, + "startTime": { + "label": "เวลาเริ่มต้น" + } + }, + "monitors": { + "title": "มอนิเตอร์", + "description": "มอนิเตอร์ที่ช่วงเวลาบำรุงรักษาควรใช้งาน", + "option": { + "addMonitors": { + "label": "เพิ่มมอนิเตอร์" + } + } + } + } + }, + "header": { + "title": "ช่วงเวลาบำรุงรักษา", + "description": "กำหนดเวลาหยุดทำงานเพื่อระงับการแจ้งเตือน และให้ทีมและผู้ใช้รู้ถึงสิ่งที่จะเกิดขึ้น" + } + }, + "notifications": { + "fallback": { + "actionButton": "สร้างช่องทาง", + "title": "ยังไม่มีช่องทางการแจ้งเตือน", + "description": "เชื่อมต่อ Slack, Discord, อีเมล, webhooks และอื่น ๆ เพื่อให้คนที่ถูกต้องรู้เกี่ยวกับเหตุการณ์ทันทีที่เกิดขึ้น" + }, + "form": { + "accessToken": { + "optionAccessToken": "โทเค็นการเข้าถึง", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "ที่อยู่ที่จะส่งการแจ้งเตือน", + "optionAddress": "ที่อยู่", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "ที่อยู่" + }, + "homeServer": { + "optionHomeServer": "โฮมเซิร์ฟเวอร์", + "placeholder": "example.com" + }, + "matrix": { + "description": "กำหนดค่าการเชื่อมต่อ Matrix homeserver สำหรับการแจ้งเตือน", + "title": "ตั้งค่า Matrix" + }, + "notificationName": { + "description": "ชื่อที่สื่อความหมายสำหรับช่องทางการแจ้งเตือน", + "optionName": "ชื่อช่องทาง", + "placeholder": "เช่น การแจ้งเตือน Production", + "title": "ชื่อช่องทาง" + }, + "pagerDuty": { + "description": "คีย์การรวม PagerDuty สำหรับรับการแจ้งเตือน", + "optionIntegrationKey": "คีย์การรวม", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "คีย์การรวม" + }, + "roomId": { + "optionRoomId": "รหัสห้อง", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "เลือกประเภทช่องทางการแจ้งเตือนที่ต้องการสร้าง", + "optionType": "ประเภท", + "title": "ประเภทช่องทาง" + }, + "telegram": { + "title": "ตั้งค่า Telegram", + "description": "เพื่อเปิดใช้งานการแจ้งเตือนผ่าน Telegram ให้สร้างบอท Telegram โดยใช้ BotFather ซึ่งเป็นบอททางการสำหรับสร้างและจัดการบอท Telegram จากนั้นนำ API token และ Chat ID มาใส่ที่นี่", + "optionBotToken": "โทเค็นบอท", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "Chat ID", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "การตั้งค่า Pushover", + "description": "ป้อนโทเค็นแอปและคีย์ผู้ใช้ Pushover ของคุณ", + "optionAppToken": "โทเค็นแอป", + "placeholderAppToken": "ป้อนโทเค็นแอป", + "optionUserKey": "คีย์ผู้ใช้", + "placeholderUserKey": "ป้อนคีย์ผู้ใช้" + }, + "twilio": { + "title": "การตั้งค่า Twilio", + "description": "ป้อนข้อมูลรับรองบัญชี Twilio เพื่อส่ง SMS", + "optionAccountSid": "SID บัญชี", + "placeholderAccountSid": "ป้อน SID บัญชี", + "optionAuthToken": "โทเค็นการรับรอง", + "placeholderAuthToken": "ป้อนโทเค็นการรับรอง", + "optionFromNumber": "หมายเลขผู้ส่ง", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "หมายเลขผู้รับ", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "ปลายทาง" + } + }, + "header": { + "title": "การแจ้งเตือน", + "description": "เชื่อมต่อ Slack, Discord, อีเมล, webhooks และอื่น ๆ เพื่อให้คนที่ถูกต้องรู้เกี่ยวกับเหตุการณ์ทันทีที่เกิดขึ้น" + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "Cumulative Layout Shift (CLS)", + "fcp": "First Contentful Paint (FCP)", + "lcp": "Largest Contentful Paint (LCP)", + "si": "Speed Index (SI)", + "tbt": "Total Blocking Time (TBT)" + }, + "legend": { + "title": "รายงาน PageSpeed", + "weight": "น้ำหนัก" + }, + "pie": { + "title": "รายงานประสิทธิภาพ" + } + }, + "table": { + "headers": { + "pageSpeedScore": "คะแนน PageSpeed" + } + }, + "fallback": { + "actionButton": "สร้างมอนิเตอร์!", + "title": "ยังไม่มีตัวตรวจสอบ PageSpeed", + "description": "ติดตาม Core Web Vitals และคะแนน Lighthouse เพื่อจับความถดถอยก่อนที่ผู้ใช้จะสังเกตเห็น" + }, + "header": { + "title": "ตัวตรวจสอบ PageSpeed", + "description": "ติดตาม Core Web Vitals และคะแนน Lighthouse เพื่อจับความถดถอยก่อนที่ผู้ใช้จะสังเกตเห็น" + } + }, + "settings": { + "form": { + "timezone": { + "title": "เขตเวลาที่แสดง", + "description": "เลือกเขตเวลาที่ใช้แสดงวันที่และเวลาทั่วทั้งแอปพลิเคชัน", + "option": { + "timezone": { + "label": "เขตเวลาที่แสดง" + } + } + }, + "ui": { + "title": "รูปลักษณ์", + "description": "สลับระหว่างโหมดสว่างและมืด เปลี่ยนภาษา หรือปรับแต่งประเภทกราฟ", + "option": { + "theme": { + "label": "โหมดธีม", + "light": "สว่าง", + "dark": "มืด" + }, + "language": { + "label": "ภาษา" + }, + "chartType": { + "label": "ประเภทกราฟ", + "histogram": "ฮิสโตแกรม", + "heatmap": "แผนที่ความร้อน" + } + } + }, + "pagespeed": { + "title": "คีย์ API ของ Google PageSpeed", + "description": "กรอกคีย์ API ของ Google PageSpeed เพื่อเปิดใช้งานการตรวจสอบ Google PageSpeed คลิกรีเซ็ตเพื่ออัปเดตคีย์", + "option": { + "apiKey": { + "label": "คีย์ API ของ PageSpeed", + "labelSet": "ตั้งค่าคีย์ API แล้ว คลิกรีเซ็ตเพื่อเปลี่ยน", + "placeholder": "กรอกคีย์ API ของ Google PageSpeed" + } + } + }, + "url": { + "title": "แสดง IP/URL ของมอนิเตอร์บนหน้าสถานะ", + "description": "แสดง IP หรือ URL ของมอนิเตอร์บนหน้าสถานะสาธารณะ หากปิดใช้งาน จะแสดงเฉพาะชื่อมอนิเตอร์เพื่อปกป้องข้อมูลที่ละเอียดอ่อน", + "option": { + "showURL": { + "label": "แสดง IP/URL บนหน้าสถานะ", + "enabled": "เปิดใช้งาน", + "disabled": "ปิดใช้งาน" + } + } + }, + "stats": { + "title": "ประวัติการตรวจสอบ", + "description": "ล้างประวัติการตรวจสอบและสถิติทั้งหมดของทีม การกระทำนี้ไม่สามารถย้อนกลับได้", + "option": { + "clear": { + "label": "ล้างสถิติทั้งหมด การกระทำนี้ไม่สามารถย้อนกลับได้" + } + }, + "dialog": { + "title": "ล้างประวัติการตรวจสอบทั้งหมด?", + "description": "ไม่สามารถย้อนกลับได้" + } + }, + "retention": { + "title": "การเก็บรักษาข้อมูลตรวจสอบ", + "description": "กำหนดระยะเวลาเก็บรักษาข้อมูลตรวจสอบก่อนถูกลบอัตโนมัติ", + "option": { + "days": { + "label": "ระยะเวลาเก็บรักษา (วัน)", + "unlimited": "ไม่จำกัด" + } + } + }, + "thresholds": { + "title": "เกณฑ์ทั่วไป", + "description": "กำหนดเกณฑ์ CPU หน่วยความจำ ดิสก์ และอุณหภูมิทั่วไปสำหรับการตรวจสอบโครงสร้างพื้นฐาน ใช้กับมอนิเตอร์ฮาร์ดแวร์ทั้งหมด เว้นแต่ถูกแทนที่", + "option": { + "cpu": { + "label": "เกณฑ์ CPU (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "เกณฑ์หน่วยความจำ (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "เกณฑ์ดิสก์ (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "เกณฑ์อุณหภูมิ (°C)", + "placeholder": "1 - 150" + } + } + }, + "email": { + "title": "ตั้งค่าอีเมล", + "description": "กำหนดค่าอีเมลสำหรับระบบ ใช้สำหรับส่งการแจ้งเตือน", + "descriptionTransport": "สร้าง SMTP transport สำหรับ NodeMailer", + "descriptionTransportLink": "ดูข้อกำหนดที่นี่", + "option": { + "host": { + "label": "โฮสต์อีเมล - ชื่อโฮสต์หรือ IP ที่จะเชื่อมต่อ", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "พอร์ตอีเมล - พอร์ตที่จะเชื่อมต่อ", + "placeholder": "587" + }, + "address": { + "label": "ที่อยู่อีเมล - ใช้สำหรับยืนยันตัวตน", + "placeholder": "you@example.com" + }, + "user": { + "label": "ผู้ใช้อีเมล - ชื่อผู้ใช้สำหรับยืนยันตัวตน แทนที่อีเมลหากระบุ", + "placeholder": "เว้นว่างหากไม่จำเป็น" + }, + "password": { + "label": "รหัสผ่านอีเมล - รหัสผ่านสำหรับยืนยันตัวตน", + "labelSet": "ตั้งค่ารหัสผ่านแล้ว คลิกรีเซ็ตเพื่อเปลี่ยน", + "placeholder": "กรอกรหัสผ่านของคุณ" + }, + "tlsServername": { + "label": "ชื่อเซิร์ฟเวอร์ TLS - ชื่อโฮสต์สำหรับตรวจสอบ TLS เมื่อโฮสต์เป็น IP (ไม่บังคับ)", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "โฮสต์เชื่อมต่ออีเมล - ชื่อโฮสต์ที่ใช้ในการ HELO/EHLO", + "placeholder": "localhost" + }, + "secure": { + "label": "ใช้ SSL (แนะนำ): เข้ารหัสการเชื่อมต่อด้วย SSL/TLS" + }, + "pool": { + "label": "เปิดใช้งาน connection pooling: ใช้การเชื่อมต่อที่มีอยู่ซ้ำเพื่อเพิ่มประสิทธิภาพ" + }, + "ignoreTLS": { + "label": "ปิดใช้งาน STARTTLS: ไม่ใช้ TLS แม้เซิร์ฟเวอร์จะรองรับ" + }, + "requireTLS": { + "label": "บังคับใช้ STARTTLS: ต้องอัปเกรดเป็น TLS ล้มเหลวหากไม่รองรับ" + }, + "rejectUnauthorized": { + "label": "ปฏิเสธใบรับรองที่ไม่ถูกต้อง: ปฏิเสธการเชื่อมต่อด้วยใบรับรอง self-signed หรือไม่น่าเชื่อถือ" + } + } + }, + "demoMonitors": { + "title": "มอนิเตอร์สาธิต", + "description": "เพิ่มมอนิเตอร์ตัวอย่างเพื่อวัตถุประสงค์ในการ���าธิต" + }, + "removeMonitors": { + "title": "รีเซ็ตระบบ", + "description": "ลบมอนิเตอร์ทั้งหมดจากระบบของคุณ", + "dialog": { + "title": "ลบมอนิเตอร์ทั้งหมด?", + "description": "การกระทำนี้ไม่สามารถย้อนกลับได้", + "paragraph": "ประวัติการตรวจสอบทั้งหมด เหตุการณ์ ช่วงเวลาบำรุงรักษาที่กำหนดไว้ และหน้าสถานะที่ใช้มอนิเตอร์เหล่านี้จะถูกลบไปด้วย ส่งออกมอนิเตอร์ก่อนหากต้องการเก็บสำเนาข้อมูลสำรองไว้" + } + }, + "exportMonitors": { + "title": "ส่งออกมอนิเตอร์" + }, + "importExportMonitors": { + "title": "นำเข้า / ส่งออกมอนิเตอร์", + "description": "นำเข้าหรือส่งออกข้อมูลมอนิเตอร์เป็นไฟล์ JSON สำหรับสำรองหรือโอนย้าย" + }, + "about": { + "title": "เกี่ยวกับ", + "developedBy": "พัฒนาโดย Bluewave Labs" + }, + "validation": { + "errorMessage": "กรุณาแก้ไขข้อผิดพลาดการตรวจสอบต่อไปนี้:" + } + }, + "header": { + "title": "การตั้งค่า", + "description": "กำหนดค่า SMTP, การผสานรวม, การเก็บรักษา และพฤติกรรมแอปทั่วโลก" + } + }, + "statusPages": { + "deleteSuccess": "ลบหน้าสถานะสำเร็จ", + "fallback": { + "title": "ยังไม่มีหน้าสถานะ", + "actionButton": "สร้างหน้าสถานะ!", + "description": "เผยแพร่หน้าสาธารณะที่แสดง uptime แบบเรียลไทม์และประวัติเหตุการณ์ให้กับลูกค้าและผู้มีส่วนได้ส่วนเสีย" + }, + "monitorsList": { + "chartTypeHeatmap": "แผนที่ความร้อน", + "chartTypeHistogram": "ฮิสโตแกรม", + "noData": "ไม่มีข้อมูล", + "infrastructure": { + "title": "โครงสร้างพื้นฐาน", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "หน่วยความจำ", + "disk": "ดิสก์", + "usage": "การใช้งาน", + "used": "ใช้แล้ว", + "total": "ทั้งหมด" + }, + "uptime": { + "title": "อัปไทม์", + "responseTime": "เวลาตอบสนอง" + } + }, + "statusBar": { + "allDown": "ระบบทั้งหมดล่ม", + "allUp": "ระบบทั้งหมดทำงานปกติ", + "degraded": "บางระบบกำลังประสบปัญหา", + "unknown": "ไม่สามารถระบุสถานะระบบได้", + "allBreached": "ทุกระบบเกินขีดจำกัด", + "allMaintenance": "ทุกระบบอยู่ระหว่างการบำรุงรักษา", + "allPaused": "ทุกระบบถูกหยุดชั่วคราว", + "allInitializing": "ทุกระบบกำลังเริ่มต้น", + "breachedAndDown": "บางระบบเกินขีดจำกัดและขัดข้อง", + "breached": "บางระบบเกินขีดจำกัด", + "maintenanceAndDown": "บางระบบขัดข้องระหว่างการบำรุงรักษา", + "maintenance": "บางระบบอยู่ระหว่างการบำรุงรักษา", + "partiallyPaused": "บางระบบถูกหยุดชั่วคราว", + "initializing": "บางระบบกำลังเริ่มต้น", + "noMonitors": "ยังไม่มีการกำหนดค่ามอนิเตอร์" + }, + "table": { + "headers": { + "name": "ชื่อหน้าสถานะ", + "url": "URL สาธารณะ" + }, + "published": "เผยแพร่แล้ว", + "unpublished": "ยังไม่เผยแพร่" + }, + "title": "หน้าสถานะ", + "details": { + "empty": { + "title": "ยังไม่มีข้อมูลที่นี่", + "addMonitor": "เพิ่มมอนิเตอร์เพื่อเริ่มต้น" + } + }, + "form": { + "access": { + "title": "การเข้าถึง", + "description": "หากหน้าสถานะพร้อมแล้ว คุณสามารถทำเครื่องหมายว่าเผยแพร่แล้ว", + "option": { + "published": { + "name": "เผยแพร่แล้วและสาธารณะสามารถเห็นได้" + } + } + }, + "basicInfo": { + "title": "ข้อมูลพื้นฐาน", + "description": "กำหนดชื่อบริษัทและซับโดเมนที่หน้าสถานะชี้ไป", + "option": { + "name": { + "label": "ชื่อบริษัท", + "placeholder": "Acme Inc." + }, + "url": { + "label": "ที่อยู่หน้าสถานะของคุณ", + "placeholder": "my-status-page" + } + } + }, + "monitors": { + "title": "มอนิเตอร์", + "description": "เลือกมอนิเตอร์ที่จะแสดงบนหน้าสถานะ", + "noMonitors": "ไม่ได้เลือกมอนิเตอร์", + "option": { + "monitors": { + "label": "เลือกมอนิเตอร์", + "placeholder": "ค้นหาและเลือกมอนิเตอร์..." + } + } + }, + "timezone": { + "title": "เขตเวลา", + "description": "เลือกเขตเวลาที่หน้าสถานะจะแสดง", + "option": { + "timezone": { + "label": "เขตเวลา", + "placeholder": "เลือกเขตเวลา..." + } + } + }, + "appearance": { + "title": "รูปลักษณ์", + "description": "กำหนดรูปลักษณ์เริ่มต้นของหน้าสถานะสาธารณะ", + "option": { + "logo": { + "label": "โลโก้" + }, + "color": { + "label": "สีแบรนด์" + } + } + }, + "features": { + "title": "ฟีเจอร์", + "description": "กำหนดค่าข้อมูลที่แสดงบนหน้าสถานะ", + "option": { + "showCharts": { + "label": "แสดงกราฟเวลาตอบสนอง" + }, + "showUptimePercentage": { + "label": "แสดงเปอร์เซ็นต์อัปไทม์" + }, + "showAdminLoginLink": { + "label": "แสดงลิงก์เข้าสู่ระบบผู้ดูแล" + }, + "showInfrastructure": { + "label": "แสดงเมตริกโครงสร้างพื้นฐาน" + } + } + } + }, + "header": { + "title": "หน้าสถานะ", + "description": "เผยแพร่หน้าสาธารณะที่แสดง uptime แบบเรียลไทม์และประวัติเหตุการณ์ให้กับลูกค้าและผู้มีส่วนได้ส่วนเสีย" + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "ค้นหามอนิเตอร์..." + } + }, + "table": { + "headers": { + "responseTime": "เวลาตอบสนอง" + } + }, + "fallback": { + "actionButton": "สร้างมอนิเตอร์!", + "title": "ยังไม่มีตัวตรวจสอบ uptime", + "description": "ติดตาม HTTP endpoints, ping, คอนเทนเนอร์ และพอร์ต — รับการแจ้งเตือนทันทีที่บางอย่างล่ม" + }, + "header": { + "title": "ตัวตรวจสอบ uptime", + "description": "ติดตาม HTTP endpoints, ping, คอนเทนเนอร์ และพอร์ต — รับการแจ้งเตือนทันทีที่บางอย่างล่ม" + } } } } diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index 59f294533..c2ef7b2a7 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -1,1108 +1,1447 @@ { - "submit": "Gönder", - "title": "Başlık", - "distributedStatusHeaderText": "Gerçek zamanlı, Gerçek cihazlar kapsamı", - "distributedStatusSubHeaderText": "Dünya çapında milyonlarca cihaz tarafından desteklenen sistem performansını küresel bölgeye, ülkeye veya şehre göre görüntüleyin", - "settingsDisabled": "Devre dışı", - "settingsSuccessSaved": "Ayarlar başarıyla kaydedildi", - "settingsFailedToSave": "Ayarlar kaydedilemedi", - "settingsStatsCleared": "İstatistikler başarıyla temizlendi", - "settingsFailedToClearStats": "İstatistikler temizlenemedi", - "settingsMonitorsDeleted": "Tüm monitörler başarıyla silindi", - "settingsFailedToDeleteMonitors": "Monitörler silinemedi", - "starPromptTitle": "Checkmate yıldızla değerlendirin", - "starPromptDescription": "En son sürümleri görün ve GitHub'daki topluluğun büyümesine yardımcı olun", - "https": "HTTPS", - "http": "HTTP", - "monitor": "monitör", - "aboutus": "Hakkımızda", - "now": "Şimdi", - "delete": "Sil", - "configure": "Yapılandır", - "responseTime": "Yanıt süresi:", - "ms": "ms", - "bar": "Çubuk", - "area": "Alan", - "country": "ÜLKE", - "city": "ŞEHİR", - "response": "YANIT", - "monitorStatusUp": "Monitör {name} ({url}) ayakta ve yanıt veriyor", - "monitorStatusDown": "Monitör {name} ({url}) yanıt vermiyor", - "webhookSendSuccess": "Web kanca bildirimi başarıyla gönderildi", - "webhookSendError": "Web kanca bildirimi bu platforma gönderilemedi: {platform}", - "webhookUnsupportedPlatform": "Desteklenmeyen platform: {platform}", - "distributedRightCategoryTitle": "Monitör", - "distributedStatusServerMonitors": "Sunucu monitörleri", - "distributedStatusServerMonitorsDescription": "İlgili sunucuların monitör durumları", - "distributedUptimeCreateSelectURL": "Burada sunucu adresini (URL) ve monitör türünü girebilirsiniz.", - "distributedUptimeCreateChecks": "Yapılacak kontroller", - "distributedUptimeCreateChecksDescription": "Adresi girdikten sonra kontrol ekleyebilir ya da çıkartabilirsiniz.", - "distributedUptimeCreateIncidentNotification": "Olay bildirimleri", - "distributedUptimeCreateIncidentDescription": "Bir olay olduğunda kullanıcıları bilgilendir.", - "distributedUptimeCreateAdvancedSettings": "Gelişmiş ayarlar", - "distributedUptimeDetailsNoMonitorHistory": "Bu monitör için henüz bir denetim günlüğü oluşturulmadı.", - "distributedUptimeDetailsStatusHeaderUptime": "Erişilebilirlik", - "distributedUptimeDetailsStatusHeaderLastUpdate": "Son güncelleme", - "notifications": { - "enableNotifications": "{{platform}} bildirimlerini etkinleştir", - "testNotification": "Bildirimi test et", - "addOrEditNotifications": "Bildirimleri ekle ya da düzenle", - "slack": { - "label": "Slack", - "description": "Slack bildirimlerini etkinleştirmek için bir Slack uygulaması oluşturun ve gelen web kancalarını etkinleştirin. Daha sonra, webhook URL’sini buraya girmeniz yeterlidir.\n\n\n\n\n\n", - "webhookLabel": "Web kanca URL:", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "Slack webhook URL'si gereklidir" - }, - "discord": { - "label": "Discord", - "description": "Checkmate’ten Discord bildirimleri aracılığıyla bir Discord kanalına veri göndermek için Discord’un gelen web kancaları özelliğini kullanabilirsiniz.\n\n\n\n\n\n\n\n", - "webhookLabel": "Discord web kanca URL", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "Discord webhook URL'si gerekli" - }, - "telegram": { - "label": "Telegram", - "description": "Telegram bildirimlerini etkinleştirmek için, Telegram botlarını oluşturup yönetmek için kullanılan resmi bot BotFather ile bir Telegram botu oluşturun. Daha sonra, API jetonunu (token) ve sohbet kimliğini (chat ID) alın ve buraya yazın.", - "tokenLabel": "Bot jetonu (token)", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "Sohbet ID", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "Telegram token ve sohbet kimliği gereklidir" - }, - "webhook": { - "label": "Web kancaları", - "description": "Olaylar meydana geldiğinde bildirim almak için özel bir web kancası ayarlayabilirsiniz.", - "urlLabel": "Web kanca URL:", - "urlPlaceholder": "https://sunucu-adresiniz.com/webhook", - "urlRequired": "Webhook URL'si gerekli" - }, - "testNotificationDevelop": "Test bildirimi 2", - "integrationButton": "Bildirim Entegrasyonu", - "testSuccess": "Test bildirimi başarıyla gönderildi", - "testFailed": "Test bildirimi gönderilemedi", - "unsupportedType": "Desteklenmeyen bildirim türü", - "networkError": "Ağ hatası oluştu", - "fallback": { - "title": "", - "checks": [""], - "actionButton": "" - }, - "createButton": "Bildirim kanalı oluştur", - "createTitle": "Bildirim kanalı", - "create": { - "success": "", - "failed": "Bildirim kanalı oluşturulamadı" - }, - "fetch": { - "success": "Bildirimler başarıyla alındı", - "failed": "Bildirimler alınamadı" - }, - "delete": { - "success": "Bildirim başarıyla silindi", - "failed": "Bildirim silinemedi" - }, - "edit": { - "success": "Bildirim başarıyla güncellendi", - "failed": "Bildirim güncellenemedi" - }, - "test": { - "success": "Test bildirimi başarıyla gönderildi", - "failed": "" - } - }, - "testLocale": "TEST13 UPLOAD", - "add": "Ekle", - "monitors": "Monitörler", - "distributedUptimeStatusCreateStatusPage": "durum sayfası", - "distributedUptimeStatusCreateStatusPageAccess": "Erişim", - "distributedUptimeStatusCreateStatusPageReady": "Durum sayfanız hazırsa yayınlayabilirsiniz.", - "distributedUptimeStatusBasicInfoHeader": "Temel bilgiler", - "distributedUptimeStatusBasicInfoDescription": "Şirket adınızı ve durum sayfanızın işaret ettiği alt alan adını tanımlayın.", - "distributedUptimeStatusLogoHeader": "Logo", - "distributedUptimeStatusLogoDescription": "Durum sayfanız için bir logo yükleyin", - "distributedUptimeStatusLogoUploadButton": "Logo yükle", - "distributedUptimeStatusStandardMonitorsHeader": "Standart monitörler", - "distributedUptimeStatusStandardMonitorsDescription": "Durum sayfanıza standart monitörleri ekleyin.", - "distributedUptimeStatusCreateYour": "Oluştur:", - "distributedUptimeStatusEditYour": "Düzenle:", - "distributedUptimeStatusPublishedLabel": "Yayında ve herkes tarafından görülebilir", - "distributedUptimeStatusCompanyNameLabel": "Şirket adı", - "distributedUptimeStatusPageAddressLabel": "Durum sayfası adresi", - "distributedUptimeStatus30Days": "30 gün", - "distributedUptimeStatus60Days": "60 gün", - "distributedUptimeStatus90Days": "90 gün", - "distributedUptimeStatusPageNotSetUp": "Henüz bir durum sayfası oluşturulmadı.", - "distributedUptimeStatusContactAdmin": "Lütfen yöneticiniz ile görüşün", - "distributedUptimeStatusPageNotPublic": "Bu durum sayfası herkese açık değil", - "distributedUptimeStatusPageDeleteDialog": "Bu durum sayfasını silmek istiyor musunuz? ", - "distributedUptimeStatusPageDeleteConfirm": "Evet, durum sayfasını sil", - "distributedUptimeStatusPageDeleteDescription": "Silindiği zaman durum sayfalarını geri getiremezsiniz.", - "distributedUptimeStatusDevices": "Cihazlar", - "distributedUptimeStatusUpt": "UPT", - "distributedUptimeStatusUptBurned": "Yakılan UPT", - "distributedUptimeStatusUptLogo": "Upt Logo", - "incidentsTableNoIncidents": "Hiç olay kaydedilmedi", - "incidentsTablePaginationLabel": "olaylar", - "incidentsTableMonitorName": "Monitör adı", - "incidentsTableStatus": "Durum", - "incidentsTableDateTime": "Tarih ve saat", - "incidentsTableStatusCode": "Durum kodu", - "incidentsTableMessage": "Mesaj", - "incidentsOptionsHeader": "Servisler:", - "incidentsOptionsHeaderFilterBy": "Filtrele:", - "incidentsOptionsHeaderFilterAll": "Tümü", - "incidentsOptionsHeaderFilterDown": "Erişilemiyor", - "incidentsOptionsHeaderFilterCannotResolve": "Çözümlenemiyor", - "incidentsOptionsHeaderShow": "Göster:", - "incidentsOptionsHeaderLastHour": "Son saat", - "incidentsOptionsHeaderLastDay": "Son gün", - "incidentsOptionsHeaderLastWeek": "Son hafta", - "incidentsOptionsPlaceholderAllServers": "Tüm sunucular", - "infrastructureCreateYour": "Oluştur:", - "infrastructureCreateGeneralSettingsDescription": "Burada, sunucu aracısına bağlanmak için kolay ad ve yetkilendirme sırrıyla birlikte ana bilgisayarın URL'sini seçebilirsiniz.", - "infrastructureServerRequirement": "İzlediğiniz sunucunun şu şekilde çalışması gerekir:", - "infrastructureCustomizeAlerts": "Alarmları özelleştir", - "infrastructureAlertNotificationDescription": "Eşikler belirtilen bir yüzdeyi aştığında kullanıcıya bildirim gönder.", - "infrastructureCreateMonitor": "Altyapı Monitörü oluştur", - "infrastructureProtocol": "Protokol", - "infrastructureServerUrlLabel": "Sunucu adresi", - "infrastructureDisplayNameLabel": "Görünen adı", - "infrastructureAuthorizationSecretLabel": "Kimlik denetimi parolası", - "gb": "GB", - "mb": "MB", - "mem": "Bellek", - "memoryUsage": "Bellek kullanımı", - "cpu": "CPU", - "cpuUsage": "CPU kullanımı", - "cpuTemperature": "CPU sıcaklığı", - "diskUsage": "Disk kullanımı", - "used": "Kullanılan", - "total": "Toplam", - "cores": "Çekirdek", - "frequency": "Frekans", - "status": "Durum", - "cpuPhysical": "CPU (fiziksel)", - "cpuLogical": "CPU (Mantıksal)", - "cpuFrequency": "CPU Frekansı", - "avgCpuTemperature": "Ortalama CPU Isısı", - "memory": "Bellek", - "disk": "Disk", - "uptime": "Çalışma süresi", - "os": "İşletim sistemi", - "host": "Makine", - "actions": "İşlemler", - "integrations": "Entegrasyonlar", - "integrationsPrism": "Prism'i favori servisinize bağlayın.", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "Slack'e bağlanın ve bir kanaldaki olayları görün", - "integrationsDiscord": "Discord", - "integrationsDiscordInfo": "Discord'a bağlanın ve olayları doğrudan bir kanalda görüntüleyin", - "integrationsZapier": "Zapier", - "integrationsZapierInfo": "Tüm olayları Zapier'a gönderin ve sonra her yerde görün", - "commonSave": "Kaydet", - "createYour": "Senin yarat", - "createMonitor": "Monitör oluştur", - "pause": "Beklet", - "resume": "Yeniden başlat", - "editing": "Düzenleniyor...", - "url": "URL", - "access": "Erişim", - "timezone": "Zaman dilimi", - "features": "Özellikler", - "administrator": "Yönetici misin?", - "loginHere": "Buradan giriş yap", - "displayName": "Görüntülenecek isim", - "urlMonitor": "Monitör edilecek URL", - "portToMonitor": "Monitör edilecek port", - "websiteMonitoring": "Web sitesi monitörü", - "websiteMonitoringDescription": "Web sitenizi veya API uç noktanızı izlemek için HTTP(s) kullanın.", - "pingMonitoring": "Ping monitörü", - "pingMonitoringDescription": "Sitenizin ayakta olup olmadığını denetleyin.", - "dockerContainerMonitoring": "Docker konteyner monitörü", - "dockerContainerMonitoringDescription": "Docker konteynerinizin ayakta olup olmadığını denetleyin.", - "portMonitoring": "Port monitörü", - "portMonitoringDescription": "Portunuzun açık olup olmadığını denetleyin.", - "createMaintenanceWindow": "Bakım aralığı oluşturun", - "createMaintenance": "Bakım oluştur", - "editMaintenance": "Bakımı düzenle", - "maintenanceWindowName": "Bakım aralığı adı", - "friendlyNameInput": "Görünen ad", - "friendlyNamePlaceholder": "__:__ saatinde __ dakika boyunca bakım var", - "maintenanceRepeat": "Bakım Tekrarı", - "maintenance": "bakım", - "duration": "Süre", - "addMonitors": "Monitör ekle", - "window": "pencere", - "cancel": "İptal", - "message": "Mesaj", - "low": "düşük", - "high": "yüksek", - "statusCode": "Durum kodu", - "date&Time": "Tarih & saat", - "type": "Tür", - "statusPageName": "Durum sayfası adı", - "publicURL": "Genel URL", - "repeat": "Tekrarla", - "edit": "Düzenle", - "createA": "Bir tane oluştur", - "remove": "Kaldırmak", - "maintenanceWindowDescription": "Bu zaman diliminde pingleriniz gönderilmeyecek", - "startTime": "Başlangıç saati", - "timeZoneInfo": "Tüm tarihler ve saatler GMT+0 zaman dilimindedir.", - "monitorsToApply": "Bakım penceresini uygulamak için monitörler", - "nextWindow": "Sonraki pencere", - "notFoundButton": "Ana panoya git", - "pageSpeedConfigureSettingsDescription": "Burada, monitör tipiyle birlikte sunucunun URL'sini seçebilirsiniz.", - "monitorDisplayName": "Monitör görüntüleme adı", - "whenNewIncident": "Yeni bir olay oluştuğunda,", - "notifySMS": "SMS ile bildir (yakında)", - "notifyEmails": "Ayrıca birden fazla eposta adresine bildirim gönderebilirsiniz (yakında geliyor)", - "seperateEmails": "Birden fazla epostayı virgülle ayırabilirsiniz", - "checkFrequency": "Frekansı denetle", - "matchMethod": "Eşleşme yöntemi", - "expectedValue": "Beklenen değer", - "deleteDialogTitle": "Gerçekten bu monitörü silmek istiyor musunuz?", - "deleteDialogDescription": "Bir kez silindiği zaman tekrar getiremezsiniz.", - "pageSpeedMonitor": "Sayfa Hızı Monitörü", - "shown": "Gösteriliyor: ", - "ago": "önce", - "companyName": "Firma adı", - "pageSpeedDetailsPerformanceReport": "Değerler yaklaşıktır ve değişebilir", - "pageSpeedDetailsPerformanceReportCalculator": "Hesap makinesini gör", - "checkingEvery": "Denetleme sıklığı", - "statusPageCreateSettings": "Eğer durum sayfanız hazırsa, herkese açık yayınlayabilirsiniz", - "basicInformation": "Temel Bilgiler", - "statusPageCreateBasicInfoDescription": "Şirket adınızı ve durum sayfanızın işaret ettiği alt alan adını tanımlayın.", - "statusPageCreateSelectTimeZoneDescription": "Durum sayfanızın hangi zaman diliminde görüntüleneceğini seçin.", - "statusPageCreateAppearanceDescription": "Genel durum sayfanızın varsayılan görünümünü ve hissini tanımlayın.", - "statusPageCreateSettingsCheckboxLabel": "Yayınlandı ve herkese açık", - "statusPageCreateBasicInfoStatusPageAddress": "Durum sayfası adresiniz", - "statusPageCreateTabsContent": "Durum sayfası sunucuları", - "statusPageCreateTabsContentDescription": "İzlediğiniz herhangi bir sayıda sunucuyu durum sayfanıza ekleyebilirsiniz. Ayrıca en iyi görüntüleme deneyimi için bunları yeniden sıralayabilirsiniz.", - "statusPageCreateTabsContentFeaturesDescription": "Durum sayfasında daha fazla bilgi göster", - "showCharts": "Çizelgeleri göster", - "showUptimePercentage": "Çalışma süresi yüzdesini göster", - "removeLogo": "Logoyu sil", - "statusPageStatus": "Herkese açık bir durum sayfası henüz oluşturulmadı.", - "statusPageStatusContactAdmin": "Lütfen yöneticinizle iletişime geçin.", - "statusPageStatusNotPublic": "Durum sayfası herkese açık değil.", - "statusPageStatusNoPage": "Burada bir durum sayfası bulunmuyor.", - "statusPageStatusServiceStatus": "Servis durumu", - "deleteStatusPage": "Gerçekten bu durum sayfasını silmek istiyor musunuz?", - "deleteStatusPageConfirm": "Evet, durum sayfasını sil", - "deleteStatusPageDescription": "Silindikten sonra durum sayfasını geri getiremezsiniz.", - "uptimeCreate": "Beklenen değer, yanıt sonucuyla eşleştirilir ve bu eşleşme durumu belirler.", - "uptimeCreateJsonPath": "Bu ifade, yanıt JSON verisi üzerinde değerlendirilir, ortaya çıkan sonuç beklenen değerle karşılaştırılır. Bkz:", - "uptimeCreateJsonPathQuery": "Sorgu dili dokümantasyonu için.", - "maintenanceTableActionMenuDialogTitle": "Gerçekten bu bakım aralığını silmek istiyor musunuz?", - "infrastructureEditYour": "Düzenle", - "infrastructureEditMonitor": "Altyapı İzlemeyi Kaydet", - "infrastructureMonitorCreated": "Altyapı izleyicisi başarıyla oluşturuldu!", - "infrastructureMonitorUpdated": "Altyapı izleyicisi başarıyla güncellendi!", - "errorInvalidTypeId": "Geçersiz bildirim türü sağlandı", - "errorInvalidFieldId": "Geçersiz alan kimliği sağlandı", - "inviteNoTokenFound": "Davet belirteci bulunamadı", - "pageSpeedWarning": "Uyarı: Henüz bir Google PageSpeed API anahtarı eklemediniz. Bu olmadan PageSpeed izleyicisi çalışmayacaktır.", - "pageSpeedLearnMoreLink": "Buraya tıklayın", - "pageSpeedAddApiKey": "(API anahtarınızı eklemek için)", - "update": "Güncelleme", - "invalidFileFormat": "Desteklenmeyen dosya biçimi!", - "invalidFileSize": "Dosya boyutu çok büyük!", - "ClickUpload": "Yüklemek için tıklayın", - "DragandDrop": "sürükle ve bırak", - "MaxSize": "Azami Boyut", - "SupportedFormats": "Desteklenen formatlar", - "FirstName": "İsim", - "LastName": "Soyisim", - "EmailDescriptionText": "Geçerli epostanız - bu eposta değiştirilemez.", - "YourPhoto": "Profil fotoğrafı", - "PhotoDescriptionText": "Bu fotoğrafınız profil sayfasında görüntülenecektir.", - "save": "Kaydet", - "DeleteDescriptionText": "Bu, hesabı ve ilişkili tüm verileri sunucudan kaldıracaktır. Bu geri alınamaz.", - "DeleteAccountWarning": "Hesabınızı kaldırmanız, tekrar oturum açamayacağınız ve tüm verilerinizin silineceği anlamına gelir. Bu geri alınamaz.", - "DeleteWarningTitle": "Gerçekten bu hesabı silmek istiyor musunuz?", - "bulkImport": { - "title": "Toplu içeri alma", - "selectFileTips": "Yüklenecek CSV dosyasını seçin", - "selectFileDescription": " veya örneğimizi indirebilirsiniz", - "selectFile": "Dosyayı seçin", - "parsingFailed": "Ayrıştırma başarısız oldu", - "uploadSuccess": "Monitör başarıyla oluşturuldu!", - "validationFailed": "Doğrulama başarısız oldu", - "noFileSelected": "Hiçbir dosya seçilmedi", - "fallbackPage": "Toplu olarak sunucu listesini yüklemek için bir dosyayı içe aktarın", - "invalidFileType": "", - "uploadFailed": "" - }, - "DeleteAccountTitle": "Hesabı sil", - "DeleteAccountButton": "Hesabı sil", - "publicLink": "Herkese açık bağlantı", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "reset": "Sıfırla", - "ignoreTLSError": "TLS/SSL hatalarını gözardı et", - "tlsErrorIgnored": "TLS/SSL hataları gözardı ediliyor", - "ignoreTLSErrorDescription": "TLS/SSL hatalarını gözardı et ve websitesinin ayakta olduğunu denetle", - "createNew": "Yeni oluştur", - "greeting": { - "prepend": "Merhaba", - "append": "Öğleden sonra muhteşem geçsin!", - "overview": "" - }, - "roles": { - "superAdmin": "Süper yönetici", - "admin": "Yönetici", - "teamMember": "Ekip elemanı", - "demoUser": "Demo kullanıcısı" - }, - "teamPanel": { - "teamMembers": "Ekip elemanları", - "filter": { - "all": "Tümü", - "member": "Eleman" - }, - "inviteTeamMember": "Ekip elemanı davet et", - "inviteNewTeamMember": "Yeni ekip elemanı davet et", - "inviteDescription": "", - "email": "Eposta", - "selectRole": "Rolü seçin", - "inviteLink": "Davet linki", - "cancel": "İptal", - "noMembers": "Bu role sahip hiç üye yok", - "getToken": "", - "emailToken": "", - "table": { - "name": "İsim", - "email": "Eposta", - "role": "Rol", - "created": "Oluşturulma" - }, - "addTeamMember": { - "addMemberMenu": "", - "title": "", - "description": "", - "addButton": "" - }, - "register": "", - "registerToast": { - "success": "", - "dbUserExists": "", - "unknownError": "" - }, - "registerTeamMember": { - "title": "", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "lastName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "email": { - "errors": { - "empty": "", - "invalid": "" - } - }, - "role": { - "errors": { - "empty": "" - } - } - } - } - } - }, - "role": "", - "changeTeamPassword": { - "changePasswordMenu": "", - "title": "", - "description": "", - "success": "" - } - }, - "monitorState": { - "paused": "Durduruldu", - "resumed": "Yeniden başlatıldı", - "active": "Aktif" - }, - "menu": { - "uptime": "", - "pagespeed": "Sayfa hızı", - "infrastructure": "", - "incidents": "Olaylar", - "statusPages": "Durum sayfaları", - "maintenance": "Bakım", - "integrations": "Entegrasyonlar", - "settings": "Ayarlar", - "support": "Destek", - "discussions": "Soru-cevap", - "docs": "Dokümanlar", - "changelog": "Değişiklik günlüğü", - "profile": "Profil", - "password": "Parola", - "team": "Ekip", - "logOut": "Çıkış yap", - "notifications": "Bildirimler", - "logs": "Günlükler" - }, - "settingsEmailUser": "E-posta kullanıcısı – Kimlik doğrulama için kullanıcı adı; belirtilirse e-posta adresinin yerine geçer.", - "state": "Durum", - "statusBreadCrumbsStatusPages": "Durum sayfası", - "statusBreadCrumbsDetails": "Detaylar", - "commonSaving": "Kaydediliyor.", - "navControls": "Kontroller", - "incidentsPageTitle": "Olaylar", - "passwordPanel": { - "passwordChangedSuccess": "Şifreniz başarı ile değiştirildi.", - "passwordInputIncorrect": "Şifreniz hatalı", - "currentPassword": "Mevcut parola", - "enterCurrentPassword": "Mevcut parolanızı girin", - "newPassword": "Yeni parola", - "enterNewPassword": "Yeni parolanızı girin", - "confirmNewPassword": "Yeni şifrenizi onaylayın", - "passwordRequirements": "Yeni şifreniz en az 8 karakterden oluşmalı ve en az bir büyük harf, bir küçük harf, bir rakam ve bir özel karakter içermelidir.", - "saving": "Kaydediliyor..." - }, - "emailSent": "E-posta başarı ile gönderildi.", - "failedToSendEmail": "E-posta gönderimi başarısız.", - "settingsTestEmailSuccess": "Test E-postası başarı ile gönderildi.", - "settingsTestEmailFailed": "Test E-postası gönderimi başarısız.", - "settingsTestEmailFailedWithReason": "Test E-postası gönderimi başarısız: {{reason}}", - "settingsTestEmailUnknownError": "Bilinmeyen hata.", - "statusMsg": { - "paused": "İzleme duraklatıldı.", - "up": "Siteniz yayında.", - "down": "Siteniz kapalı.", - "pending": "Sırada.." - }, - "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "", - "game": "", - "https": "" - }, "common": { - "appName": "Checkmate", - "monitoringAgentName": "", - "buttons": { - "toggleTheme": "" + "auth": { + "roles": { + "admin": "Yönetici", + "demo": "Demo", + "superadmin": "Süper yönetici", + "user": "Kullanıcı" + } }, - "toasts": { - "networkError": "", - "checkConnection": "Lütfen bağlantınızı kontrol edin", - "unknownError": "Bilinmeyen hata" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "Devam et", - "back": "Geri" + "alerts": { + "pageSpeedApiKey": { + "content": "Uyarı: Henüz bir Google PageSpeed API anahtarı eklemediniz. Eklemek için Ayarlar sayfasını ziyaret edin. Bu anahtar olmadan PageSpeed monitörü çalışmayacaktır." + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "Detaylar", + "home": "Ana sayfa" + }, + "buttons": { + "addMember": "Üye ekle", + "cancel": "İptal", + "close": "Kapat", + "configure": "Yapılandır", + "confirm": "Onayla", + "create": "Oluştur", + "delete": "Sil", + "generateToken": "Token oluştur", + "incidents": "Olaylar", + "inviteMember": "Üye davet et", + "pause": "Duraklat", + "resume": "Devam et", + "save": "Kaydet", + "sendInvite": "Davet gönder", + "test": "Test", + "testNotifications": "Bildirimleri test et", + "toggleTheme": "Açık / koyu temayı değiştir", + "flushQueue": "Kuyruğu temizle", + "notFound": "Ana panele git", + "resetPassword": "Parolayı sıfırla", + "reset": "Sıfırla", + "clear": "Temizle", + "addDemo": "Demo monitörler ekle", + "removeMonitors": "Monitörleri kaldır", + "sendTestEmail": "Test e-postası gönder", + "exportToJSON": "JSON olarak dışa aktar", + "importFromJSON": "JSON dosyasından içe aktar", + "clearFilters": "Filtreleri temizle", + "removeUser": "Kullanıcıyı kaldır" + }, + "charts": { + "labels": { + "averageResponseTime": "Ortalama yanıt süresi", + "downtime": "Kesinti süresi", + "high": "yüksek", + "low": "düşük", + "uptime": "Çalışma süresi" }, - "inputs": { - "email": { - "label": "Eposta", - "placeholder": "", - "errors": { - "empty": "Devam etmek için eposta adresinizi girin", - "invalid": "" - } - }, - "password": { - "label": "Parola", - "rules": { - "length": { - "beginning": "", - "highlighted": "8 karakter uzunluğunda" - }, - "special": { - "beginning": "En az içermelidir: ", - "highlighted": "bir özel karakter" - }, - "number": { - "beginning": "En az içermelidir: ", - "highlighted": "bir sayı" - }, - "uppercase": { - "beginning": "En az içermelidir: ", - "highlighted": "Bir büyük harf" - }, - "lowercase": { - "beginning": "En az içermelidir: ", - "highlighted": "Bir küçük harf" - }, - "match": { - "beginning": "", - "highlighted": "eşleşmelidir" - } - }, - "errors": { - "empty": "Lütfen parolanızı girin", - "length": "Parolanız en az 8 karakter uzunluğunda olmalıdır", - "uppercase": "Parola en az 1 büyük harf içermelidir", - "lowercase": "Parola en az 1 küçük harf içermelidir", - "number": "Parola en az 1 sayı içermelidir", - "special": "Parola en az 1 özel karakter içermelidir", - "incorrect": "Parolanız sistemdeki ile uyuşmuyor" - } - }, - "passwordConfirm": { - "label": "Parolayı onayla", - "placeholder": "Onaylamak için parolayı yeniden gir", - "errors": { - "empty": "", - "different": "" - } - }, - "firstName": { - "label": "", - "placeholder": "Jordan", - "errors": { - "empty": "Lütfen adınızı girin", - "length": "İsim 50 karakterden az olmalıdır", - "pattern": "İsim yalnızca harfler, boşluklar, kesme işaretleri veya tireler içerebilir" - } - }, - "lastName": { - "label": "Soyadı", - "placeholder": "Ellis", - "errors": { - "empty": "Lütfen soyadınızı girin", - "length": "Soyadı en çok 50 karakter olmalıdır", - "pattern": "Soyad yalnızca harfler, boşluklar, kesme işaretleri veya tireler içerebilir" + "histogram": { + "avg": "Ort: {{value}} ms", + "max": "Maks: {{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "Bu işlem geri alınamaz.", + "title": "Bunu silmek istediğinizden emin misiniz?" + } + }, + "labels": { + "active": "Aktif", + "paused": "duraklatıldı", + "na": "N/A", + "resolved": "Çözüldü", + "responseTime": "Yanıt süresi" + }, + "form": { + "role": { + "option": { + "role": { + "label": "Roller" } } }, - "errors": { - "validation": "Veriyi işlerken hata oluştu." - }, - "fields": { - "password": { - "errors": { - "incorrect": "" + "name": { + "option": { + "firstName": { + "label": "Ad", + "placeholder": "Jordan" + }, + "lastName": { + "label": "Soyadı", + "placeholder": "Ellis" } - }, - "role": { - "errors": { - "min": "" + } + }, + "email": { + "option": { + "email": { + "label": "E-posta", + "placeholder": "you@example.com" } } } }, - "login": { - "heading": "Giriş yap", - "subheadings": { - "stepOne": "Eposta adresinizi girin", - "stepTwo": "Parolanızı girin" - }, - "links": { - "forgotPassword": "Parolanızı mı unuttunuz?", - "register": "Hesabınız yok mu?", - "forgotPasswordLink": "Parolayı sıfırla", - "registerLink": "Buradan kayıt olun" - }, - "toasts": { - "success": "Tekrar hoşgeldiniz! Başarıyla sisteme giriş yaptınız.", - "incorrectPassword": "Hatalı parola" - }, + "table": { + "empty": "Burada hiçbir şey yok", + "headers": { + "actions": "İşlemler", + "dateTime": "Tarih ve saat", + "message": "Mesaj", + "monitor": "Monitör", + "monitorId": "Monitör ID", + "name": "Ad", + "status": "Durum", + "type": "Tür", + "url": "URL", + "interval": "Aralık", + "active": "Aktif", + "responseTime": "Yanıt süresi" + } + } + }, + "components": { + "imageUpload": { + "clickToUpload": "Yüklemek için tıklayın", + "dragAndDrop": "sürükle ve bırak", + "supportedFormats": "Desteklenen formatlar", + "maxSize": "Azami Boyut", + "orDragAndDrop": "veya sürükle bırak", "errors": { - "password": { - "incorrect": "Giriş yaptığınız parola sistemdekiyle uyuşmuyor" - } - }, - "welcome": "" + "invalidFileSize": "Dosya boyutu çok büyük!", + "invalidFileFormat": "Desteklenmeyen dosya biçimi!" + } }, - "registration": { - "heading": { - "superAdmin": "Süper yönetici oluştur", - "user": "Kayıt ol" - }, - "subheadings": { - "stepOne": "Kişisel bilgilerinizi girin", - "stepTwo": "Eposta adresinizi girin", - "stepThree": "Parolanızı oluturun" + "headerStatusPageControls": { + "publicLink": "Herkese açık bağlantı" + }, + "headerTimeRange": { + "labels": { + "day": "Gün", + "month": "Ay", + "recent": "Son", + "week": "Hafta" }, "description": { - "superAdmin": "Başlamak için süper yönetici hesabı oluşturun", - "user": "" + "recent": "Son 2 saatin istatistikleri gösteriliyor.", + "day": "Son 24 saatin istatistikleri gösteriliyor.", + "week": "Son 7 günün istatistikleri gösteriliyor.", + "month": "Son 30 günün istatistikleri gösteriliyor." + } + }, + "offlineBanner": { + "serverUnreachable": "Sunucuya ulaşılamıyor", + "retry": "Tekrar dene", + "retrying": "Yeniden deneniyor...", + "reconnected": "Bağlantı yeniden kuruldu" + }, + "sidebar": { + "menu": { + "uptime": "Çalışma süresi", + "pagespeed": "Sayfa hızı", + "infrastructure": "Altyapı", + "notifications": "Bildirimler", + "checks": "Kontroller", + "incidents": "Olaylar", + "statusPages": "Durum sayfaları", + "maintenance": "Bakım", + "logs": "Günlükler", + "settings": "Ayarlar" }, - "gettingStartedButton": { - "superAdmin": "Süper yönetici hesabı oluşturun", - "user": "" + "bottomMenu": { + "support": "Destek", + "discussions": "Soru-cevap", + "docs": "Dokümanlar", + "changelog": "Değişiklik günlüğü" }, - "termsAndPolicies": "Bir hesap oluşturarak Hizmet Şartları ve Gizlilik Politikası'nı kabul etmiş olursunuz.", + "accountMenu": { + "profile": "Profil", + "password": "Parola", + "team": "Ekip" + }, + "starPrompt": { + "title": "Checkmate'e yıldız verin", + "description": "En son sürümleri görün ve GitHub'da topluluğun büyümesine katkıda bulunun" + }, + "authFooter": { + "navControls": "Kontroller", + "logOut": "Çıkış yap", + "roles": { + "superAdmin": "Süper yönetici", + "admin": "Yönetici", + "user": "Kullanıcı", + "demoUser": "Demo kullanıcısı" + } + } + }, + "starPrompt": { + "title": "Checkmate'e yıldız verin", + "description": "En son sürümleri görün ve GitHub'da topluluğun büyümesine katkıda bulunun" + }, + "pageHeader": { "links": { - "login": "Bir hesabınız mı var? Giriş yapın" - }, - "toasts": { - "success": "Hoşgeldiniz! Hesabınız başarıyla oluşturuldu." - }, - "welcome": "" - }, - "forgotPassword": { - "heading": "Parolanızı mı kaybettiniz?", - "subheadings": { - "stepOne": "Endişelenmeyin, sıfırlamak için gerekli bilgileri göndereceğiz.", - "stepTwo": "Parola sıfırlama için gerekli bilgileri hesabına ilettik.", - "stepThree": "Parolanız daha önce kullandığınız paroladan farklı olmalıdır.", - "stepFour": "Parolanız başarıyla sıfırlandı. Buraya tıklayarak giriş yapabilirsiniz." - }, - "buttons": { - "openEmail": "Eposta uygulamasını aç", - "resetPassword": "Parolayı sıfırla" - }, - "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" - }, - "links": { - "login": "", - "resend": "" - }, - "toasts": { - "sent": "", - "emailNotFound": "Eposta bulunamadı.", - "redirect": "", - "success": "", - "error": "" + "support": "Destek", + "discussions": "Tartışmalar", + "docs": "Dokümantasyon", + "changelog": "Değişiklik günlüğü" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "", - "stillUnreachable": "" + "pages": { + "notFound": { + "title": "Eyvah! Sushini düşürdün!", + "subtitle": "URL mevcut değil veya erişim yetkiniz yok." + }, + "account": { + "tabs": { + "profile": "Profil", + "password": "Parola", + "team": "Ekip" }, - "alertBox": "Sunucu bağlantı hatası", - "description": "", - "retryButton": { - "default": "Bağlantıyı tekrarla", - "processing": "Bağlanıyor..." + "form": { + "name": { + "title": "Ad", + "description": "Kişisel bilgilerinizi güncelleyin" + }, + "photo": { + "title": "Profil fotoğrafı", + "description": "Bir profil fotoğrafı yükleyin" + }, + "currentPassword": { + "title": "Mevcut parola", + "description": "Kimliğinizi doğrulamak için mevcut parolanızı girin", + "option": { + "label": "Mevcut parola", + "placeholder": "Mevcut parolanızı girin" + } + }, + "newPassword": { + "title": "Yeni parola", + "description": "En az 8 karakter içeren güçlü bir parola seçin", + "option": { + "newPassword": { + "label": "Yeni parola", + "placeholder": "Yeni parolanızı girin" + }, + "confirm": { + "label": "Parolayı onaylayın", + "placeholder": "Yeni parolanızı onaylayın" + } + } + }, + "deleteAccount": { + "title": "Hesabı sil", + "description": "Bu işlem kalıcıdır ve geri alınamaz" + } + }, + "team": { + "filter": { + "placeholder": "Role göre filtrele", + "all": "Tümü", + "admin": "Yönetici", + "member": "Eleman" + }, + "table": { + "headers": { + "email": "E-posta", + "role": "Rol", + "created": "Oluşturulma" + } + }, + "addMember": { + "title": "Yeni ekip üyesi kaydet", + "description": "Yeni bir kullanıcı hesabı oluşturun. Kimlik bilgilerini oluşturduktan sonra güvenli bir şekilde paylaşın." + }, + "invite": { + "title": "Ekip üyesi davet et", + "description": "Ekibinize katılmak için bir davet gönderin", + "email": { + "label": "E-posta adresi", + "placeholder": "E-posta adresini girin" + }, + "role": { + "label": "Rol", + "placeholder": "Bir rol seçin" + }, + "linkLabel": "Davet bağlantısı" + }, + "fallback": { + "title": "Henüz takım üyesi yok", + "description": "Ekip arkadaşlarınızı e-posta ile davet edin veya doğrudan ekleyin. Katıldıklarında, rolleriyle birlikte burada görünecekler." + } + }, + "header": { + "title": "Hesap", + "description": "Profilinizi, parolanızı, ekibinizi ve çalışma alanı tercihlerinizi yönetin." + } + }, + "auth": { + "brandPanel": { + "eyebrow": "Açık kaynaklı altyapı izleme", + "tagline": "Sunucularınızın her hareketini izleyin.", + "description": "Sistemlerinizin çalışma süresini, yanıt hızını ve yaşanan kesintileri tek bir panelden anlık takip edin. Kendi sunucunuzda çalışır, verileriniz sizde kalır." + }, + "common": { + "passwordRules": { + "length": { + "beginning": "", + "highlighted": "8 karakter uzunluğunda" + }, + "lowercase": { + "beginning": "En az içermelidir: ", + "highlighted": "Bir küçük harf" + }, + "match": { + "beginning": "", + "highlighted": "eşleşmelidir" + }, + "number": { + "beginning": "En az içermelidir: ", + "highlighted": "bir sayı" + }, + "special": { + "beginning": "En az içermelidir: ", + "highlighted": "bir özel karakter" + }, + "uppercase": { + "beginning": "En az içermelidir: ", + "highlighted": "Bir büyük harf" + } + }, + "form": { + "option": { + "email": { + "label": "E-posta", + "placeholder": "me@example.com" + }, + "password": { + "label": "Parola", + "placeholder": "********" + }, + "confirmPassword": { + "label": "Parolayı onaylayın" + } + } + } + }, + "login": { + "title": "Checkmate'e tekrar hoş geldiniz!", + "subtitle": "Devam etmek için giriş yapın", + "submit": "Giriş yap", + "links": { + "forgotPassword": { + "text": "Parolanızı mı unuttunuz?", + "linkText": "Parolayı sıfırla" + }, + "register": { + "text": "Hesabınız yok mu?", + "linkText": "Buradan kaydolun" + } + } + }, + "register": { + "title": "Checkmate'e tekrar hoş geldiniz!", + "subtitle": "Başlamak için kaydolun", + "submit": "Kaydol", + "setupNotice": "Bu sayfa yalnızca ilk kurulum sırasında kullanılabilir. Süper yönetici kaydedildikten sonra, ek kullanıcılar takım ayarlarından davet edilebilir." + }, + "forgotPassword": { + "title": "Parolanızı mı unuttunuz?", + "subtitle": "Endişelenmeyin, size sıfırlama talimatları göndereceğiz.", + "submit": "Kurtarma talebi gönder", + "links": { + "login": { + "text": "Geri dön:", + "linkText": "giriş" + } + } + }, + "setNewPassword": { + "title": "Parolanızı sıfırlayın", + "subtitle": "Yeni parolanız daha önce kullandığınız parolalardan farklı olmalıdır." + } + }, + "checks": { + "selects": { + "monitor": { + "all": "Tüm monitörler" + }, + "status": { + "all": "Tümü", + "down": "Çevrimdışı", + "up": "Çevrimiçi" + } + }, + "table": { + "empty": "Bu zaman aralığında çevrimdışı kontrol yok", + "headers": { + "statusCode": "Durum kodu", + "location": "Konum" + } + }, + "fallback": { + "title": "Henüz kontrol yok", + "description": "Bir izleyici oluşturduğunuzda, her durum kontrolü ve yanıt süresi burada bir kontrol olarak görünecek." + }, + "header": { + "title": "Kontroller", + "description": "Checkmate'in izleyicilerinize karşı çalıştırdığı her sondaj, durum kodu ve yanıt süresiyle birlikte burada görünür." + } + }, + "common": { + "monitors": { + "actions": { + "configure": "Yapılandır", + "delete": "Sil", + "generateToken": "Token oluştur", + "details": "Detaylar", + "incidents": "Olaylar", + "inviteMember": "Üye davet et", + "openSite": "Siteyi aç", + "pause": "Duraklat", + "resume": "Devam et", + "selectAll": "Tüm monitörleri seç", + "selectMonitor": "{{name}}'i seç", + "bulkSelected_one": "{{count}} monitör seçildi", + "bulkSelected_other": "{{count}} monitör seçildi" + }, + "statBoxes": { + "activeFor": "Aktif süresi", + "certificateExpiry": "Sertifika bitiş tarihi", + "lastCheck": "Son kontrol", + "lastResponseTime": "Son yanıt süresi", + "serviceIsDown": "Servis çalışmıyor" + }, + "status": { + "down": "çevrimdışı", + "breached": "eşik aşıldı", + "initializing": "başlatılıyor", + "maintenance": "bakım", + "paused": "duraklatıldı", + "total": "toplam", + "up": "çevrimiçi" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "Oyun", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "Port", + "optionPagespeed": "PageSpeed", + "optionHardware": "Altyapı", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} monitör duraklatıldı", + "paused_other": "{{count}} monitör duraklatıldı", + "resumed_one": "{{count}} monitör sürdürüldü", + "resumed_other": "{{count}} monitör sürdürüldü", + "alreadyPaused_one": "Seçili monitör zaten duraklatıldı", + "alreadyPaused_other": "Seçili tüm monitörler zaten duraklatıldı", + "alreadyRunning_one": "Seçili monitör zaten çalışıyor", + "alreadyRunning_other": "Seçili tüm monitörler zaten çalışıyor" + } + } + }, + "createMonitor": { + "form": { + "advanced": { + "description": "Gelişmiş kullanım senaryoları için isteğe bağlı ayarlar", + "option": { + "advancedMatching": { + "label": "Gelişmiş eşleştirme kullan" + }, + "expectedValue": { + "label": "Beklenen değer" + }, + "jsonPath": { + "description": "Bu ifade, yanıt JSON verisi üzerinde değerlendirilecek ve sonuç beklenen değerle eşleştirilecektir. Sorgu dili belgeleri için jmespath.org sayfasına bakın.", + "label": "JSONPath ifadesi" + }, + "matchMethod": { + "label": "Eşleştirme yöntemi", + "equal": "", + "include": "", + "regex": "" + } + }, + "title": "Gelişmiş ayarlar" + }, + "frequency": { + "description": "Bu monitörün durumunu ne sıklıkla kontrol etmek istiyorsunuz?", + "option": { + "frequency": { + "label": "Kontrol sıklığı", + "value": { + "fifteenMinutes": "15 dakika", + "fifteenSeconds": "", + "fiveMinutes": "5 dakika", + "fourMinutes": "", + "oneMinute": "", + "tenMinutes": "10 dakika", + "thirtyMinutes": "30 dakika", + "thirtySeconds": "", + "threeMinutes": "", + "twoMinutes": "" + } + } + }, + "title": "Kontrol sıklığı" + }, + "general": { + "option": { + "container": { + "label": "Container adı/ID", + "placeholder": "uygulamam veya abcd1234" + }, + "host": { + "label": "Ana bilgisayar", + "placeholder": "192.168.1.100 veya example.com" + }, + "name": { + "label": "Görünen ad", + "placeholder": "örn. Web Sitem" + }, + "secret": { + "label": "Yetkilendirme anahtarı", + "placeholder": "Gizli anahtarınızı girin" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "", + "placeholder": "" + }, + "port": { + "label": "İzlenecek port", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "Servis adı", + "placeholder": "örn. my.service.v1 (genel sağlık için boş bırakın)" + }, + "wsUrl": { + "label": "WebSocket URL", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture uç noktası", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "Genel ayarlar", + "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": "", + "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ın CPU, bellek, disk kullanımını ve sıcaklığını izleyin. Capture uç nokta URL'si tam yolu /api/v1/metrics içermelidir (ör. http://192.168.1.10:5001/api/v1/metrics). Yetkilendirme anahtarına yalnızca token dizesini yapıştırın — 'Bearer' eklemeyin." + } + }, + "ignoreTls": { + "description": "HTTPS bağlantıları için TLS/SSL sertifika doğrulamasını yapılandırın.", + "option": { + "tls": { + "label": "TLS/SSL hatalarını yoksay" + } + }, + "title": "TLS/SSL ayarları" + }, + "incidents": { + "description": "Bir monitörün ne zaman çevrimdışı olduğunu belirlemek için kayan pencere kullanılır. Monitörün durumu, yalnızca kayan penceredeki kontrol yüzdesi belirlenen değeri karşıladığında değişir.", + "option": { + "checks": { + "label": "Kayan penceredeki kontrol sayısı" + }, + "percentage": { + "label": "Monitör durumu değişmeden önce kayan penceredeki kontrollerin yüzde kaçı başarısız/başarılı olmalı?" + } + }, + "title": "Olaylar" + }, + "notifications": { + "description": "Kullanmak istediğiniz bildirim kanallarını seçin", + "title": "Bildirimler" + }, + "type": { + "description": "Gerçekleştirilecek kontrol türünü seçin", + "optionDockerDescription": "Bir container'ın çalışıp çalışmadığını izlemek için Docker kullanın.", + "optionGameDescription": "Belirli bir oyun sunucusunun çevrimiçi olup olmadığını izleyin.", + "optionGrpcDescription": "Standart Health Checking Protocol kullanarak gRPC servislerini izleyin.", + "optionWebSocketDescription": "Bağlantı sağlığı ve yanıt süresi için WebSocket uç noktalarını izleyin.", + "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" + }, + "thresholds": { + "title": "Uyarı eşikleri", + "description": "Bu altyapı monitörü için uyarıların tetikleneceği eşikleri tanımlayın.", + "option": { + "cpuThreshold": { + "label": "CPU uyarı eşiği (%)" + }, + "memoryThreshold": { + "label": "Bellek uyarı eşiği (%)" + }, + "diskThreshold": { + "label": "Disk uyarı eşiği (%)" + }, + "tempThreshold": { + "label": "Sıcaklık uyarı eşiği (°C)" + } + } + }, + "geoChecks": { + "title": "Coğrafi dağıtılmış kontroller", + "description": "Küresel erişilebilirlik ve performansı izlemek için birden fazla coğrafi konumdan kontrol çalıştırın.", + "option": { + "enabled": { + "label": "Coğrafi dağıtılmış kontrolleri etkinleştir" + }, + "locations": { + "label": "Konumlar", + "placeholder": "Konum seçin", + "options": { + "EU": "Avrupa", + "NA": "Kuzey Amerika", + "AS": "Asya", + "SA": "Güney Amerika", + "AF": "Afrika", + "OC": "Okyanusya" + } + }, + "interval": { + "label": "Kontrol aralığı", + "value": { + "fiveMinutes": "5 dakika", + "tenMinutes": "10 dakika", + "fifteenMinutes": "15 dakika", + "thirtyMinutes": "30 dakika" + } + } + } + }, + "url": { + "title": "Durum sayfasında monitör IP/URL gösterimi", + "description": "Monitörün IP adresini veya URL'sini herkese açık durum sayfasında görüntüleyin. Devre dışı bırakılırsa hassas bilgileri korumak için yalnızca monitör adı gösterilir.", + "option": { + "showURL": { + "label": "Durum sayfasında IP/URL göster", + "enabled": "Etkin", + "disabled": "Devre dışı" + } + } + }, + "stats": { + "title": "Monitör geçmişi", + "description": "Ekibinizin tüm izleme geçmişini ve istatistiklerini temizleyin. Bu işlem geri alınamaz.", + "buttonText": "Tüm istatistikleri temizle", + "dialog": { + "title": "Tüm izleme geçmişi temizlensin mi?", + "description": "Bu işlem ekibinizin tüm izleme geçmişini, istatistiklerini ve kontrol verilerini kalıcı olarak silecektir. Bu işlem geri alınamaz.", + "confirm": "Evet, tüm istatistikleri temizle" + } + } + } + }, + "editUser": { + "form": { + "roles": { + "title": "Roller", + "description": "Kullanıcıya roller atayın. Birden fazla rol seçilebilir." + } + }, + "dialog": { + "removeUser": { + "title": "Kullanıcıyı kaldır", + "content": "{{name}} adlı kullanıcıyı ekibinizden kaldırmak istediğinizden emin misiniz? Bu işlem geri alınamaz." + } + } + }, + "incidents": { + "fallback": { + "title": "Olay yok — tüm sistemler sağlıklı", + "description": "Bir izleyici devre dışı kaldığında veya kurtarıldığında, olay zamanlama, izleyici detayları ve çözüm geçmişiyle birlikte burada görünecek." + }, + "dialog": { + "resolveIncident": { + "title": "Olayı çöz", + "option": { + "comment": { + "label": "Yorum (isteğe bağlı)", + "placeholder": "Çözüm hakkında bir yorum ekleyin..." + } + } + }, + "details": { + "analysis": "Olay analizi", + "comment": "Yorum:", + "detailsLabel": "Detaylar", + "downtime": "Kesinti süresi:", + "message": "Mesaj:", + "monitor": "Monitör:", + "overview": "Genel bakış", + "resolutionDetails": "Çözüm detayları", + "resolutionType": "Tür:", + "resolutionTypes": { + "automatic": "Otomatik", + "manual": "Manuel" + }, + "resolve": "Olayı çöz", + "resolvedAt": "Çözülme zamanı:", + "resolvedBy": "Çözen:", + "startedAt": "Başlangıç zamanı:", + "status": "Durum:", + "statusCode": "Durum kodu:", + "timeline": "Zaman çizelgesi", + "title": "Olay detayları", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "Tüm monitörler", + "monitor": "Monitör", + "resolutionType": "Çözüm türü", + "resolutionTypes": { + "manual": "Manuel", + "automatic": "Otomatik", + "all": "Tümü" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "Aktif olaylar", + "active_zero": "Aktif olay yok", + "active_one": "{{count}} aktif olay", + "active_other": "{{count}} aktif olay" + }, + "incidentStats": { + "avgResolutionTime": "Ort. çözüm süresi", + "mostAffectedMonitor": "En çok etkilenen monitör", + "title": "Olay istatistikleri", + "totalIncidents": "Toplam olay sayısı" + }, + "latestIncidents": { + "title": "Son olaylar" + } + }, + "table": { + "actions": { + "details": "Detaylar", + "goToMonitor": "Monitöre git", + "resolveManually": "Manuel olarak çöz" + }, + "activeIncidents": "Aktif olaylar", + "headers": { + "endTime": "Bitiş zamanı", + "resolutionType": "Çözüm türü", + "startTime": "Başlangıç saati", + "statusCode": "Durum kodu" + }, + "resolvedIncidents": "Çözülen olaylar", + "status": { + "active": "Aktif", + "resolved": "Çözüldü" + } + }, + "header": { + "title": "Olaylar", + "description": "Bir izleyici devre dışı kaldığında veya kurtarıldığında, olaylar zamanlama, izleyici detayları ve çözüm geçmişiyle birlikte burada görünür." + } + }, + "infrastructure": { + "charts": { + "labels": { + "cpu": "CPU kullanımı", + "disk": "Disk kullanımı", + "memory": "Bellek kullanımı", + "netBytesRecv": "{{name}} - Alınan bayt", + "netBytesSent": "{{name}} - Gönderilen bayt", + "netNoTraffic": "Capture tüm arabirimlerde sıfır trafik raporluyor. Bu, Capture yalnızca loopback arabirimlerini görebilen bir konteyner içinde çalışırken normaldir — gerçek ağ etkinliğini görmek için Capture'ı doğrudan host üzerinde çalıştırın.", + "temp": "Sıcaklık" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "Maks frekans", + "title": "CPU kullanımı", + "upperLabel": "Mevcut frekans" + }, + "disk": { + "lowerLabel": "Boş", + "title": "Disk {{idx}} kullanımı", + "upperLabel": "Kullanılan" + }, + "memory": { + "lowerLabel": "Boş", + "title": "Bellek kullanımı", + "upperLabel": "Kullanılan" + } + }, + "statBoxes": { + "avgCpuTemperature": "Ortalama CPU Isısı", + "cpuFrequency": "CPU Frekansı", + "cpuLogical": "CPU (Mantıksal)", + "cpuPhysical": "CPU (fiziksel)", + "disk": "Disk", + "memory": "Bellek", + "os": "İşletim sistemi" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "Disk", + "memory": "Bellek" + } + }, + "tabs": { + "labels": { + "network": "", + "overview": "Genel bakış" + } + }, + "fallback": { + "actionButton": "Monitör oluşturun!", + "title": "Henüz altyapı izleyicisi yok", + "description": "Sunucu performansını izleyin, darboğazları belirleyin ve gerçek zamanlı altyapı izleme ile güvenilirliği sağlayın." + }, + "header": { + "title": "Altyapı izleyicileri", + "description": "Sunucu performansını izleyin, darboğazları belirleyin ve gerçek zamanlı altyapı izleme ile güvenilirliği sağlayın." + } + }, + "logs": { + "fallback": { + "title": "Henüz günlük yok", + "description": "Sunucu etkinliği, uyarılar ve hatalar Checkmate çalıştıkça burada görünecek." + }, + "tabs": { + "diagnostics": "Teşhis", + "logs": "Sunucu günlükleri", + "queue": "İş kuyruğu" + }, + "logLevelSelect": { + "label": "Günlük seviyesi" + }, + "jobQueue": "İş kuyruğu", + "jobQueueExplainer": "Vurgulanan satırlar şu anda bir işçi tarafından işlenmekte olan işlerdir.", + "failedJobs": "Başarısız işler", + "failedJobsExplainer": "Monitöre göre son başarısızlıklar — son başarısızlık zamanı, başarısızlık sayısı ve nedeni.", + "noLogs": "Günlük bulunamadı", + "metrics": { + "jobs": "İşler", + "activeJobs": "Aktif işler", + "failingJobs": "Başarısız olan işler", + "totalRuns": "Toplam çalıştırma", + "totalFailures": "Toplam başarısızlık" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "Olay döngüsü gecikmesi", + "uptime": "Çalışma süresi", + "usedHeapSize": "Kullanılan heap boyutu", + "totalHeapSize": "Toplam heap boyutu", + "osMemoryLimit": "İşletim sistemi bellek limiti" + }, + "gauges": { + "heapAllocation": "Heap tahsisi", + "heapUsage": "Heap kullanımı", + "heapUtilization": "Heap verimliliği", + "instantCpuUsage": "Anlık CPU kullanımı", + "availableMemoryPercentage": "% kullanılabilir bellek", + "allocatedPercentage": "% tahsis edilmiş", + "usedSPercentage": "% CPU tarafından kullanılan (1s)", + "total": "Toplam", + "used": "Kullanılan" + }, + "sections": { + "runtimeStats": { + "title": "Çalışma istatistikleri", + "description": "Node.js süreci ve ana makinenin anlık görüntüsü. 5 saniyede bir güncellenir." + }, + "memoryCpu": { + "title": "Bellek ve CPU", + "description": "Sürecin ayrılmış belleğini ve ana makinenin CPU'sunu nasıl kullandığı." + } + } + }, + "table": { + "headers": { + "timestamp": "Zaman damgası", + "level": "Seviye", + "service": "Servis", + "method": "Yöntem", + "monitorId": "Monitör ID", + "runCount": "Çalışma sayısı", + "failCount": "Başarısızlık sayısı", + "lastRunAt": "Son çalışma zamanı", + "lockedAt": "Kilitlenme zamanı", + "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" + } + }, + "queueFallback": { + "title": "Kuyrukta iş yok", + "description": "Zamanlanmış veya çalışan izleyici kontrolleri burada görünür. Boş bir kuyruk her şeyin güncel olduğu anlamına gelir." + }, + "header": { + "title": "Günlükler ve kuyruk", + "description": "Sunucu günlüklerini, izleyici iş kuyruğunu ve çalışma zamanı tanılarını tek bir yerde inceleyin." + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "Bakım penceresi oluşturun!", + "title": "Planlanmış bakım penceresi yok", + "description": "Planlı kesintileri zamanlayın; uyarılar bastırılsın, ekibiniz ve kullanıcılarınız ne bekleyeceklerini bilsin." + }, + "table": { + "headers": { + "nextWindow": "Sonraki pencere", + "repeat": "Tekrarla" + } + }, + "form": { + "general": { + "title": "Genel ayarlar", + "description": "Bakım pencereniz için bir ad ve tekrar seçeneği belirleyin.", + "option": { + "name": { + "label": "Ad", + "placeholder": "örn. Haftalık Bakım" + }, + "repeat": { + "label": "Tekrarla" + } + } + }, + "startDate": { + "title": "Başlangıç tarihi", + "description": "Bakım pencereniz için başlangıç tarihini seçin.", + "option": { + "startDate": { + "label": "Başlangıç tarihi" + } + } + }, + "startTime": { + "title": "Başlangıç saati", + "description": "Bakım pencereniz için başlangıç saati ve süreyi ayarlayın. Tüm değerler UTC cinsindendir", + "option": { + "duration": { + "label": "Süre" + }, + "startTime": { + "label": "Başlangıç saati" + } + }, + "monitors": { + "title": "Monitörler", + "description": "Bakım penceresinin uygulanacağı monitörler", + "option": { + "addMonitors": { + "label": "Monitör ekle" + } + } + } + } + }, + "header": { + "title": "Bakım pencereleri", + "description": "Planlı kesintileri zamanlayın; uyarılar bastırılsın, ekibiniz ve kullanıcılarınız ne bekleyeceklerini bilsin." + } + }, + "notifications": { + "fallback": { + "actionButton": "Kanal oluştur", + "title": "Henüz bildirim kanalı yok", + "description": "Slack, Discord, e-posta, webhook ve daha fazlasını bağlayın — olaylar olur olmaz doğru kişilere haber verilir." + }, + "form": { + "accessToken": { + "optionAccessToken": "Erişim token'ı", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "Bildirimlerin gönderileceği adres.", + "optionAddress": "Adres", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "Adres" + }, + "homeServer": { + "optionHomeServer": "Ana sunucu", + "placeholder": "example.com" + }, + "matrix": { + "description": "Bildirimler için Matrix ana sunucu bağlantınızı yapılandırın.", + "title": "Matrix yapılandırması" + }, + "notificationName": { + "description": "Bildirim kanalı için açıklayıcı bir ad", + "optionName": "Kanal adı", + "placeholder": "örn. Üretim Uyarıları", + "title": "Kanal adı" + }, + "pagerDuty": { + "description": "Uyarıları almak için PagerDuty entegrasyon anahtarınız.", + "optionIntegrationKey": "Entegrasyon anahtarı", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "Entegrasyon anahtarı" + }, + "roomId": { + "optionRoomId": "Oda ID", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "Oluşturulacak bildirim kanalı türünü seçin.", + "optionType": "Tür", + "title": "Kanal türü" + }, + "telegram": { + "title": "Telegram yapılandırması", + "description": "Telegram bildirimlerini etkinleştirmek için, Telegram botlarını oluşturup yönetmek için kullanılan resmi bot BotFather ile bir Telegram botu oluşturun. Daha sonra, API jetonunu (token) ve sohbet kimliğini (chat ID) alın ve buraya yazın.", + "optionBotToken": "Bot token", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "Sohbet ID", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Pushover yapılandırması", + "description": "Pushover uygulama belirtecinizi ve kullanıcı anahtarınızı girin.", + "optionAppToken": "Uygulama belirteci", + "placeholderAppToken": "Uygulama belirtecini girin", + "optionUserKey": "Kullanıcı anahtarı", + "placeholderUserKey": "Kullanıcı anahtarını girin" + }, + "twilio": { + "title": "Twilio yapılandırması", + "description": "SMS göndermek için Twilio hesap kimlik bilgilerinizi girin.", + "optionAccountSid": "Hesap SID", + "placeholderAccountSid": "Hesap SID girin", + "optionAuthToken": "Kimlik doğrulama belirteci", + "placeholderAuthToken": "Kimlik doğrulama belirtecini girin", + "optionFromNumber": "Gönderen numara", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "Alıcı numara", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "Hedef" + } + }, + "header": { + "title": "Bildirimler", + "description": "Slack, Discord, e-posta, webhook ve daha fazlasını bağlayın — olaylar olur olmaz doğru kişilere haber verilir." + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "Cumulative Layout Shift (CLS)", + "fcp": "First Contentful Paint (FCP)", + "lcp": "Largest Contentful Paint (LCP)", + "si": "Speed Index (SI)", + "tbt": "Total Blocking Time (TBT)" + }, + "legend": { + "title": "PageSpeed raporu", + "weight": "Ağırlık" + }, + "pie": { + "title": "Performans raporu" + } + }, + "table": { + "headers": { + "pageSpeedScore": "PageSpeed puanı" + } + }, + "fallback": { + "actionButton": "Monitör oluşturun!", + "title": "Henüz PageSpeed izleyicisi yok", + "description": "Core Web Vitals ve Lighthouse puanlarını zaman içinde izleyerek kullanıcılarınız fark etmeden gerilemeleri yakalayın." + }, + "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." + } + }, + "settings": { + "form": { + "timezone": { + "title": "Görüntüleme saat dilimi", + "description": "Uygulama genelinde tarih ve saatleri görüntülemek için kullanılacak saat dilimini seçin.", + "option": { + "timezone": { + "label": "Görüntüleme saat dilimi" + } + } + }, + "ui": { + "title": "Görünüm", + "description": "Açık ve koyu mod arasında geçiş yapın, dili değiştirin veya grafik görüntüleme türünü özelleştirin.", + "option": { + "theme": { + "label": "Tema modu", + "light": "Açık", + "dark": "Koyu" + }, + "language": { + "label": "Dil" + }, + "chartType": { + "label": "Grafik türü", + "histogram": "Histogram", + "heatmap": "Isı haritası" + } + } + }, + "pagespeed": { + "title": "Google PageSpeed API anahtarı", + "description": "Google PageSpeed izlemesini etkinleştirmek için Google PageSpeed API anahtarınızı girin. Anahtarı güncellemek için Sıfırla'ya tıklayın.", + "option": { + "apiKey": { + "label": "PageSpeed API anahtarı", + "labelSet": "API anahtarı ayarlandı. Değiştirmek için Sıfırla'ya tıklayın.", + "placeholder": "Google PageSpeed API anahtarınızı girin" + } + } + }, + "url": { + "title": "Durum sayfasında monitör IP/URL gösterimi", + "description": "Monitörün IP adresini veya URL'sini herkese açık durum sayfasında görüntüleyin. Devre dışı bırakılırsa hassas bilgileri korumak için yalnızca monitör adı gösterilir.", + "option": { + "showURL": { + "label": "Durum sayfasında IP/URL göster", + "enabled": "Etkin", + "disabled": "Devre dışı" + } + } + }, + "stats": { + "title": "Monitör geçmişi", + "description": "Ekibinizin tüm izleme geçmişini ve istatistiklerini temizleyin. Bu işlem geri alınamaz.", + "option": { + "clear": { + "label": "Tüm istatistikleri temizle. Bu işlem geri alınamaz." + } + }, + "dialog": { + "title": "Tüm izleme geçmişi temizlensin mi?", + "description": "Bu işlem geri alınamaz" + } + }, + "retention": { + "title": "Kontrol saklama", + "description": "Kontrol verilerinin otomatik temizlenmeden önce ne kadar süre saklanacağını ayarlayın.", + "option": { + "days": { + "label": "Saklama süresi (gün)", + "unlimited": "Sınırsız" + } + } + }, + "thresholds": { + "title": "Genel eşikler", + "description": "Altyapı izleme için genel CPU, bellek, disk ve sıcaklık eşiklerini ayarlayın. Bunlar, geçersiz kılınmadığı sürece tüm donanım monitörlerine uygulanır.", + "option": { + "cpu": { + "label": "CPU eşiği (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "Bellek eşiği (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "Disk eşiği (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "Sıcaklık eşiği (°C)", + "placeholder": "1 - 150" + } + } + }, + "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": "", + "descriptionTransportLink": "Özellikleri burada görün", + "option": { + "host": { + "label": "E-posta sunucusu - Bağlanılacak ana bilgisayar adı veya IP adresi", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "E-posta portu - Bağlanılacak port", + "placeholder": "587" + }, + "address": { + "label": "E-posta adresi - Kimlik doğrulama için kullanılır", + "placeholder": "you@example.com" + }, + "user": { + "label": "E-posta kullanıcısı - Kimlik doğrulama için kullanıcı adı, belirtilirse e-posta adresini geçersiz kılar", + "placeholder": "Gerekli değilse boş bırakın" + }, + "password": { + "label": "E-posta parolası - Kimlik doğrulama için parola", + "labelSet": "Parola ayarlandı. Değiştirmek için Sıfırla'ya tıklayın.", + "placeholder": "Parolanızı girin" + }, + "tlsServername": { + "label": "TLS sunucu adı - Ana bilgisayar IP olduğunda TLS doğrulama için isteğe bağlı ana bilgisayar adı", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "E-posta bağlantı sunucusu - HELO/EHLO karşılamasında kullanılacak ana bilgisayar adı", + "placeholder": "localhost" + }, + "secure": { + "label": "SSL kullan (önerilen): Bağlantıyı SSL/TLS ile şifreleyin" + }, + "pool": { + "label": "Bağlantı havuzunu etkinleştir: Performansı artırmak için mevcut bağlantıları yeniden kullanın" + }, + "ignoreTLS": { + "label": "STARTTLS'yi devre dışı bırak: Sunucu desteklese bile TLS kullanma" + }, + "requireTLS": { + "label": "STARTTLS'yi zorunlu kıl: TLS yükseltmesini zorunlu kıl, desteklenmiyorsa başarısız ol" + }, + "rejectUnauthorized": { + "label": "Geçersiz sertifikaları reddet: Kendinden imzalı veya güvenilmeyen sertifikalı bağlantıları reddet" + } + } + }, + "demoMonitors": { + "title": "Demo monitörler", + "description": "Gösterim amaçlı örnek monitörler ekleyin." + }, + "removeMonitors": { + "title": "Sistemi sıfırla", + "description": "Sistemdeki tüm monitörleri kaldır.", + "dialog": { + "title": "Tüm monitörler kaldırılsın mı?", + "description": "Bu işlem geri alınamaz.", + "paragraph": "Tüm kontrol geçmişi, olaylar, planlanmış bakım pencereleri ve bu monitörleri kullanan durum sayfaları da kaldırılacaktır. Verilerin yedeğini saklamak istiyorsanız, önce monitörleri dışa aktarın." + } + }, + "exportMonitors": { + "title": "Monitörleri dışa aktar" + }, + "importExportMonitors": { + "title": "Monitörleri içe / dışa aktar", + "description": "Yedekleme veya aktarım için monitör verilerinizi JSON dosyası olarak içe veya dışa aktarın." + }, + "about": { + "title": "Hakkında", + "developedBy": "Bluewave Labs tarafından geliştirildi" + }, + "validation": { + "errorMessage": "Lütfen aşağıdaki doğrulama hatalarını düzeltin:" + } + }, + "header": { + "title": "Ayarlar", + "description": "SMTP, entegrasyonlar, saklama ve genel uygulama davranışını yapılandırın." + } + }, + "statusPages": { + "deleteSuccess": "Durum sayfası başarıyla silindi", + "fallback": { + "title": "Henüz durum sayfası yok", + "actionButton": "Durum sayfası oluşturun!", + "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 bir sayfa yayınlayın." + }, + "monitorsList": { + "chartTypeHeatmap": "Isı haritası", + "chartTypeHistogram": "Histogram", + "noData": "Veri mevcut değil", + "infrastructure": { + "title": "Altyapı", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "Bellek", + "disk": "Disk", + "usage": "Kullanım", + "used": "Kullanılan", + "total": "Toplam" + }, + "uptime": { + "title": "Çalışma süresi", + "responseTime": "Yanıt süresi" + } + }, + "statusBar": { + "allDown": "Tüm sistemler çevrimdışı", + "allUp": "Tüm sistemler çalışıyor", + "degraded": "Bazı sistemlerde sorun yaşanıyor", + "unknown": "Sistem durumu belirlenemiyor", + "allBreached": "Tüm sistemler eşik değerlerini aştı", + "allMaintenance": "Tüm sistemler bakımda", + "allPaused": "Tüm sistemler duraklatıldı", + "allInitializing": "Tüm sistemler başlatılıyor", + "breachedAndDown": "Bazı sistemler eşik değerlerini aştı ve çalışmıyor", + "breached": "Bazı sistemler eşik değerlerini aştı", + "maintenanceAndDown": "Bazı sistemler bakım sırasında çalışmıyor", + "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ı" + }, + "table": { + "headers": { + "name": "Durum sayfası adı", + "url": "Herkese açık URL" + }, + "published": "Yayında", + "unpublished": "Yayında değil" + }, + "title": "Durum sayfaları", + "details": { + "empty": { + "title": "Burada henüz hiçbir şey yok", + "addMonitor": "Başlamak için bir monitör ekleyin" + } + }, + "form": { + "access": { + "title": "Erişim", + "description": "Durum sayfanız hazırsa yayında olarak işaretleyebilirsiniz.", + "option": { + "published": { + "name": "Yayında ve herkese açık" + } + } + }, + "basicInfo": { + "title": "Temel bilgiler", + "description": "Şirket adını ve durum sayfanızın işaret ettiği alt alan adını tanımlayın.", + "option": { + "name": { + "label": "Şirket adı", + "placeholder": "Acme Inc." + }, + "url": { + "label": "Durum sayfası adresiniz", + "placeholder": "my-status-page" + } + } + }, + "monitors": { + "title": "Monitörler", + "description": "Durum sayfanızda görüntülenecek monitörleri seçin.", + "noMonitors": "Monitör seçilmedi", + "option": { + "monitors": { + "label": "Monitör seçin", + "placeholder": "Monitör arayın ve seçin..." + } + } + }, + "timezone": { + "title": "Saat dilimi", + "description": "Durum sayfanızın görüntüleneceği saat dilimini seçin.", + "option": { + "timezone": { + "label": "Saat dilimi", + "placeholder": "Saat dilimi seçin..." + } + } + }, + "appearance": { + "title": "Görünüm", + "description": "Herkese açık durum sayfanızın varsayılan görünümünü ve hissini tanımlayın.", + "option": { + "logo": { + "label": "Logo" + }, + "color": { + "label": "Marka rengi" + } + } + }, + "features": { + "title": "Özellikler", + "description": "Durum sayfanızda hangi bilgilerin görüntüleneceğini yapılandırın.", + "option": { + "showCharts": { + "label": "Yanıt süresi grafiklerini göster" + }, + "showUptimePercentage": { + "label": "Çalışma süresi yüzdesini göster" + }, + "showAdminLoginLink": { + "label": "Yönetici giriş bağlantısını göster" + }, + "showInfrastructure": { + "label": "Altyapı metriklerini göster" + } + } + } + }, + "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." + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "Monitör ara..." + } + }, + "table": { + "headers": { + "responseTime": "Yanıt süresi" + } + }, + "fallback": { + "actionButton": "Monitör oluşturun!", + "title": "Henüz çalışma süresi izleyicisi yok", + "description": "HTTP uç noktalarını, ping'leri, konteynerleri ve portları izleyin — bir şey kapandığında anında uyarı alın." + }, + "header": { + "title": "Çalışma süresi izleyicileri", + "description": "HTTP uç noktalarını, ping'leri, konteynerleri ve portları izleyin — bir şey kapandığında anında uyarı alın." } } - }, - "createNotifications": { - "title": "Bildirim kanalları oluştur", - "nameSettings": { - "title": "İsim", - "description": "Entegrasyon için açıklama", - "nameLabel": "İsim", - "namePlaceholder": "örn. Slack bildirimleri" - }, - "typeSettings": { - "title": "Tür", - "description": "", - "typeLabel": "Tür" - }, - "emailSettings": { - "title": "Eposta", - "description": "Hedef eposta adresi", - "emailLabel": "Eposta adresi", - "emailPlaceholder": "örn. john@example.com" - }, - "slackSettings": { - "title": "Slack", - "description": "Slack webhook'u burada tanımlayın", - "webhookLabel": "Slack webhook adresi", - "webhookPlaceholder": "https://hooks.slack.com/services/...\n" - }, - "pagerdutySettings": { - "title": "PagerDuty", - "description": "PagerDuty entegrasyonunu burada yapın", - "integrationKeyLabel": "Entegrasyon anahtarı", - "integrationKeyPlaceholder": "1234567890" - }, - "discordSettings": { - "title": "Discord", - "description": "Discord webhook'u buradan ayarlayın", - "webhookLabel": "Discord Webhook adresi", - "webhookPlaceholder": "https://sunucu-adı.com/webhook\n" - }, - "webhookSettings": { - "title": "Webhook", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "https://sunucu-adı.com/webhook\n" - }, - "testNotification": "Test bildirimi", - "dialogDeleteTitle": "Gerçekten bu bildirimi silmek istiyor musunuz?", - "dialogDeleteConfirm": "Sil" - }, - "notificationConfig": { - "title": "Bildirimler", - "description": "" - }, - "monitorStatus": { - "checkingEvery": "", - "withCaptureAgent": "", - "up": "ayakta", - "down": "çalışmıyor", - "paused": "beklemede" - }, - "advancedMatching": "Gelişmiş eşleştirme", - "sendTestNotifications": "Bildirimleri dene", - "selectAll": "Tümünü seç", - "showAdminLoginLink": "", - "logsPage": { - "title": "Günlükler", - "description": "Sistem günlükleri - son 1000 satır", - "tabs": { - "queue": "İş kuyruğu", - "logs": "Sunucu günlükleri", - "diagnostics": "Teşhis" - }, - "toast": { - "fetchLogsSuccess": "Günlükler başarıyla alındı" - }, - "logLevelSelect": { - "title": "", - "values": { - "all": "", - "info": "", - "warn": "", - "error": "", - "debug": "" - } - } - }, - "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", - "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "", - "activeHeader": "Aktif", - "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "Son tamamlama", - "lastRunTookHeader": "", - "intervalHeader": "" - }, - "metricsTable": { - "title": "", - "metricHeader": "Metrik", - "valueHeader": "Değer" - }, - "failedJobTable": { - "title": "Tamamlanamayan işler", - "monitorIdHeader": "Monitör ID", - "monitorUrlHeader": "Monitör URL", - "failCountHeader": "Hata sayısı", - "failedAtHeader": "", - "failReasonHeader": "" - } - }, - "export": { - "title": "Monitörleri dışarı al", - "success": "Monitörler başarıyla dışarıya alındı", - "failed": "Monitörleri dışarıya alırken bir hata oluştu" - }, - "monitorActions": { - "title": "Dışarı/İçeri al", - "import": "", - "export": "", - "deleteSuccess": "Monitör başarıyla silindi", - "deleteFailed": "Monitör silinemedi", - "details": "Detaylar" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "" - }, - "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" - }, - "emailSettings": { - "buttonSendTestEmail": "", - "description": "", - "descriptionTransport": "", - "labelAddress": "", - "labelConnectionHost": "", - "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", - "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", - "labelRequireTLS": "", - "labelSecure": "", - "labelTLSServername": "", - "labelUser": "", - "linkTransport": "", - "placeholderUser": "Eğer gerekmiyorsa boş bırakabilirsiniz", - "title": "Eposta", - "toastEmailRequiredFieldsError": "" - }, - "pageSpeedSettings": { - "description": "Google PageSpeed izlemeyi etkinleştirmek için Google PageSpeed API anahtarınızı girin. Anahtarı güncellemek için Sıfırla'ya tıklayın.", - "labelApiKeySet": "API key tanımlandı. Sıfırla düğmesine tıklayarak değiştirebilirsiniz.", - "labelApiKey": "PageSpeed API anahtarı", - "title": "Google PageSpeed API anahtarı" - }, - "saveButtonLabel": "Kaydet", - "statsSettings": { - "clearAllStatsButton": "", - "clearAllStatsDescription": "", - "clearAllStatsDialogConfirm": "", - "clearAllStatsDialogDescription": "", - "clearAllStatsDialogTitle": "", - "description": "", - "labelTTL": "", - "labelTTLOptional": "Sonsuz için 0", - "title": "İzleme geçmişi" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "Tüm monitörleri sil", - "description": "Sistemden tüm monitörleri sil", - "dialogConfirm": "Evet, tüm monitörleri sil", - "dialogDescription": "", - "dialogTitle": "Gerçekten tüm monitörleri silmek istiyor musunuz?", - "title": "Sistemi sıfırla" - }, - "timezoneSettings": { - "description": "", - "label": "Görünüm zaman dilimi", - "title": "Görünüm zaman dilimi" - }, - "title": "Ayarlar", - "uiSettings": { - "description": "Aydınlık ve karanlık mod arasında geçiş yapın ya da kullanıcı arayüzü dilini değiştirin.", - "labelLanguage": "Dil", - "labelTheme": "Tema modu", - "title": "Görünüm" - }, - "urlSettings": { - "description": "", - "label": "", - "selectDisabled": "Kapalı", - "selectEnabled": "Etkin", - "title": "" - }, - "globalThresholds": { - "title": "", - "description": "" - } - }, - "statusPageCreate": { - "buttonSave": "" - }, - "incidentsOptionsHeaderFilterResolved": "", - "settingsSave": "", - "statusPageCreateAppearanceTitle": "", - "confirmPassword": "", - "monitorHooks": { - "failureAddDemoMonitors": "", - "successAddDemoMonitors": "" - }, - "settingsAppearance": "", - "settingsDisplayTimezone": "", - "settingsGeneralSettings": "", - "incidentsOptionsHeaderTotalIncidents": "", - "statusPage": { - "deleteSuccess": "Durum sayfası başarıyla silindi", - "deleteFailed": "Durum sayfası silinemedi", - "createSuccess": "Durum sayfası başarıyla oluşturuldu", - "updateSuccess": "Durum sayfası başarıyla güncellendi", - "generalSettings": "Genel ayarlar", - "contents": "İçerikler", - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "testNotificationsDisabled": "Bu izleyici için ayarlanmış bir bildirim yok. 'Yapılandır' düğmesine tıklayarak bir bildirim eklemeniz gerekiyor.", - "incidentsTableResolvedAt": "", - "incidentsTableActionResolve": "Çözümle", - "checkHooks": { - "failureResolveOne": "Olay çözümlenirken bir hata oluştu.", - "failureResolveAll": "Tüm olayları çözümlerken bir hata oluştu.", - "failureResolveMonitor": "" - }, - "checkFormError": "Formda hatalar var. Lütfen kontrol edin.", - "diagnosticsPage": { - "diagnosticDescription": "", - "statsDescription": "", - "gauges": { - "heapAllocationTitle": "", - "heapAllocationSubtitle": "", - "heapUsageTitle": "", - "heapUsageSubtitle": "", - "heapUtilizationTitle": "", - "heapUtilizationSubtitle": "", - "instantCpuUsageTitle": "", - "instantCpuUsageSubtitle": "" - }, - "stats": { - "eventLoopDelayTitle": "", - "uptimeTitle": "", - "usedHeapSizeTitle": "", - "totalHeapSizeTitle": "", - "osMemoryLimitTitle": "" - } - }, - "pageSpeedLighthouseAPI": "", - "time": { - "threeMinutes": "", - "fiveMinutes": "", - "tenMinutes": "", - "twentyMinutes": "", - "oneHour": "1 saat", - "oneDay": "1 gün", - "oneWeek": "1 hafta", - "fourMinutes": "", - "oneMinute": "", - "twoMinutes": "", - "fifteenSeconds": "", - "thirtySeconds": "" - }, - "general": { - "noOptionsFound": "" - }, - "infrastructureMonitor": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "pageSpeed": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "" - } - }, - "editUserPage": { - "form": { - "email": "", - "firstName": "", - "lastName": "", - "role": "", - "save": "" - }, - "table": { - "actionHeader": "", - "roleHeader": "" - }, - "title": "", - "toast": { - "successUserUpdate": "", - "validationErrors": "" - } - }, - "incidentsPageActionResolveMonitor": "", - "incidentsPageActionResolveAll": "", - "matchMethodOptions": { - "equal": "", - "equalPlaceholder": "", - "include": "", - "includePlaceholder": "", - "regex": "", - "regexPlaceholder": "", - "text": "" - }, - "monitorType": { - "docker": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "http": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "ping": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "port": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "game": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "" - }, - "bytesPerSecond": "", - "bytesReceived": "", - "bytesSent": "", - "chooseGame": "", - "createMonitorPage": { - "incidentConfigDescription": "", - "incidentConfigStatusWindowLabel": "", - "incidentConfigStatusWindowThresholdLabel": "", - "incidentConfigTitle": "", - "incidentConfigDescriptionV2": "", - "incidentConfigStatusCheckNumber": "", - "intervalTitle": "", - "intervalDescription": "" - }, - "dataRate": "", - "dataReceived": "", - "dataSent": "", - "details": "", - "drops": "", - "errors": "", - "errorsIn": "", - "errorsOut": "", - "gameServerMonitoring": "", - "gameServerMonitoringDescription": "", - "network": "", - "networkDrops": "", - "networkErrors": "", - "networkInterface": "", - "noNetworkStatsAvailable": "", - "packetsPerSecond": "", - "packetsReceived": "", - "packetsReceivedRate": "", - "packetsSent": "", - "rate": "", - "selectInterface": "" + } } diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 22b96487c..2ab0a837f 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -1,838 +1,1447 @@ { - "submit": "", - "title": "", - "distributedStatusHeaderText": "", - "distributedStatusSubHeaderText": "", - "settingsDisabled": "", - "settingsSuccessSaved": "", - "settingsFailedToSave": "", - "settingsStatsCleared": "", - "settingsFailedToClearStats": "", - "settingsFailedToAddDemoMonitors": "", - "settingsMonitorsDeleted": "", - "settingsFailedToDeleteMonitors": "", - "starPromptTitle": "", - "starPromptDescription": "", - "https": "", - "http": "", - "monitor": "", - "aboutus": "", - "now": "", - "delete": "", - "configure": "", - "responseTime": "", - "ms": "", - "bar": "", - "area": "", - "country": "", - "city": "", - "response": "", - "monitorStatusUp": "", - "monitorStatusDown": "", - "webhookSendSuccess": "", - "webhookSendError": "", - "webhookUnsupportedPlatform": "", - "distributedRightCategoryTitle": "", - "distributedStatusServerMonitors": "", - "distributedStatusServerMonitorsDescription": "", - "distributedUptimeCreateSelectURL": "", - "distributedUptimeCreateChecks": "", - "distributedUptimeCreateChecksDescription": "", - "distributedUptimeCreateIncidentNotification": "", - "distributedUptimeCreateIncidentDescription": "", - "distributedUptimeCreateAdvancedSettings": "", - "distributedUptimeDetailsNoMonitorHistory": "", - "distributedUptimeDetailsStatusHeaderUptime": "", - "distributedUptimeDetailsStatusHeaderLastUpdate": "", - "notifications": { - "enableNotifications": "", - "testNotification": "", - "addOrEditNotifications": "", - "slack": { - "label": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "discord": { - "label": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "telegram": { - "label": "", - "description": "", - "tokenLabel": "", - "tokenPlaceholder": "", - "chatIdLabel": "", - "chatIdPlaceholder": "", - "fieldsRequired": "" - }, - "webhook": { - "label": "", - "description": "", - "urlLabel": "", - "urlPlaceholder": "", - "urlRequired": "" - }, - "testNotificationDevelop": "", - "integrationButton": "", - "testSuccess": "", - "testFailed": "", - "unsupportedType": "", - "networkError": "", - "fallback": { - "title": "", - "checks": [""] - }, - "createButton": "", - "createTitle": "", - "create": { - "success": "", - "failed": "" - }, - "fetch": { - "success": "", - "failed": "" - }, - "delete": { - "success": "", - "failed": "" - }, - "edit": { - "success": "", - "failed": "" - }, - "test": { - "success": "", - "failed": "" - } - }, - "testLocale": "", - "add": "", - "monitors": "", - "distributedUptimeStatusCreateStatusPage": "", - "distributedUptimeStatusCreateStatusPageAccess": "", - "distributedUptimeStatusCreateStatusPageReady": "", - "distributedUptimeStatusBasicInfoHeader": "", - "distributedUptimeStatusBasicInfoDescription": "", - "distributedUptimeStatusLogoHeader": "", - "distributedUptimeStatusLogoDescription": "", - "distributedUptimeStatusLogoUploadButton": "", - "distributedUptimeStatusStandardMonitorsHeader": "", - "distributedUptimeStatusStandardMonitorsDescription": "", - "distributedUptimeStatusCreateYour": "", - "distributedUptimeStatusEditYour": "", - "distributedUptimeStatusPublishedLabel": "", - "distributedUptimeStatusCompanyNameLabel": "", - "distributedUptimeStatusPageAddressLabel": "", - "distributedUptimeStatus30Days": "", - "distributedUptimeStatus60Days": "", - "distributedUptimeStatus90Days": "", - "distributedUptimeStatusPageNotSetUp": "", - "distributedUptimeStatusContactAdmin": "", - "distributedUptimeStatusPageNotPublic": "", - "distributedUptimeStatusPageDeleteDialog": "", - "distributedUptimeStatusPageDeleteConfirm": "", - "distributedUptimeStatusPageDeleteDescription": "", - "distributedUptimeStatusDevices": "", - "distributedUptimeStatusUpt": "", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "", - "incidentsTableNoIncidents": "", - "incidentsTablePaginationLabel": "", - "incidentsTableMonitorName": "", - "incidentsTableStatus": "", - "incidentsTableDateTime": "", - "incidentsTableStatusCode": "", - "incidentsTableMessage": "", - "incidentsOptionsHeader": "", - "incidentsOptionsHeaderFilterBy": "", - "incidentsOptionsHeaderFilterAll": "", - "incidentsOptionsHeaderFilterDown": "", - "incidentsOptionsHeaderFilterCannotResolve": "", - "incidentsOptionsHeaderShow": "", - "incidentsOptionsHeaderLastHour": "", - "incidentsOptionsHeaderLastDay": "", - "incidentsOptionsHeaderLastWeek": "", - "incidentsOptionsPlaceholderAllServers": "", - "infrastructureCreateYour": "", - "infrastructureCreateGeneralSettingsDescription": "", - "infrastructureServerRequirement": "", - "infrastructureCustomizeAlerts": "", - "infrastructureAlertNotificationDescription": "", - "infrastructureCreateMonitor": "", - "infrastructureProtocol": "", - "infrastructureServerUrlLabel": "", - "infrastructureDisplayNameLabel": "", - "infrastructureAuthorizationSecretLabel": "", - "gb": "", - "mb": "", - "mem": "", - "memoryUsage": "", - "cpu": "", - "cpuUsage": "", - "cpuTemperature": "", - "diskUsage": "", - "used": "", - "total": "", - "cores": "", - "frequency": "", - "status": "", - "cpuPhysical": "", - "cpuLogical": "", - "cpuFrequency": "", - "avgCpuTemperature": "", - "memory": "", - "disk": "", - "uptime": "", - "os": "", - "host": "", - "actions": "", - "integrations": "", - "integrationsPrism": "", - "integrationsSlack": "", - "integrationsSlackInfo": "", - "integrationsDiscord": "", - "integrationsDiscordInfo": "", - "integrationsZapier": "", - "integrationsZapierInfo": "", - "commonSave": "", - "createYour": "", - "createMonitor": "", - "pause": "", - "resume": "", - "editing": "", - "url": "", - "access": "", - "timezone": "", - "features": "", - "administrator": "", - "loginHere": "", - "displayName": "", - "urlMonitor": "", - "portToMonitor": "", - "websiteMonitoring": "", - "websiteMonitoringDescription": "", - "pingMonitoring": "", - "pingMonitoringDescription": "", - "dockerContainerMonitoring": "", - "dockerContainerMonitoringDescription": "", - "portMonitoring": "", - "portMonitoringDescription": "", - "createMaintenanceWindow": "", - "createMaintenance": "", - "editMaintenance": "", - "maintenanceWindowName": "", - "friendlyNameInput": "", - "friendlyNamePlaceholder": "", - "maintenanceRepeat": "", - "maintenance": "", - "duration": "", - "addMonitors": "", - "window": "", - "cancel": "", - "message": "", - "low": "", - "high": "", - "statusCode": "", - "date&Time": "", - "type": "", - "statusPageName": "", - "publicURL": "", - "repeat": "", - "edit": "", - "createA": "", - "remove": "", - "maintenanceWindowDescription": "", - "startTime": "", - "timeZoneInfo": "", - "monitorsToApply": "", - "nextWindow": "", - "notFoundButton": "", - "pageSpeedConfigureSettingsDescription": "", - "monitorDisplayName": "", - "whenNewIncident": "", - "notifySMS": "", - "notifyEmails": "", - "seperateEmails": "", - "checkFrequency": "", - "chooseGame": "", - "matchMethod": "", - "expectedValue": "", - "deleteDialogTitle": "", - "deleteDialogDescription": "", - "pageSpeedMonitor": "", - "shown": "", - "ago": "", - "companyName": "", - "pageSpeedDetailsPerformanceReport": "", - "pageSpeedDetailsPerformanceReportCalculator": "", - "checkingEvery": "", - "statusPageCreateSettings": "", - "basicInformation": "", - "statusPageCreateBasicInfoDescription": "", - "statusPageCreateSelectTimeZoneDescription": "", - "statusPageCreateAppearanceDescription": "", - "statusPageCreateSettingsCheckboxLabel": "", - "statusPageCreateBasicInfoStatusPageAddress": "", - "statusPageCreateTabsContent": "", - "statusPageCreateTabsContentDescription": "", - "statusPageCreateTabsContentFeaturesDescription": "", - "showCharts": "", - "showUptimePercentage": "", - "removeLogo": "", - "statusPageStatus": "", - "statusPageStatusContactAdmin": "", - "statusPageStatusNotPublic": "", - "statusPageStatusNoPage": "", - "statusPageStatusServiceStatus": "", - "deleteStatusPage": "", - "deleteStatusPageConfirm": "", - "deleteStatusPageDescription": "", - "uptimeCreate": "", - "uptimeCreateJsonPath": "", - "uptimeCreateJsonPathQuery": "", - "maintenanceTableActionMenuDialogTitle": "", - "infrastructureEditYour": "", - "infrastructureEditMonitor": "", - "infrastructureMonitorCreated": "", - "infrastructureMonitorUpdated": "", - "errorInvalidTypeId": "", - "errorInvalidFieldId": "", - "inviteNoTokenFound": "", - "pageSpeedWarning": "", - "pageSpeedLearnMoreLink": "", - "pageSpeedAddApiKey": "", - "update": "", - "invalidFileFormat": "", - "invalidFileSize": "", - "ClickUpload": "", - "DragandDrop": "", - "MaxSize": "", - "SupportedFormats": "", - "FirstName": "", - "LastName": "", - "EmailDescriptionText": "", - "YourPhoto": "", - "PhotoDescriptionText": "", - "save": "", - "DeleteDescriptionText": "", - "DeleteAccountWarning": "", - "DeleteWarningTitle": "", - "bulkImport": { - "title": "", - "selectFileTips": "", - "selectFileDescription": "", - "selectFile": "", - "parsingFailed": "", - "uploadSuccess": "", - "validationFailed": "", - "noFileSelected": "", - "fallbackPage": "" - }, - "DeleteAccountTitle": "", - "DeleteAccountButton": "", - "publicLink": "", - "maskedPageSpeedKeyPlaceholder": "", - "reset": "", - "ignoreTLSError": "", - "tlsErrorIgnored": "", - "ignoreTLSErrorDescription": "", - "createNew": "", - "greeting": { - "prepend": "", - "append": "", - "overview": "" - }, - "roles": { - "superAdmin": "", - "admin": "", - "teamMember": "", - "demoUser": "" - }, - "teamPanel": { - "teamMembers": "", - "filter": { - "all": "", - "member": "" - }, - "inviteTeamMember": "", - "inviteNewTeamMember": "", - "inviteDescription": "", - "email": "", - "selectRole": "", - "inviteLink": "", - "cancel": "", - "noMembers": "", - "getToken": "", - "emailToken": "", - "table": { - "name": "", - "email": "", - "role": "", - "created": "" - } - }, - "monitorState": { - "paused": "", - "resumed": "", - "active": "" - }, - "menu": { - "uptime": "", - "pagespeed": "", - "infrastructure": "", - "incidents": "", - "statusPages": "", - "maintenance": "", - "integrations": "", - "settings": "", - "support": "", - "discussions": "", - "docs": "", - "changelog": "", - "profile": "", - "password": "", - "team": "", - "logOut": "", - "notifications": "", - "logs": "" - }, - "settingsEmailUser": "", - "state": "", - "statusBreadCrumbsStatusPages": "", - "statusBreadCrumbsDetails": "", - "commonSaving": "", - "navControls": "", - "incidentsPageTitle": "", - "passwordPanel": { - "passwordChangedSuccess": "", - "passwordInputIncorrect": "", - "currentPassword": "", - "enterCurrentPassword": "", - "newPassword": "", - "enterNewPassword": "", - "confirmNewPassword": "", - "passwordRequirements": "", - "saving": "" - }, - "emailSent": "", - "failedToSendEmail": "", - "settingsTestEmailSuccess": "", - "settingsTestEmailFailed": "", - "settingsTestEmailFailedWithReason": "", - "settingsTestEmailUnknownError": "", - "statusMsg": { - "paused": "", - "up": "", - "down": "", - "pending": "" - }, - "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "", - "game": "" - }, "common": { - "appName": "", - "monitoringAgentName": "", - "buttons": { - "toggleTheme": "" + "auth": { + "roles": { + "admin": "Адміністратор", + "demo": "Демо", + "superadmin": "Суперадміністратор", + "user": "Користувач" + } }, - "toasts": { - "networkError": "", - "checkConnection": "", - "unknownError": "" + "alerts": { + "pageSpeedApiKey": { + "content": "Увага: Ви ще не додали ключ API Google PageSpeed. Перейдіть у Налаштування, щоб додати його. Без нього моніторинг PageSpeed не працюватиме." + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "Деталі", + "home": "Головна" + }, + "buttons": { + "addMember": "Додати учасника", + "cancel": "Скасувати", + "close": "Закрити", + "configure": "Налаштувати", + "confirm": "Підтвердити", + "create": "Створити", + "delete": "Видалити", + "generateToken": "Згенерувати токен", + "incidents": "Інциденти", + "inviteMember": "Запросити учасника", + "pause": "Призупинити", + "resume": "Відновити", + "save": "Зберегти", + "sendInvite": "Надіслати запрошення", + "test": "Тест", + "testNotifications": "Тестові сповіщення", + "toggleTheme": "Перемкнути світлу / темну", + "flushQueue": "Очистити чергу", + "notFound": "Перейти на головну панель", + "resetPassword": "Скинути пароль", + "reset": "Скинути", + "clear": "Очистити", + "addDemo": "Додати демо-монітори", + "removeMonitors": "Видалити монітори", + "sendTestEmail": "Надіслати тестовий лист", + "exportToJSON": "Експортувати в JSON", + "importFromJSON": "Імпортувати з JSON", + "clearFilters": "Очистити фільтри", + "removeUser": "Видалити користувача" + }, + "charts": { + "labels": { + "averageResponseTime": "Середній час відповіді", + "downtime": "Час простою", + "high": "Високий", + "low": "Низький", + "uptime": "Час роботи" + }, + "histogram": { + "avg": "Сер: {{value}} мс", + "max": "Макс: {{value}} мс" + } + }, + "dialogs": { + "delete": { + "description": "Цю дію не можна скасувати.", + "title": "Ви впевнені, що хочете це видалити?" + } + }, + "labels": { + "active": "Активний", + "paused": "призупинено", + "na": "Н/Д", + "resolved": "Вирішено", + "responseTime": "Час відповіді" + }, + "form": { + "role": { + "option": { + "role": { + "label": "Ролі" + } + } + }, + "name": { + "option": { + "firstName": { + "label": "Ім'я", + "placeholder": "Введіть ім'я" + }, + "lastName": { + "label": "Прізвище", + "placeholder": "Введіть прізвище" + } + } + }, + "email": { + "option": { + "email": { + "label": "Електронна пошта", + "placeholder": "you@example.com" + } + } + } + }, + "table": { + "empty": "Тут нічого немає", + "headers": { + "actions": "Дії", + "dateTime": "Дата і час", + "message": "Повідомлення", + "monitor": "Монітор", + "monitorId": "ID монітора", + "name": "Ім'я", + "status": "Статус", + "type": "Тип", + "url": "URL", + "interval": "Інтервал", + "active": "Активний", + "responseTime": "Час відповіді" + } } }, - "auth": { - "common": { - "navigation": { - "continue": "", - "back": "" - }, - "inputs": { - "email": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "invalid": "" - } - }, - "password": { - "label": "", - "rules": { - "length": { - "beginning": "", - "highlighted": "" - }, - "special": { - "beginning": "", - "highlighted": "" - }, - "number": { - "beginning": "", - "highlighted": "" - }, - "uppercase": { - "beginning": "", - "highlighted": "" - }, - "lowercase": { - "beginning": "", - "highlighted": "" - }, - "match": { - "beginning": "", - "highlighted": "" - } - }, - "errors": { - "empty": "", - "length": "", - "uppercase": "", - "lowercase": "", - "number": "", - "special": "", - "incorrect": "" - } - }, - "passwordConfirm": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "different": "" - } - }, - "firstName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - }, - "lastName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - } - }, + "components": { + "imageUpload": { + "clickToUpload": "Натисніть, щоб завантажити", + "dragAndDrop": "або перетягніть", + "supportedFormats": "Підтримувані формати", + "maxSize": "Макс. розмір", + "orDragAndDrop": "або перетягніть файл", "errors": { - "validation": "" - }, - "fields": { - "password": { - "errors": { - "incorrect": "" - } - } + "invalidFileSize": "Розмір файлу занадто великий!", + "invalidFileFormat": "Непідтримуваний формат файлу!" } }, - "login": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "" - }, - "links": { - "forgotPassword": "", - "register": "", - "forgotPasswordLink": "", - "registerLink": "" - }, - "toasts": { - "success": "", - "incorrectPassword": "" - }, - "errors": { - "password": { - "incorrect": "" - } - } + "headerStatusPageControls": { + "publicLink": "Публічне посилання" }, - "registration": { - "heading": { - "superAdmin": "", - "user": "" - }, - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "" + "headerTimeRange": { + "labels": { + "day": "День", + "month": "Місяць", + "recent": "Нещодавно", + "week": "Тиждень" }, "description": { - "superAdmin": "", - "user": "" + "recent": "Показано статистику за останні 2 години.", + "day": "Показано статистику за останні 24 години.", + "week": "Показано статистику за останні 7 днів.", + "month": "Показано статистику за останні 30 днів." + } + }, + "offlineBanner": { + "serverUnreachable": "Не вдається з'єднатися з сервером", + "retry": "Повторити", + "retrying": "Повторна спроба...", + "reconnected": "З'єднання відновлено" + }, + "sidebar": { + "menu": { + "uptime": "Доступність", + "pagespeed": "Pagespeed", + "infrastructure": "Інфраструктура", + "notifications": "Сповіщення", + "checks": "Перевірки", + "incidents": "Інциденти", + "statusPages": "Сторінки статусу", + "maintenance": "Обслуговування", + "logs": "Журнали", + "settings": "Налаштування" }, - "gettingStartedButton": { - "superAdmin": "", - "user": "" + "bottomMenu": { + "support": "Підтримка", + "discussions": "Обговорення", + "docs": "Документація", + "changelog": "Журнал змін" }, - "termsAndPolicies": "", + "accountMenu": { + "profile": "Профіль", + "password": "Пароль", + "team": "Команда" + }, + "starPrompt": { + "title": "Оцінити Checkmate", + "description": "Переглядайте останні оновлення та допомагайте розвивати спільноту на GitHub" + }, + "authFooter": { + "navControls": "Керування", + "logOut": "Вийти", + "roles": { + "superAdmin": "Суперадміністратор", + "admin": "Адміністратор", + "user": "Користувач", + "demoUser": "Демо-користувач" + } + } + }, + "starPrompt": { + "title": "Оцінити Checkmate", + "description": "Переглядайте останні оновлення та допомагайте розвивати спільноту на GitHub" + }, + "pageHeader": { "links": { - "login": "" - }, - "toasts": { - "success": "" - } - }, - "forgotPassword": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "", - "stepFour": "" - }, - "buttons": { - "openEmail": "", - "resetPassword": "" - }, - "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" - }, - "links": { - "login": "", - "resend": "" - }, - "toasts": { - "sent": "", - "emailNotFound": "", - "redirect": "", - "success": "", - "error": "" + "support": "Підтримка", + "discussions": "Обговорення", + "docs": "Документація", + "changelog": "Журнал змін" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "", - "stillUnreachable": "" + "pages": { + "notFound": { + "title": "Ой ні! Ви впустили суші!", + "subtitle": "Або URL не існує, або у Вас немає доступу до нього." + }, + "account": { + "tabs": { + "profile": "Профіль", + "password": "Пароль", + "team": "Команда" }, - "alertBox": "", - "description": "", - "retryButton": { - "default": "", - "processing": "" + "form": { + "name": { + "title": "Ім'я", + "description": "Оновіть Вашу особисту інформацію" + }, + "photo": { + "title": "Фото профілю", + "description": "Завантажте фото профілю" + }, + "currentPassword": { + "title": "Поточний пароль", + "description": "Введіть поточний пароль для підтвердження Вашої особи", + "option": { + "label": "Поточний пароль", + "placeholder": "Введіть поточний пароль" + } + }, + "newPassword": { + "title": "Новий пароль", + "description": "Оберіть надійний пароль довжиною не менше 8 символів", + "option": { + "newPassword": { + "label": "Новий пароль", + "placeholder": "Введіть новий пароль" + }, + "confirm": { + "label": "Підтвердити пароль", + "placeholder": "Підтвердити новий пароль" + } + } + }, + "deleteAccount": { + "title": "Видалити обліковий запис", + "description": "Ця дія є остаточною і не може бути скасована" + } + }, + "team": { + "filter": { + "placeholder": "Фільтрувати за роллю", + "all": "Усі ролі", + "admin": "Адміністратор", + "member": "Учасник" + }, + "table": { + "headers": { + "email": "Електронна пошта", + "role": "Роль", + "created": "Створено" + } + }, + "addMember": { + "title": "Зареєструвати нового учасника команди", + "description": "Створіть новий обліковий запис. Безпечно передайте облікові дані після створення." + }, + "invite": { + "title": "Запросити учасника команди", + "description": "Надіслати запрошення приєднатися до Вашої команди", + "email": { + "label": "Адреса електронної пошти", + "placeholder": "Введіть адресу електронної пошти" + }, + "role": { + "label": "Роль", + "placeholder": "Оберіть роль" + }, + "linkLabel": "Посилання для запрошення" + }, + "fallback": { + "title": "Поки що немає учасників команди", + "description": "Запросіть колег електронною поштою або додайте їх безпосередньо. Як тільки вони приєднаються, вони з'являться тут зі своєю роллю." + } + }, + "header": { + "title": "Обліковий запис", + "description": "Керуйте профілем, паролем, командою та налаштуваннями робочого простору." } - } - }, - "createNotifications": { - "title": "", - "nameSettings": { - "title": "", - "description": "", - "nameLabel": "", - "namePlaceholder": "" }, - "typeSettings": { - "title": "", - "description": "", - "typeLabel": "" - }, - "emailSettings": { - "title": "", - "description": "", - "emailLabel": "", - "emailPlaceholder": "" - }, - "slackSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "pagerdutySettings": { - "title": "", - "description": "", - "integrationKeyLabel": "", - "integrationKeyPlaceholder": "" - }, - "discordSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "webhookSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - } - }, - "notificationConfig": { - "title": "", - "description": "" - }, - "monitorStatus": { - "checkingEvery": "", - "withCaptureAgent": "", - "up": "", - "down": "", - "paused": "" - }, - "advancedMatching": "", - "sendTestNotifications": "", - "testNotificationsDisabled": "", - "selectAll": "", - "showAdminLoginLink": "", - "logsPage": { - "title": "", - "description": "", - "tabs": { - "queue": "", - "logs": "" - }, - "toast": { - "fetchLogsSuccess": "" - }, - "logLevelSelect": { - "title": "", - "values": { - "all": "", - "info": "", - "warn": "", - "error": "", - "debug": "" + "auth": { + "brandPanel": { + "eyebrow": "Моніторинг інфраструктури з відкритим кодом", + "tagline": "Слідкуйте за кожним кроком ваших серверів.", + "description": "Моніторинг доступності, часу відгуку та інцидентів у реальному часі - self-hosted і належить вам." + }, + "common": { + "passwordRules": { + "length": { + "beginning": "", + "highlighted": "" + }, + "lowercase": { + "beginning": "", + "highlighted": "" + }, + "match": { + "beginning": "", + "highlighted": "" + }, + "number": { + "beginning": "", + "highlighted": "" + }, + "special": { + "beginning": "", + "highlighted": "" + }, + "uppercase": { + "beginning": "", + "highlighted": "" + } + }, + "form": { + "option": { + "email": { + "label": "Електронна пошта", + "placeholder": "me@example.com" + }, + "password": { + "label": "Пароль", + "placeholder": "********" + }, + "confirmPassword": { + "label": "Підтвердити пароль" + } + } + } + }, + "login": { + "title": "Ласкаво просимо до Checkmate!", + "subtitle": "Увійдіть, щоб продовжити", + "submit": "Увійти", + "links": { + "forgotPassword": { + "text": "Забули пароль?", + "linkText": "Скинути пароль" + }, + "register": { + "text": "Не маєте облікового запису?", + "linkText": "Зареєструватися тут" + } + } + }, + "register": { + "title": "Ласкаво просимо до Checkmate!", + "subtitle": "Зареєструйтесь, щоб розпочати", + "submit": "Зареєструватися", + "setupNotice": "Ця сторінка доступна лише під час початкового налаштування. Після реєстрації суперадміністратора додаткових користувачів можна запросити з налаштувань команди." + }, + "forgotPassword": { + "title": "Забули пароль?", + "subtitle": "Не хвилюйтесь, ми надішлемо Вам інструкції для скидання.", + "submit": "Запит на відновлення", + "links": { + "login": { + "text": "Повернутися до", + "linkText": "входу" + } + } + }, + "setNewPassword": { + "title": "Скинути пароль", + "subtitle": "Ваш новий пароль повинен відрізнятися від раніше використаних паролів." } - } - }, - "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", - "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "", - "activeHeader": "", - "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "", - "lastRunTookHeader": "" }, - "metricsTable": { - "title": "", - "metricHeader": "", - "valueHeader": "" + "checks": { + "selects": { + "monitor": { + "all": "Усі монітори" + }, + "status": { + "all": "Усі", + "down": "Недоступний", + "up": "Доступний" + } + }, + "table": { + "empty": "Немає невдалих перевірок за цей період", + "headers": { + "statusCode": "Код статусу", + "location": "Розташування" + } + }, + "fallback": { + "title": "Перевірок ще немає", + "description": "Як тільки ви створите монітор, кожна перевірка статусу та час відповіді з’являться тут." + }, + "header": { + "title": "Перевірки", + "description": "Кожне зондування, яке Checkmate виконує проти ваших моніторів, з'явиться тут з кодом статусу та часом відповіді." + } }, - "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", - "failedAtHeader": "", - "failReasonHeader": "" - } - }, - "export": { - "title": "", - "success": "", - "failed": "" - }, - "monitorActions": { - "title": "", - "import": "", - "export": "" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "" + "common": { + "monitors": { + "actions": { + "configure": "Налаштувати", + "delete": "Видалити", + "generateToken": "Згенерувати токен", + "details": "Деталі", + "incidents": "Інциденти", + "inviteMember": "Запросити учасника", + "openSite": "Відкрити сайт", + "pause": "Призупинити", + "resume": "Відновити", + "selectAll": "Вибрати всі монітори", + "selectMonitor": "Вибрати {{name}}", + "bulkSelected_one": "Вибрано {{count}} монітор", + "bulkSelected_other": "Вибрано {{count}} моніторів" + }, + "statBoxes": { + "activeFor": "Активний протягом", + "certificateExpiry": "Закінчення сертифіката", + "lastCheck": "Остання перевірка", + "lastResponseTime": "Останній час відповіді", + "serviceIsDown": "Сервіс недоступний" + }, + "status": { + "down": "недоступний", + "breached": "поріг перевищено", + "initializing": "ініціалізація", + "maintenance": "обслуговування", + "paused": "призупинено", + "total": "усього", + "up": "доступний" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "Гра", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "Порт", + "optionPagespeed": "PageSpeed", + "optionHardware": "Інфраструктура", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "{{count}} монітор призупинено", + "paused_other": "{{count}} моніторів призупинено", + "resumed_one": "{{count}} монітор відновлено", + "resumed_other": "{{count}} моніторів відновлено", + "alreadyPaused_one": "Вибраний монітор уже призупинено", + "alreadyPaused_other": "Всі вибрані монітори уже призупинено", + "alreadyRunning_one": "Вибраний монітор уже запущено", + "alreadyRunning_other": "Всі вибрані монітори уже запущено" + } + } }, - "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" + "createMonitor": { + "form": { + "advanced": { + "description": "Додаткові налаштування для складних випадків", + "option": { + "advancedMatching": { + "label": "Використовувати розширене зіставлення" + }, + "expectedValue": { + "label": "Очікуване значення" + }, + "jsonPath": { + "description": "Цей вираз буде обчислено для JSON-даних відповіді, а результат буде зіставлено з очікуваним значенням. Дивіться документацію мови запитів на jmespath.org.", + "label": "Вираз JSONPath" + }, + "matchMethod": { + "label": "Метод зіставлення", + "equal": "Дорівнює", + "include": "Містить", + "regex": "Regex" + } + }, + "title": "Розширені налаштування" + }, + "frequency": { + "description": "Як часто Ви хочете перевіряти стан цього монітора?", + "option": { + "frequency": { + "label": "Частота перевірки", + "value": { + "fifteenMinutes": "15 хвилин", + "fifteenSeconds": "15 секунд", + "fiveMinutes": "5 хвилин", + "fourMinutes": "4 хвилини", + "oneMinute": "1 хвилина", + "tenMinutes": "10 хвилин", + "thirtyMinutes": "30 хвилин", + "thirtySeconds": "30 секунд", + "threeMinutes": "3 хвилини", + "twoMinutes": "2 хвилини" + } + } + }, + "title": "Частота перевірки" + }, + "general": { + "option": { + "container": { + "label": "Ім'я/ID контейнера", + "placeholder": "my-app або abcd1234" + }, + "host": { + "label": "Хост", + "placeholder": "192.168.1.100 або example.com" + }, + "name": { + "label": "Відображуване ім'я", + "placeholder": "напр. Мій сайт" + }, + "secret": { + "label": "Секрет авторизації", + "placeholder": "Введіть Ваш секретний ключ" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "Обрати гру", + "placeholder": "Оберіть гру" + }, + "port": { + "label": "Порт для моніторингу", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "Назва сервісу", + "placeholder": "напр. my.service.v1 (залиште порожнім для загальної перевірки)" + }, + "wsUrl": { + "label": "WebSocket URL", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Точка доступу Capture", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "Загальні налаштування", + "description": { + "http": "Введіть URL або IP для моніторингу (напр., https://example.com/ або 192.168.1.100) та додайте зрозуміле ім'я для відображення на панелі.", + "ping": "", + "port": "", + "docker": "", + "game": "Введіть IP-адресу або ім'я хоста та номер порту для пінгу (наприклад, 192.168.1.100 або example.com) і виберіть тип гри. Деяким іграм потрібно ввімкнути серверні запити у файлі конфігурації — перевірте документацію вашого ігрового сервера. Деталі протоколу див. у списку підтримуваних ігор.", + "pagespeed": "Відстежуйте швидкість завантаження сторінки, показники Core Web Vitals та оцінки оптимізації Вашого сайту.", + "grpc": "Введіть ім'я хоста та порт gRPC-сервера, за бажанням вкажіть назву сервісу для перевірки стану та додайте відображуване ім'я.", + "websocket": "Введіть WebSocket URL для моніторингу (напр., wss://example.com/socket) та додайте відображуване ім'я.", + "hardware": "Відстежуйте використання ЦП, пам'яті, диска та температуру вашої інфраструктури. URL точки доступу Capture має містити повний шлях /api/v1/metrics (наприклад, http://192.168.1.10:5001/api/v1/metrics). У секрет авторизації вставте лише рядок токена — не додавайте 'Bearer'." + } + }, + "ignoreTls": { + "description": "Налаштуйте перевірку сертифікатів TLS/SSL для HTTPS-з'єднань.", + "option": { + "tls": { + "label": "Ігнорувати помилки TLS/SSL" + } + }, + "title": "Налаштування TLS/SSL" + }, + "incidents": { + "description": "Ковзне вікно використовується для визначення моменту, коли монітор стає недоступним. Статус монітора зміниться лише тоді, коли відсоток перевірок у ковзному вікні досягне вказаного значення.", + "option": { + "checks": { + "label": "Кількість перевірок у ковзному вікні" + }, + "percentage": { + "label": "Який відсоток перевірок у ковзному вікні має зазнати невдачі/успіху перед зміною статусу монітора?" + } + }, + "title": "Інциденти" + }, + "notifications": { + "description": "Оберіть канали сповіщень, які Ви хочете використовувати", + "title": "Сповіщення" + }, + "type": { + "description": "Оберіть тип перевірки", + "optionDockerDescription": "Використовуйте Docker для моніторингу роботи контейнера.", + "optionGameDescription": "Моніторинг доступності конкретного ігрового сервера.", + "optionGrpcDescription": "Моніторинг gRPC-сервісів за допомогою стандартного протоколу перевірки стану.", + "optionWebSocketDescription": "Моніторинг WebSocket-ендпоінтів на предмет стану з'єднання та часу відповіді.", + "optionHttpDescription": "Використовуйте HTTP(S) для моніторингу Вашого сайту або API-ендпоінту.", + "optionPingDescription": "Використовуйте ICMP Ping для моніторингу доступності сервера.", + "optionPortDescription": "Моніторинг відкритості конкретного порту на сервері.", + "title": "Тип" + }, + "thresholds": { + "title": "Порогові значення сповіщень", + "description": "Визначте порогові значення, при яких мають спрацьовувати сповіщення для цього монітора обладнання.", + "option": { + "cpuThreshold": { + "label": "Поріг сповіщення CPU (%)" + }, + "memoryThreshold": { + "label": "Поріг сповіщення пам'яті (%)" + }, + "diskThreshold": { + "label": "Поріг сповіщення диска (%)" + }, + "tempThreshold": { + "label": "Поріг сповіщення температури (°C)" + } + } + }, + "geoChecks": { + "title": "Геоперевірки", + "description": "Запускайте перевірки з різних географічних локацій для моніторингу глобальної доступності та продуктивності.", + "option": { + "enabled": { + "label": "Увімкнути геоперевірки" + }, + "locations": { + "label": "Розташування", + "placeholder": "Оберіть розташування", + "options": { + "EU": "Європа", + "NA": "Північна Америка", + "AS": "Азія", + "SA": "Південна Америка", + "AF": "Африка", + "OC": "Океанія" + } + }, + "interval": { + "label": "Інтервал перевірки", + "value": { + "fiveMinutes": "5 хвилин", + "tenMinutes": "10 хвилин", + "fifteenMinutes": "15 хвилин", + "thirtyMinutes": "30 хвилин" + } + } + } + }, + "url": { + "title": "IP/URL монітора на сторінці статусу", + "description": "Відображати IP-адресу або URL монітора на публічній сторінці статусу. Якщо вимкнено, буде показано лише назву монітора для захисту конфіденційної інформації.", + "option": { + "showURL": { + "label": "Відображати IP/URL на сторінці статусу", + "enabled": "Увімкнено", + "disabled": "Вимкнено" + } + } + }, + "stats": { + "title": "Історія моніторингу", + "description": "Очистити всю історію моніторингу та статистику Вашої команди. Ця дія незворотна.", + "buttonText": "Очистити всю статистику", + "dialog": { + "title": "Очистити всю історію моніторингу?", + "description": "Це назавжди видалить всю історію моніторингу, статистику та дані перевірок Вашої команди. Цю дію не можна скасувати.", + "confirm": "Так, очистити всю статистику" + } + } + } }, - "emailSettings": { - "buttonSendTestEmail": "", - "description": "", - "descriptionTransport": "", - "labelAddress": "", - "labelConnectionHost": "", - "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", - "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", - "labelRequireTLS": "", - "labelSecure": "", - "labelTLSServername": "", - "labelUser": "", - "linkTransport": "", - "placeholderUser": "", - "title": "" + "editUser": { + "form": { + "roles": { + "title": "Ролі", + "description": "Призначте ролі користувачу. Можна обрати кілька ролей." + } + }, + "dialog": { + "removeUser": { + "title": "Видалити користувача", + "content": "Ви впевнені, що хочете видалити {{name}} з Вашої команди? Цю дію не можна скасувати." + } + } }, - "pageSpeedSettings": { - "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "" + "incidents": { + "fallback": { + "title": "Інцидентів немає — усі системи справні", + "description": "Коли монітор виходить з ладу або відновлюється, інцидент з’явиться тут з часом, деталями монітора та історією вирішення." + }, + "dialog": { + "resolveIncident": { + "title": "Вирішити інцидент", + "option": { + "comment": { + "label": "Коментар (необов'язково)", + "placeholder": "Додайте коментар щодо вирішення..." + } + } + }, + "details": { + "analysis": "Аналіз інциденту", + "comment": "Коментар:", + "detailsLabel": "Деталі", + "downtime": "Час простою:", + "message": "Повідомлення:", + "monitor": "Монітор:", + "overview": "Огляд", + "resolutionDetails": "Деталі вирішення", + "resolutionType": "Тип:", + "resolutionTypes": { + "automatic": "Автоматично", + "manual": "Вручну" + }, + "resolve": "Вирішити інцидент", + "resolvedAt": "Вирішено о:", + "resolvedBy": "Вирішив:", + "startedAt": "Початок:", + "status": "Статус:", + "statusCode": "Код статусу:", + "timeline": "Хронологія", + "title": "Деталі інциденту", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "Усі монітори", + "monitor": "Монітор", + "resolutionType": "Тип вирішення", + "resolutionTypes": { + "manual": "Вручну", + "automatic": "Автоматично", + "all": "Усі" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "Активні інциденти", + "active_zero": "Немає активних інцидентів", + "active_one": "{{count}} активний інцидент", + "active_other": "{{count}} активних інцидентів" + }, + "incidentStats": { + "avgResolutionTime": "Середній час вирішення", + "mostAffectedMonitor": "Найвразливіший монітор", + "title": "Статистика інцидентів", + "totalIncidents": "Усього інцидентів" + }, + "latestIncidents": { + "title": "Останні інциденти" + } + }, + "table": { + "actions": { + "details": "Деталі", + "goToMonitor": "Перейти до монітора", + "resolveManually": "Вирішити вручну" + }, + "activeIncidents": "Активні інциденти", + "headers": { + "endTime": "Час завершення", + "resolutionType": "Тип вирішення", + "startTime": "", + "statusCode": "Код статусу" + }, + "resolvedIncidents": "Вирішені інциденти", + "status": { + "active": "Активний", + "resolved": "Вирішено" + } + }, + "header": { + "title": "Інциденти", + "description": "Коли монітор виходить з ладу або відновлюється, інциденти з'являються тут з часом, деталями монітора та історією вирішення." + } }, - "saveButtonLabel": "", - "statsSettings": { - "clearAllStatsButton": "", - "clearAllStatsDescription": "", - "clearAllStatsDialogConfirm": "", - "clearAllStatsDialogDescription": "", - "clearAllStatsDialogTitle": "", - "description": "", - "labelTTL": "", - "labelTTLOptional": "", - "title": "" + "infrastructure": { + "charts": { + "labels": { + "cpu": "Використання CPU", + "disk": "Використання диска", + "memory": "Використання пам'яті", + "netBytesRecv": "{{name}} - Отримано байтів", + "netBytesSent": "{{name}} - Надіслано байтів", + "netNoTraffic": "Capture повідомляє про нульовий трафік на всіх інтерфейсах. Це нормально, коли Capture працює всередині контейнера, який бачить лише loopback-інтерфейси — запустіть Capture безпосередньо на хості, щоб побачити реальну мережеву активність.", + "temp": "Темп." + } + }, + "gauges": { + "cpu": { + "lowerLabel": "Макс. частота", + "title": "Використання CPU", + "upperLabel": "Поточна частота" + }, + "disk": { + "lowerLabel": "Вільно", + "title": "Використання диска {{idx}}", + "upperLabel": "Використано" + }, + "memory": { + "lowerLabel": "Вільно", + "title": "Використання пам'яті", + "upperLabel": "Використано" + } + }, + "statBoxes": { + "avgCpuTemperature": "", + "cpuFrequency": "", + "cpuLogical": "", + "cpuPhysical": "", + "disk": "Диск", + "memory": "Пам'ять", + "os": "" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "Диск", + "memory": "Пам'ять" + } + }, + "tabs": { + "labels": { + "network": "Мережа", + "overview": "Огляд" + } + }, + "fallback": { + "actionButton": "Створити монітор!", + "title": "Ще немає моніторів інфраструктури", + "description": "Відстежуйте продуктивність серверів, виявляйте вузькі місця та забезпечуйте надійність з моніторингом у реальному часі." + }, + "header": { + "title": "Монітори інфраструктури", + "description": "Відстежуйте продуктивність серверів, виявляйте вузькі місця та забезпечуйте надійність з моніторингом у реальному часі." + } }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", - "dialogDescription": "", - "dialogTitle": "", - "title": "" + "logs": { + "fallback": { + "title": "Поки що немає журналів", + "description": "Активність сервера, попередження та помилки з’являться тут під час роботи Checkmate." + }, + "tabs": { + "diagnostics": "Діагностика", + "logs": "Журнали", + "queue": "Черга завдань" + }, + "logLevelSelect": { + "label": "Рівень логів" + }, + "jobQueue": "Черга завдань", + "jobQueueExplainer": "Виділені рядки — це завдання, які наразі обробляються воркером.", + "failedJobs": "Невдалі завдання", + "failedJobsExplainer": "Останні збої за моніторами — час останнього збою, кількість збоїв і причина.", + "noLogs": "Логи не знайдено", + "metrics": { + "jobs": "Завдання", + "activeJobs": "Активні завдання", + "failingJobs": "Невдалі завдання", + "totalRuns": "Усього запусків", + "totalFailures": "Усього помилок" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "Затримка циклу подій", + "uptime": "Час роботи", + "usedHeapSize": "Використаний розмір купи", + "totalHeapSize": "Загальний розмір купи", + "osMemoryLimit": "Обмеження пам'яті ОС" + }, + "gauges": { + "heapAllocation": "Виділення купи", + "heapUsage": "Використання купи", + "heapUtilization": "Утилізація купи", + "instantCpuUsage": "Миттєве використання CPU", + "availableMemoryPercentage": "% доступної пам'яті", + "allocatedPercentage": "% виділено", + "usedSPercentage": "% від 1с використано CPU", + "total": "Усього", + "used": "Використано" + }, + "sections": { + "runtimeStats": { + "title": "Статистика виконання", + "description": "Знімок процесу Node.js і хост-машини. Оновлюється кожні 5 секунд." + }, + "memoryCpu": { + "title": "Пам'ять і ЦП", + "description": "Як процес використовує виділену пам'ять і ЦП хоста." + } + } + }, + "table": { + "headers": { + "timestamp": "Мітка часу", + "level": "Рівень", + "service": "Сервіс", + "method": "Метод", + "monitorId": "ID монітора", + "runCount": "Кількість запусків", + "failCount": "Кількість помилок", + "lastRunAt": "Останній запуск", + "lockedAt": "Заблоковано о", + "lastFinishedAt": "Останнє завершення", + "lastRunTook": "Останній запуск тривав", + "lastFailedAt": "Остання помилка", + "failReason": "Причина помилки" + } + }, + "queueFallback": { + "title": "У черзі немає завдань", + "description": "Заплановані або виконувані перевірки моніторів з’являться тут. Порожня черга означає, що все актуально." + }, + "header": { + "title": "Журнали та черга", + "description": "Перевіряйте серверні журнали, чергу завдань моніторів і діагностику в одному місці." + } }, - "timezoneSettings": { - "description": "", - "label": "", - "title": "" + "maintenanceWindow": { + "fallback": { + "actionButton": "Створити вікно обслуговування!", + "title": "Немає запланованих вікон обслуговування", + "description": "Плануйте планові простої, щоб сповіщення було пригнічено, а команда та користувачі знали, чого очікувати." + }, + "table": { + "headers": { + "nextWindow": "", + "repeat": "" + } + }, + "form": { + "general": { + "title": "Загальні налаштування", + "description": "Встановіть назву та параметр повтору для вікна обслуговування.", + "option": { + "name": { + "label": "Ім'я", + "placeholder": "напр. Щотижневе обслуговування" + }, + "repeat": { + "label": "Повтор" + } + } + }, + "startDate": { + "title": "Дата початку", + "description": "Оберіть дату початку вікна обслуговування.", + "option": { + "startDate": { + "label": "Дата початку" + } + } + }, + "startTime": { + "title": "Час початку", + "description": "Встановіть час початку та тривалість вікна обслуговування. Усі значення вказані в UTC", + "option": { + "duration": { + "label": "Тривалість" + }, + "startTime": { + "label": "Час початку" + } + }, + "monitors": { + "title": "Монітори", + "description": "Монітори, до яких застосовується вікно обслуговування", + "option": { + "addMonitors": { + "label": "Додати монітори" + } + } + } + } + }, + "header": { + "title": "Вікна обслуговування", + "description": "Плануйте планові простої, щоб сповіщення було пригнічено, а команда та користувачі знали, чого очікувати." + } }, - "title": "", - "uiSettings": { - "description": "", - "labelLanguage": "", - "labelTheme": "", - "title": "" + "notifications": { + "fallback": { + "actionButton": "Створити канал", + "title": "Ще немає каналів сповіщень", + "description": "Підключіть Slack, Discord, електронну пошту, вебхуки та інше, щоб потрібні люди дізнавалися про інциденти одразу." + }, + "form": { + "accessToken": { + "optionAccessToken": "Токен доступу", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "Адреса, на яку надсилатимуться сповіщення.", + "optionAddress": "Адреса", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "Адреса" + }, + "homeServer": { + "optionHomeServer": "Домашній сервер", + "placeholder": "example.com" + }, + "matrix": { + "description": "Налаштуйте підключення до Matrix homeserver для сповіщень.", + "title": "Налаштування Matrix" + }, + "notificationName": { + "description": "Описова назва каналу сповіщень", + "optionName": "Назва каналу", + "placeholder": "напр. Сповіщення продакшену", + "title": "Назва каналу" + }, + "pagerDuty": { + "description": "Ваш ключ інтеграції PagerDuty для отримання сповіщень.", + "optionIntegrationKey": "Ключ інтеграції", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "Ключ інтеграції" + }, + "roomId": { + "optionRoomId": "ID кімнати", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "Оберіть тип каналу сповіщень для створення.", + "optionType": "Тип", + "title": "Тип каналу" + }, + "telegram": { + "title": "Налаштування Telegram", + "description": "", + "optionBotToken": "Токен бота", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "ID чату", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Налаштування Pushover", + "description": "Введіть токен застосунку та ключ користувача Pushover.", + "optionAppToken": "Токен застосунку", + "placeholderAppToken": "Введіть токен застосунку", + "optionUserKey": "Ключ користувача", + "placeholderUserKey": "Введіть ключ користувача" + }, + "twilio": { + "title": "Налаштування Twilio", + "description": "Введіть облікові дані Twilio для надсилання SMS.", + "optionAccountSid": "SID акаунта", + "placeholderAccountSid": "Введіть SID акаунта", + "optionAuthToken": "Токен автентифікації", + "placeholderAuthToken": "Введіть токен автентифікації", + "optionFromNumber": "Номер відправника", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "Номер отримувача", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "Призначення" + } + }, + "header": { + "title": "Сповіщення", + "description": "Підключіть Slack, Discord, електронну пошту, вебхуки та інше, щоб потрібні люди дізнавалися про інциденти одразу." + } }, - "urlSettings": { - "description": "", - "label": "", - "selectDisabled": "", - "selectEnabled": "", - "title": "" + "pageSpeed": { + "charts": { + "common": { + "cls": "Сукупний зсув макету (CLS)", + "fcp": "Перше відображення вмісту (FCP)", + "lcp": "Найбільше відображення вмісту (LCP)", + "si": "Індекс швидкості (SI)", + "tbt": "Загальний час блокування (TBT)" + }, + "legend": { + "title": "Звіт PageSpeed", + "weight": "Вага" + }, + "pie": { + "title": "Звіт продуктивності" + } + }, + "table": { + "headers": { + "pageSpeedScore": "Оцінка PageSpeed" + } + }, + "fallback": { + "actionButton": "Створити монітор!", + "title": "Ще немає моніторів PageSpeed", + "description": "Відстежуйте Core Web Vitals і оцінки Lighthouse у часі, щоб помітити регресії раніше за користувачів." + }, + "header": { + "title": "Монітори PageSpeed", + "description": "Відстежуйте Core Web Vitals і оцінки Lighthouse у часі, щоб помітити регресії раніше за користувачів." + } + }, + "settings": { + "form": { + "timezone": { + "title": "Часовий пояс відображення", + "description": "Оберіть часовий пояс для відображення дат і часу у додатку.", + "option": { + "timezone": { + "label": "Часовий пояс відображення" + } + } + }, + "ui": { + "title": "Зовнішній вигляд", + "description": "Перемикайтесь між світлою та темною темами, змінюйте мову або налаштовуйте тип відображення графіків.", + "option": { + "theme": { + "label": "Режим теми", + "light": "Світла", + "dark": "Темна" + }, + "language": { + "label": "Мова" + }, + "chartType": { + "label": "Тип графіка", + "histogram": "Гістограма", + "heatmap": "Теплова карта" + } + } + }, + "pagespeed": { + "title": "Ключ API Google PageSpeed", + "description": "Введіть Ваш ключ API Google PageSpeed для увімкнення моніторингу PageSpeed. Натисніть Скинути, щоб оновити ключ.", + "option": { + "apiKey": { + "label": "Ключ API PageSpeed", + "labelSet": "Ключ API встановлено. Натисніть Скинути, щоб змінити.", + "placeholder": "Введіть Ваш ключ API Google PageSpeed" + } + } + }, + "url": { + "title": "IP/URL монітора на сторінці статусу", + "description": "Відображати IP-адресу або URL монітора на публічній сторінці статусу. Якщо вимкнено, буде показано лише назву монітора для захисту конфіденційної інформації.", + "option": { + "showURL": { + "label": "Відображати IP/URL на сторінці статусу", + "enabled": "Увімкнено", + "disabled": "Вимкнено" + } + } + }, + "stats": { + "title": "Історія моніторингу", + "description": "Очистити всю історію моніторингу та статистику Вашої команди. Ця дія незворотна.", + "option": { + "clear": { + "label": "Очистити всю статистику. Це незворотно." + } + }, + "dialog": { + "title": "Очистити всю історію моніторингу?", + "description": "Це не можна скасувати" + } + }, + "retention": { + "title": "Збереження перевірок", + "description": "Встановіть, як довго зберігаються дані перевірок перед автоматичним очищенням.", + "option": { + "days": { + "label": "Період збереження (дні)", + "unlimited": "Необмежено" + } + } + }, + "thresholds": { + "title": "Глобальні порогові значення", + "description": "Встановіть глобальні порогові значення CPU, пам'яті, диска та температури для моніторингу інфраструктури. Вони застосовуються до всіх моніторів обладнання, якщо не перевизначені.", + "option": { + "cpu": { + "label": "Поріг CPU (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "Поріг пам'яті (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "Поріг диска (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "Поріг температури (°C)", + "placeholder": "1 - 150" + } + } + }, + "email": { + "title": "Налаштування пошти", + "description": "Налаштуйте параметри електронної пошти для Вашої системи. Використовується для надсилання сповіщень та попереджень.", + "descriptionTransport": "", + "descriptionTransportLink": "Дивіться специфікації тут", + "option": { + "host": { + "label": "Хост пошти — ім'я хоста або IP-адреса для підключення", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "Порт пошти — порт для підключення", + "placeholder": "587" + }, + "address": { + "label": "Адреса пошти — використовується для автентифікації", + "placeholder": "you@example.com" + }, + "user": { + "label": "Користувач пошти — ім'я для автентифікації, замінює адресу пошти, якщо вказано", + "placeholder": "Залиште порожнім, якщо не потрібно" + }, + "password": { + "label": "Пароль пошти — пароль для автентифікації", + "labelSet": "Пароль встановлено. Натисніть Скинути, щоб змінити.", + "placeholder": "Введіть Ваш пароль" + }, + "tlsServername": { + "label": "TLS Servername — необов'язкове ім'я хоста для валідації TLS, коли хост є IP", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "Хост з'єднання пошти — ім'я хоста для HELO/EHLO привітання", + "placeholder": "localhost" + }, + "secure": { + "label": "Використовувати SSL (рекомендовано): шифрувати з'єднання за допомогою SSL/TLS" + }, + "pool": { + "label": "Увімкнути пул з'єднань: повторне використання з'єднань для покращення продуктивності" + }, + "ignoreTLS": { + "label": "Вимкнути STARTTLS: не використовувати TLS, навіть якщо сервер його підтримує" + }, + "requireTLS": { + "label": "Примусовий STARTTLS: вимагати оновлення до TLS, помилка якщо не підтримується" + }, + "rejectUnauthorized": { + "label": "Відхиляти недійсні сертифікати: відхиляти з'єднання із самопідписаними або ненадійними сертифікатами" + } + } + }, + "demoMonitors": { + "title": "Демо-монітори", + "description": "Додайте зразки моніторів для демонстрації." + }, + "removeMonitors": { + "title": "Скидання системи", + "description": "Видалити всі монітори з вашої системи.", + "dialog": { + "title": "Видалити всі монітори?", + "description": "Цю дію не можна скасувати.", + "paragraph": "Також будуть видалені вся історія перевірок, інциденти, заплановані вікна обслуговування та сторінки статусу, які використовували ці монітори. Спочатку експортуйте монітори, якщо хочете зберегти резервну копію їхніх даних." + } + }, + "exportMonitors": { + "title": "Експортувати монітори" + }, + "importExportMonitors": { + "title": "Імпорт / Експорт моніторів", + "description": "Імпортуйте або експортуйте дані моніторів у файлі JSON для резервного копіювання або перенесення." + }, + "about": { + "title": "Про додаток", + "developedBy": "Розроблено Bluewave Labs" + }, + "validation": { + "errorMessage": "Будь ласка, виправте такі помилки валідації:" + } + }, + "header": { + "title": "Налаштування", + "description": "Налаштуйте SMTP, інтеграції, зберігання та загальну поведінку додатку." + } + }, + "statusPages": { + "deleteSuccess": "Сторінку статусу успішно видалено", + "fallback": { + "title": "Ще немає сторінок статусу", + "actionButton": "Створити сторінку статусу!", + "description": "Опублікуйте публічну сторінку, що показує клієнтам і зацікавленим сторонам час роботи в реальному часі та історію інцидентів." + }, + "monitorsList": { + "chartTypeHeatmap": "Теплова карта", + "chartTypeHistogram": "Гістограма", + "noData": "Немає доступних даних", + "infrastructure": { + "title": "Інфраструктура", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "Пам'ять", + "disk": "Диск", + "usage": "Використання", + "used": "Використано", + "total": "Усього" + }, + "uptime": { + "title": "Час роботи", + "responseTime": "Час відповіді" + } + }, + "statusBar": { + "allDown": "Усі системи недоступні", + "allUp": "Усі системи працюють", + "degraded": "Деякі системи мають проблеми", + "unknown": "Неможливо визначити стан системи", + "allBreached": "Усі системи перевищили порогові значення", + "allMaintenance": "Усі системи на технічному обслуговуванні", + "allPaused": "Усі системи призупинено", + "allInitializing": "Усі системи ініціалізуються", + "breachedAndDown": "Деякі системи перевищили пороги та недоступні", + "breached": "Деякі системи перевищили пороги", + "maintenanceAndDown": "Деякі системи недоступні під час техобслуговування", + "maintenance": "Деякі системи на техобслуговуванні", + "partiallyPaused": "Деякі системи призупинено", + "initializing": "Деякі системи ініціалізуються", + "noMonitors": "Не налаштовано жодного монітора" + }, + "table": { + "headers": { + "name": "Назва сторінки статусу", + "url": "Публічний URL" + }, + "published": "Опубліковано", + "unpublished": "Неопубліковано" + }, + "title": "Сторінки статусу", + "details": { + "empty": { + "title": "Тут ще нічого немає", + "addMonitor": "Додайте монітор, щоб розпочати" + } + }, + "form": { + "access": { + "title": "Доступ", + "description": "Якщо Ваша сторінка статусу готова, Ви можете позначити її як опубліковану.", + "option": { + "published": { + "name": "Опубліковано та видно для всіх" + } + } + }, + "basicInfo": { + "title": "Основна інформація", + "description": "Визначте назву компанії та піддомен, на який вказує Ваша сторінка статусу.", + "option": { + "name": { + "label": "Назва компанії", + "placeholder": "Acme Inc." + }, + "url": { + "label": "Адреса Вашої сторінки статусу", + "placeholder": "my-status-page" + } + } + }, + "monitors": { + "title": "Монітори", + "description": "Оберіть монітори для відображення на Вашій сторінці статусу.", + "noMonitors": "Монітори не обрані", + "option": { + "monitors": { + "label": "Оберіть монітори", + "placeholder": "Шукати та обрати монітори..." + } + } + }, + "timezone": { + "title": "Часовий пояс", + "description": "Оберіть часовий пояс для відображення Вашої сторінки статусу.", + "option": { + "timezone": { + "label": "Часовий пояс", + "placeholder": "Оберіть часовий пояс..." + } + } + }, + "appearance": { + "title": "Зовнішній вигляд", + "description": "Визначте стандартний вигляд Вашої публічної сторінки статусу.", + "option": { + "logo": { + "label": "Логотип" + }, + "color": { + "label": "Колір бренду" + } + } + }, + "features": { + "title": "Функції", + "description": "Налаштуйте, яка інформація відображається на Вашій сторінці статусу.", + "option": { + "showCharts": { + "label": "Показувати графіки часу відповіді" + }, + "showUptimePercentage": { + "label": "Показувати відсоток часу роботи" + }, + "showAdminLoginLink": { + "label": "Показувати посилання для входу адміністратора" + }, + "showInfrastructure": { + "label": "Показувати метрики інфраструктури" + } + } + } + }, + "header": { + "title": "Сторінки статусу", + "description": "Публікуйте публічні сторінки з часом роботи в реальному часі та історією інцидентів для клієнтів і зацікавлених сторін." + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "Шукати монітори..." + } + }, + "table": { + "headers": { + "responseTime": "Час відповіді" + } + }, + "fallback": { + "actionButton": "Створити монітор!", + "title": "Ще немає моніторів працездатності", + "description": "Спостерігайте за HTTP-кінцевими точками, пінгами, контейнерами та портами — отримуйте сповіщення в момент відмови." + }, + "header": { + "title": "Монітори працездатності", + "description": "Спостерігайте за HTTP-кінцевими точками, пінгами, контейнерами та портами — отримуйте сповіщення в момент відмови." + } } } } diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 22b96487c..92372dec6 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -1,838 +1,1447 @@ { - "submit": "", - "title": "", - "distributedStatusHeaderText": "", - "distributedStatusSubHeaderText": "", - "settingsDisabled": "", - "settingsSuccessSaved": "", - "settingsFailedToSave": "", - "settingsStatsCleared": "", - "settingsFailedToClearStats": "", - "settingsFailedToAddDemoMonitors": "", - "settingsMonitorsDeleted": "", - "settingsFailedToDeleteMonitors": "", - "starPromptTitle": "", - "starPromptDescription": "", - "https": "", - "http": "", - "monitor": "", - "aboutus": "", - "now": "", - "delete": "", - "configure": "", - "responseTime": "", - "ms": "", - "bar": "", - "area": "", - "country": "", - "city": "", - "response": "", - "monitorStatusUp": "", - "monitorStatusDown": "", - "webhookSendSuccess": "", - "webhookSendError": "", - "webhookUnsupportedPlatform": "", - "distributedRightCategoryTitle": "", - "distributedStatusServerMonitors": "", - "distributedStatusServerMonitorsDescription": "", - "distributedUptimeCreateSelectURL": "", - "distributedUptimeCreateChecks": "", - "distributedUptimeCreateChecksDescription": "", - "distributedUptimeCreateIncidentNotification": "", - "distributedUptimeCreateIncidentDescription": "", - "distributedUptimeCreateAdvancedSettings": "", - "distributedUptimeDetailsNoMonitorHistory": "", - "distributedUptimeDetailsStatusHeaderUptime": "", - "distributedUptimeDetailsStatusHeaderLastUpdate": "", - "notifications": { - "enableNotifications": "", - "testNotification": "", - "addOrEditNotifications": "", - "slack": { - "label": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "discord": { - "label": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "", - "webhookRequired": "" - }, - "telegram": { - "label": "", - "description": "", - "tokenLabel": "", - "tokenPlaceholder": "", - "chatIdLabel": "", - "chatIdPlaceholder": "", - "fieldsRequired": "" - }, - "webhook": { - "label": "", - "description": "", - "urlLabel": "", - "urlPlaceholder": "", - "urlRequired": "" - }, - "testNotificationDevelop": "", - "integrationButton": "", - "testSuccess": "", - "testFailed": "", - "unsupportedType": "", - "networkError": "", - "fallback": { - "title": "", - "checks": [""] - }, - "createButton": "", - "createTitle": "", - "create": { - "success": "", - "failed": "" - }, - "fetch": { - "success": "", - "failed": "" - }, - "delete": { - "success": "", - "failed": "" - }, - "edit": { - "success": "", - "failed": "" - }, - "test": { - "success": "", - "failed": "" - } - }, - "testLocale": "", - "add": "", - "monitors": "", - "distributedUptimeStatusCreateStatusPage": "", - "distributedUptimeStatusCreateStatusPageAccess": "", - "distributedUptimeStatusCreateStatusPageReady": "", - "distributedUptimeStatusBasicInfoHeader": "", - "distributedUptimeStatusBasicInfoDescription": "", - "distributedUptimeStatusLogoHeader": "", - "distributedUptimeStatusLogoDescription": "", - "distributedUptimeStatusLogoUploadButton": "", - "distributedUptimeStatusStandardMonitorsHeader": "", - "distributedUptimeStatusStandardMonitorsDescription": "", - "distributedUptimeStatusCreateYour": "", - "distributedUptimeStatusEditYour": "", - "distributedUptimeStatusPublishedLabel": "", - "distributedUptimeStatusCompanyNameLabel": "", - "distributedUptimeStatusPageAddressLabel": "", - "distributedUptimeStatus30Days": "", - "distributedUptimeStatus60Days": "", - "distributedUptimeStatus90Days": "", - "distributedUptimeStatusPageNotSetUp": "", - "distributedUptimeStatusContactAdmin": "", - "distributedUptimeStatusPageNotPublic": "", - "distributedUptimeStatusPageDeleteDialog": "", - "distributedUptimeStatusPageDeleteConfirm": "", - "distributedUptimeStatusPageDeleteDescription": "", - "distributedUptimeStatusDevices": "", - "distributedUptimeStatusUpt": "", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "", - "incidentsTableNoIncidents": "", - "incidentsTablePaginationLabel": "", - "incidentsTableMonitorName": "", - "incidentsTableStatus": "", - "incidentsTableDateTime": "", - "incidentsTableStatusCode": "", - "incidentsTableMessage": "", - "incidentsOptionsHeader": "", - "incidentsOptionsHeaderFilterBy": "", - "incidentsOptionsHeaderFilterAll": "", - "incidentsOptionsHeaderFilterDown": "", - "incidentsOptionsHeaderFilterCannotResolve": "", - "incidentsOptionsHeaderShow": "", - "incidentsOptionsHeaderLastHour": "", - "incidentsOptionsHeaderLastDay": "", - "incidentsOptionsHeaderLastWeek": "", - "incidentsOptionsPlaceholderAllServers": "", - "infrastructureCreateYour": "", - "infrastructureCreateGeneralSettingsDescription": "", - "infrastructureServerRequirement": "", - "infrastructureCustomizeAlerts": "", - "infrastructureAlertNotificationDescription": "", - "infrastructureCreateMonitor": "", - "infrastructureProtocol": "", - "infrastructureServerUrlLabel": "", - "infrastructureDisplayNameLabel": "", - "infrastructureAuthorizationSecretLabel": "", - "gb": "", - "mb": "", - "mem": "", - "memoryUsage": "", - "cpu": "", - "cpuUsage": "", - "cpuTemperature": "", - "diskUsage": "", - "used": "", - "total": "", - "cores": "", - "frequency": "", - "status": "", - "cpuPhysical": "", - "cpuLogical": "", - "cpuFrequency": "", - "avgCpuTemperature": "", - "memory": "", - "disk": "", - "uptime": "", - "os": "", - "host": "", - "actions": "", - "integrations": "", - "integrationsPrism": "", - "integrationsSlack": "", - "integrationsSlackInfo": "", - "integrationsDiscord": "", - "integrationsDiscordInfo": "", - "integrationsZapier": "", - "integrationsZapierInfo": "", - "commonSave": "", - "createYour": "", - "createMonitor": "", - "pause": "", - "resume": "", - "editing": "", - "url": "", - "access": "", - "timezone": "", - "features": "", - "administrator": "", - "loginHere": "", - "displayName": "", - "urlMonitor": "", - "portToMonitor": "", - "websiteMonitoring": "", - "websiteMonitoringDescription": "", - "pingMonitoring": "", - "pingMonitoringDescription": "", - "dockerContainerMonitoring": "", - "dockerContainerMonitoringDescription": "", - "portMonitoring": "", - "portMonitoringDescription": "", - "createMaintenanceWindow": "", - "createMaintenance": "", - "editMaintenance": "", - "maintenanceWindowName": "", - "friendlyNameInput": "", - "friendlyNamePlaceholder": "", - "maintenanceRepeat": "", - "maintenance": "", - "duration": "", - "addMonitors": "", - "window": "", - "cancel": "", - "message": "", - "low": "", - "high": "", - "statusCode": "", - "date&Time": "", - "type": "", - "statusPageName": "", - "publicURL": "", - "repeat": "", - "edit": "", - "createA": "", - "remove": "", - "maintenanceWindowDescription": "", - "startTime": "", - "timeZoneInfo": "", - "monitorsToApply": "", - "nextWindow": "", - "notFoundButton": "", - "pageSpeedConfigureSettingsDescription": "", - "monitorDisplayName": "", - "whenNewIncident": "", - "notifySMS": "", - "notifyEmails": "", - "seperateEmails": "", - "checkFrequency": "", - "chooseGame": "", - "matchMethod": "", - "expectedValue": "", - "deleteDialogTitle": "", - "deleteDialogDescription": "", - "pageSpeedMonitor": "", - "shown": "", - "ago": "", - "companyName": "", - "pageSpeedDetailsPerformanceReport": "", - "pageSpeedDetailsPerformanceReportCalculator": "", - "checkingEvery": "", - "statusPageCreateSettings": "", - "basicInformation": "", - "statusPageCreateBasicInfoDescription": "", - "statusPageCreateSelectTimeZoneDescription": "", - "statusPageCreateAppearanceDescription": "", - "statusPageCreateSettingsCheckboxLabel": "", - "statusPageCreateBasicInfoStatusPageAddress": "", - "statusPageCreateTabsContent": "", - "statusPageCreateTabsContentDescription": "", - "statusPageCreateTabsContentFeaturesDescription": "", - "showCharts": "", - "showUptimePercentage": "", - "removeLogo": "", - "statusPageStatus": "", - "statusPageStatusContactAdmin": "", - "statusPageStatusNotPublic": "", - "statusPageStatusNoPage": "", - "statusPageStatusServiceStatus": "", - "deleteStatusPage": "", - "deleteStatusPageConfirm": "", - "deleteStatusPageDescription": "", - "uptimeCreate": "", - "uptimeCreateJsonPath": "", - "uptimeCreateJsonPathQuery": "", - "maintenanceTableActionMenuDialogTitle": "", - "infrastructureEditYour": "", - "infrastructureEditMonitor": "", - "infrastructureMonitorCreated": "", - "infrastructureMonitorUpdated": "", - "errorInvalidTypeId": "", - "errorInvalidFieldId": "", - "inviteNoTokenFound": "", - "pageSpeedWarning": "", - "pageSpeedLearnMoreLink": "", - "pageSpeedAddApiKey": "", - "update": "", - "invalidFileFormat": "", - "invalidFileSize": "", - "ClickUpload": "", - "DragandDrop": "", - "MaxSize": "", - "SupportedFormats": "", - "FirstName": "", - "LastName": "", - "EmailDescriptionText": "", - "YourPhoto": "", - "PhotoDescriptionText": "", - "save": "", - "DeleteDescriptionText": "", - "DeleteAccountWarning": "", - "DeleteWarningTitle": "", - "bulkImport": { - "title": "", - "selectFileTips": "", - "selectFileDescription": "", - "selectFile": "", - "parsingFailed": "", - "uploadSuccess": "", - "validationFailed": "", - "noFileSelected": "", - "fallbackPage": "" - }, - "DeleteAccountTitle": "", - "DeleteAccountButton": "", - "publicLink": "", - "maskedPageSpeedKeyPlaceholder": "", - "reset": "", - "ignoreTLSError": "", - "tlsErrorIgnored": "", - "ignoreTLSErrorDescription": "", - "createNew": "", - "greeting": { - "prepend": "", - "append": "", - "overview": "" - }, - "roles": { - "superAdmin": "", - "admin": "", - "teamMember": "", - "demoUser": "" - }, - "teamPanel": { - "teamMembers": "", - "filter": { - "all": "", - "member": "" - }, - "inviteTeamMember": "", - "inviteNewTeamMember": "", - "inviteDescription": "", - "email": "", - "selectRole": "", - "inviteLink": "", - "cancel": "", - "noMembers": "", - "getToken": "", - "emailToken": "", - "table": { - "name": "", - "email": "", - "role": "", - "created": "" - } - }, - "monitorState": { - "paused": "", - "resumed": "", - "active": "" - }, - "menu": { - "uptime": "", - "pagespeed": "", - "infrastructure": "", - "incidents": "", - "statusPages": "", - "maintenance": "", - "integrations": "", - "settings": "", - "support": "", - "discussions": "", - "docs": "", - "changelog": "", - "profile": "", - "password": "", - "team": "", - "logOut": "", - "notifications": "", - "logs": "" - }, - "settingsEmailUser": "", - "state": "", - "statusBreadCrumbsStatusPages": "", - "statusBreadCrumbsDetails": "", - "commonSaving": "", - "navControls": "", - "incidentsPageTitle": "", - "passwordPanel": { - "passwordChangedSuccess": "", - "passwordInputIncorrect": "", - "currentPassword": "", - "enterCurrentPassword": "", - "newPassword": "", - "enterNewPassword": "", - "confirmNewPassword": "", - "passwordRequirements": "", - "saving": "" - }, - "emailSent": "", - "failedToSendEmail": "", - "settingsTestEmailSuccess": "", - "settingsTestEmailFailed": "", - "settingsTestEmailFailedWithReason": "", - "settingsTestEmailUnknownError": "", - "statusMsg": { - "paused": "", - "up": "", - "down": "", - "pending": "" - }, - "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "", - "game": "" - }, "common": { - "appName": "", - "monitoringAgentName": "", - "buttons": { - "toggleTheme": "" + "auth": { + "roles": { + "admin": "Quản trị viên", + "demo": "Demo", + "superadmin": "Quản trị viên cấp cao", + "user": "Người dùng" + } }, - "toasts": { - "networkError": "", - "checkConnection": "", - "unknownError": "" + "alerts": { + "pageSpeedApiKey": { + "content": "Cảnh báo: Bạn chưa thêm khóa API Google PageSpeed. Truy cập Cài đặt để thêm. Nếu không có khóa này, trình giám sát PageSpeed sẽ không hoạt động." + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "Chi tiết", + "home": "Trang chủ" + }, + "buttons": { + "addMember": "Thêm thành viên", + "cancel": "Hủy", + "close": "Đóng", + "configure": "Cấu hình", + "confirm": "Xác nhận", + "create": "Tạo", + "delete": "Xóa", + "generateToken": "Tạo token", + "incidents": "Sự cố", + "inviteMember": "Mời thành viên", + "pause": "Tạm dừng", + "resume": "Tiếp tục", + "save": "Lưu", + "sendInvite": "Gửi lời mời", + "test": "Kiểm tra", + "testNotifications": "Kiểm tra thông báo", + "toggleTheme": "Chuyển sáng / tối", + "flushQueue": "Xóa hàng đợi", + "notFound": "Về bảng điều khiển chính", + "resetPassword": "Đặt lại mật khẩu", + "reset": "Đặt lại", + "clear": "Xóa", + "addDemo": "Thêm trình giám sát mẫu", + "removeMonitors": "Xóa trình giám sát", + "sendTestEmail": "Gửi email kiểm tra", + "exportToJSON": "Xuất ra JSON", + "importFromJSON": "Nhập từ JSON", + "clearFilters": "Xóa bộ lọc", + "removeUser": "Xóa người dùng" + }, + "charts": { + "labels": { + "averageResponseTime": "Thời gian phản hồi trung bình", + "downtime": "Thời gian ngừng hoạt động", + "high": "Cao", + "low": "Thấp", + "uptime": "Thời gian hoạt động" + }, + "histogram": { + "avg": "TB: {{value}} ms", + "max": "Tối đa: {{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "Hành động này không thể hoàn tác.", + "title": "Bạn có chắc chắn muốn xóa không?" + } + }, + "labels": { + "active": "Đang hoạt động", + "paused": "tạm dừng", + "na": "N/A", + "resolved": "Đã giải quyết", + "responseTime": "Thời gian phản hồi" + }, + "form": { + "role": { + "option": { + "role": { + "label": "Vai trò" + } + } + }, + "name": { + "option": { + "firstName": { + "label": "Tên", + "placeholder": "Nhập tên của bạn" + }, + "lastName": { + "label": "Họ", + "placeholder": "Nhập họ của bạn" + } + } + }, + "email": { + "option": { + "email": { + "label": "Email", + "placeholder": "you@example.com" + } + } + } + }, + "table": { + "empty": "Không có dữ liệu", + "headers": { + "actions": "Hành động", + "dateTime": "Ngày & giờ", + "message": "Tin nhắn", + "monitor": "Trình giám sát", + "monitorId": "ID trình giám sát", + "name": "Tên", + "status": "Trạng thái", + "type": "Loại", + "url": "Url", + "interval": "Khoảng thời gian", + "active": "Đang hoạt động", + "responseTime": "Thời gian phản hồi" + } } }, - "auth": { - "common": { - "navigation": { - "continue": "", - "back": "" - }, - "inputs": { - "email": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "invalid": "" - } - }, - "password": { - "label": "", - "rules": { - "length": { - "beginning": "", - "highlighted": "" - }, - "special": { - "beginning": "", - "highlighted": "" - }, - "number": { - "beginning": "", - "highlighted": "" - }, - "uppercase": { - "beginning": "", - "highlighted": "" - }, - "lowercase": { - "beginning": "", - "highlighted": "" - }, - "match": { - "beginning": "", - "highlighted": "" - } - }, - "errors": { - "empty": "", - "length": "", - "uppercase": "", - "lowercase": "", - "number": "", - "special": "", - "incorrect": "" - } - }, - "passwordConfirm": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "different": "" - } - }, - "firstName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - }, - "lastName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - } - }, + "components": { + "imageUpload": { + "clickToUpload": "Nhấn để tải lên", + "dragAndDrop": "hoặc kéo và thả", + "supportedFormats": "Định dạng hỗ trợ", + "maxSize": "Kích thước tối đa", + "orDragAndDrop": "hoặc kéo và thả", "errors": { - "validation": "" - }, - "fields": { - "password": { - "errors": { - "incorrect": "" - } - } + "invalidFileSize": "Tệp quá lớn!", + "invalidFileFormat": "Định dạng tệp không được hỗ trợ!" } }, - "login": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "" - }, - "links": { - "forgotPassword": "", - "register": "", - "forgotPasswordLink": "", - "registerLink": "" - }, - "toasts": { - "success": "", - "incorrectPassword": "" - }, - "errors": { - "password": { - "incorrect": "" - } - } + "headerStatusPageControls": { + "publicLink": "Liên kết công khai" }, - "registration": { - "heading": { - "superAdmin": "", - "user": "" - }, - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "" + "headerTimeRange": { + "labels": { + "day": "Ngày", + "month": "Tháng", + "recent": "Gần đây", + "week": "Tuần" }, "description": { - "superAdmin": "", - "user": "" + "recent": "Hiển thị thống kê trong 2 giờ qua.", + "day": "Hiển thị thống kê trong 24 giờ qua.", + "week": "Hiển thị thống kê trong 7 ngày qua.", + "month": "Hiển thị thống kê trong 30 ngày qua." + } + }, + "offlineBanner": { + "serverUnreachable": "Không thể kết nối đến máy chủ", + "retry": "Thử lại", + "retrying": "Đang thử lại...", + "reconnected": "Đã khôi phục kết nối" + }, + "sidebar": { + "menu": { + "uptime": "Thời gian hoạt động", + "pagespeed": "Pagespeed", + "infrastructure": "Hạ tầng", + "notifications": "Thông báo", + "checks": "Kiểm tra", + "incidents": "Sự cố", + "statusPages": "Trang trạng thái", + "maintenance": "Bảo trì", + "logs": "Nhật ký", + "settings": "Cài đặt" }, - "gettingStartedButton": { - "superAdmin": "", - "user": "" + "bottomMenu": { + "support": "Hỗ trợ", + "discussions": "Thảo luận", + "docs": "Tài liệu", + "changelog": "Nhật ký thay đổi" }, - "termsAndPolicies": "", + "accountMenu": { + "profile": "Hồ sơ", + "password": "Mật khẩu", + "team": "Nhóm" + }, + "starPrompt": { + "title": "Gắn sao Checkmate", + "description": "Xem các bản phát hành mới nhất và giúp phát triển cộng đồng trên GitHub" + }, + "authFooter": { + "navControls": "Điều khiển", + "logOut": "Đăng xuất", + "roles": { + "superAdmin": "Quản trị viên cấp cao", + "admin": "Quản trị viên", + "user": "Người dùng", + "demoUser": "Người dùng demo" + } + } + }, + "starPrompt": { + "title": "Gắn sao Checkmate", + "description": "Xem các bản phát hành mới nhất và giúp phát triển cộng đồng trên GitHub" + }, + "pageHeader": { "links": { - "login": "" - }, - "toasts": { - "success": "" - } - }, - "forgotPassword": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "", - "stepFour": "" - }, - "buttons": { - "openEmail": "", - "resetPassword": "" - }, - "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" - }, - "links": { - "login": "", - "resend": "" - }, - "toasts": { - "sent": "", - "emailNotFound": "", - "redirect": "", - "success": "", - "error": "" + "support": "Hỗ trợ", + "discussions": "Thảo luận", + "docs": "Tài liệu", + "changelog": "Nhật ký thay đổi" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "", - "stillUnreachable": "" + "pages": { + "notFound": { + "title": "Ôi không! Bạn đã làm rơi sushi!", + "subtitle": "Either the URL doesn’t exist, or you don’t have access to it." + }, + "account": { + "tabs": { + "profile": "Hồ sơ", + "password": "Mật khẩu", + "team": "Nhóm" }, - "alertBox": "", - "description": "", - "retryButton": { - "default": "", - "processing": "" + "form": { + "name": { + "title": "Tên", + "description": "Cập nhật thông tin cá nhân của bạn" + }, + "photo": { + "title": "Ảnh hồ sơ", + "description": "Tải lên ảnh hồ sơ" + }, + "currentPassword": { + "title": "Mật khẩu hiện tại", + "description": "Nhập mật khẩu hiện tại để xác minh danh tính", + "option": { + "label": "Mật khẩu hiện tại", + "placeholder": "Nhập mật khẩu hiện tại" + } + }, + "newPassword": { + "title": "Mật khẩu mới", + "description": "Chọn mật khẩu mạnh có ít nhất 8 ký tự", + "option": { + "newPassword": { + "label": "Mật khẩu mới", + "placeholder": "Nhập mật khẩu mới" + }, + "confirm": { + "label": "Xác nhận mật khẩu", + "placeholder": "Xác nhận mật khẩu mới" + } + } + }, + "deleteAccount": { + "title": "Xóa tài khoản", + "description": "Hành động này là vĩnh viễn và không thể hoàn tác" + } + }, + "team": { + "filter": { + "placeholder": "Lọc theo vai trò", + "all": "Tất cả vai trò", + "admin": "Quản trị viên", + "member": "Thành viên" + }, + "table": { + "headers": { + "email": "Email", + "role": "Vai trò", + "created": "Đã tạo" + } + }, + "addMember": { + "title": "Đăng ký thành viên mới", + "description": "Tạo tài khoản người dùng mới. Chia sẻ thông tin đăng nhập một cách an toàn sau khi tạo." + }, + "invite": { + "title": "Mời thành viên nhóm", + "description": "Gửi lời mời tham gia nhóm của bạn", + "email": { + "label": "Địa chỉ email", + "placeholder": "Nhập địa chỉ email" + }, + "role": { + "label": "Vai trò", + "placeholder": "Chọn vai trò" + }, + "linkLabel": "Liên kết mời" + }, + "fallback": { + "title": "Chưa có thành viên nhóm", + "description": "Mời đồng đội qua email hoặc thêm họ trực tiếp. Khi họ tham gia, họ sẽ xuất hiện tại đây với vai trò của họ." + } + }, + "header": { + "title": "Tài khoản", + "description": "Quản lý hồ sơ, mật khẩu, đội ngũ và tùy chọn không gian làm việc." } - } - }, - "createNotifications": { - "title": "", - "nameSettings": { - "title": "", - "description": "", - "nameLabel": "", - "namePlaceholder": "" }, - "typeSettings": { - "title": "", - "description": "", - "typeLabel": "" - }, - "emailSettings": { - "title": "", - "description": "", - "emailLabel": "", - "emailPlaceholder": "" - }, - "slackSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "pagerdutySettings": { - "title": "", - "description": "", - "integrationKeyLabel": "", - "integrationKeyPlaceholder": "" - }, - "discordSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "webhookSettings": { - "title": "", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - } - }, - "notificationConfig": { - "title": "", - "description": "" - }, - "monitorStatus": { - "checkingEvery": "", - "withCaptureAgent": "", - "up": "", - "down": "", - "paused": "" - }, - "advancedMatching": "", - "sendTestNotifications": "", - "testNotificationsDisabled": "", - "selectAll": "", - "showAdminLoginLink": "", - "logsPage": { - "title": "", - "description": "", - "tabs": { - "queue": "", - "logs": "" - }, - "toast": { - "fetchLogsSuccess": "" - }, - "logLevelSelect": { - "title": "", - "values": { - "all": "", - "info": "", - "warn": "", - "error": "", - "debug": "" + "auth": { + "brandPanel": { + "eyebrow": "Giám sát hạ tầng mã nguồn mở", + "tagline": "Theo dõi mọi chuyển động của các máy chủ của bạn.", + "description": "Theo dõi thời gian hoạt động, thời gian phản hồi và sự cố theo thời gian thực - tự lưu trữ và thuộc về bạn." + }, + "common": { + "passwordRules": { + "length": { + "beginning": "", + "highlighted": "" + }, + "lowercase": { + "beginning": "", + "highlighted": "" + }, + "match": { + "beginning": "", + "highlighted": "" + }, + "number": { + "beginning": "", + "highlighted": "" + }, + "special": { + "beginning": "", + "highlighted": "" + }, + "uppercase": { + "beginning": "", + "highlighted": "" + } + }, + "form": { + "option": { + "email": { + "label": "Email", + "placeholder": "me@example.com" + }, + "password": { + "label": "Mật khẩu", + "placeholder": "********" + }, + "confirmPassword": { + "label": "Xác nhận mật khẩu" + } + } + } + }, + "login": { + "title": "Chào mừng bạn trở lại Checkmate!", + "subtitle": "Đăng nhập để tiếp tục", + "submit": "Đăng nhập", + "links": { + "forgotPassword": { + "text": "Quên mật khẩu?", + "linkText": "Đặt lại mật khẩu" + }, + "register": { + "text": "Chưa có tài khoản?", + "linkText": "Đăng ký tại đây" + } + } + }, + "register": { + "title": "Chào mừng bạn trở lại Checkmate!", + "subtitle": "Đăng ký để bắt đầu", + "submit": "Đăng ký", + "setupNotice": "Trang này chỉ khả dụng trong quá trình thiết lập ban đầu. Sau khi siêu quản trị viên được đăng ký, có thể mời thêm người dùng từ cài đặt nhóm." + }, + "forgotPassword": { + "title": "Quên mật khẩu?", + "subtitle": "Đừng lo, chúng tôi sẽ gửi hướng dẫn đặt lại mật khẩu cho bạn.", + "submit": "Yêu cầu khôi phục", + "links": { + "login": { + "text": "Quay lại", + "linkText": "đăng nhập" + } + } + }, + "setNewPassword": { + "title": "Đặt lại mật khẩu", + "subtitle": "Mật khẩu mới phải khác với các mật khẩu đã sử dụng trước đó." } - } - }, - "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", - "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "", - "activeHeader": "", - "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "", - "lastRunTookHeader": "" }, - "metricsTable": { - "title": "", - "metricHeader": "", - "valueHeader": "" + "checks": { + "selects": { + "monitor": { + "all": "Tất cả trình giám sát" + }, + "status": { + "all": "Tất cả", + "down": "Ngừng hoạt động", + "up": "Hoạt động" + } + }, + "table": { + "empty": "Không có kiểm tra lỗi trong khoảng thời gian này", + "headers": { + "statusCode": "Mã trạng thái", + "location": "Vị trí" + } + }, + "fallback": { + "title": "Chưa có kiểm tra nào", + "description": "Khi bạn tạo một trình giám sát, mỗi lần thăm dò trạng thái và thời gian phản hồi sẽ xuất hiện tại đây như một kiểm tra." + }, + "header": { + "title": "Kiểm tra", + "description": "Mỗi lần thăm dò mà Checkmate chạy trên trình giám sát của bạn sẽ xuất hiện tại đây với mã trạng thái và thời gian phản hồi." + } }, - "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", - "failedAtHeader": "", - "failReasonHeader": "" - } - }, - "export": { - "title": "", - "success": "", - "failed": "" - }, - "monitorActions": { - "title": "", - "import": "", - "export": "" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "" + "common": { + "monitors": { + "actions": { + "configure": "Cấu hình", + "delete": "Xóa", + "generateToken": "Tạo token", + "details": "Chi tiết", + "incidents": "Sự cố", + "inviteMember": "Mời thành viên", + "openSite": "Mở trang web", + "pause": "Tạm dừng", + "resume": "Tiếp tục", + "selectAll": "Chọn tất cả màn hình", + "selectMonitor": "Chọn {{name}}", + "bulkSelected_one": "Đã chọn {{count}} màn hình", + "bulkSelected_other": "Đã chọn {{count}} màn hình" + }, + "statBoxes": { + "activeFor": "Hoạt động từ", + "certificateExpiry": "Hạn chứng chỉ", + "lastCheck": "Lần kiểm tra cuối", + "lastResponseTime": "Thời gian phản hồi cuối", + "serviceIsDown": "Dịch vụ đang ngừng hoạt động" + }, + "status": { + "down": "ngừng hoạt động", + "breached": "vượt ngưỡng", + "initializing": "đang khởi tạo", + "maintenance": "bảo trì", + "paused": "tạm dừng", + "total": "tổng", + "up": "hoạt động" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "Trò chơi", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "Cổng", + "optionPagespeed": "PageSpeed", + "optionHardware": "Hạ tầng", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "Đã tạm dừng {{count}} màn hình", + "paused_other": "Đã tạm dừng {{count}} màn hình", + "resumed_one": "Đã tiếp tục {{count}} màn hình", + "resumed_other": "Đã tiếp tục {{count}} màn hình", + "alreadyPaused_one": "Màn hình đã chọn đã được tạm dừng", + "alreadyPaused_other": "Tất cả các màn hình đã chọn đã được tạm dừng", + "alreadyRunning_one": "Màn hình đã chọn đã đang chạy", + "alreadyRunning_other": "Tất cả các màn hình đã chọn đã đang chạy" + } + } }, - "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" + "createMonitor": { + "form": { + "advanced": { + "description": "Cài đặt tùy chọn cho các trường hợp sử dụng nâng cao", + "option": { + "advancedMatching": { + "label": "Sử dụng đối sánh nâng cao" + }, + "expectedValue": { + "label": "Giá trị mong đợi" + }, + "jsonPath": { + "description": "Biểu thức này sẽ được đánh giá với dữ liệu JSON phản hồi và kết quả sẽ được dùng để đối sánh với giá trị mong đợi. Xem jmespath.org để biết tài liệu ngôn ngữ truy vấn.", + "label": "Biểu thức JSONPath" + }, + "matchMethod": { + "label": "Phương thức đối sánh", + "equal": "Bằng", + "include": "Bao gồm", + "regex": "Regex" + } + }, + "title": "Cài đặt nâng cao" + }, + "frequency": { + "description": "Bạn muốn kiểm tra trạng thái trình giám sát này bao lâu một lần?", + "option": { + "frequency": { + "label": "Tần suất kiểm tra", + "value": { + "fifteenMinutes": "15 phút", + "fifteenSeconds": "15 giây", + "fiveMinutes": "5 phút", + "fourMinutes": "4 phút", + "oneMinute": "1 phút", + "tenMinutes": "10 phút", + "thirtyMinutes": "30 phút", + "thirtySeconds": "30 giây", + "threeMinutes": "3 phút", + "twoMinutes": "2 phút" + } + } + }, + "title": "Tần suất kiểm tra" + }, + "general": { + "option": { + "container": { + "label": "Tên/ID container", + "placeholder": "my-app hoặc abcd1234" + }, + "host": { + "label": "Máy chủ", + "placeholder": "192.168.1.100 hoặc example.com" + }, + "name": { + "label": "Tên hiển thị", + "placeholder": "vd: Website của tôi" + }, + "secret": { + "label": "Khóa xác thực", + "placeholder": "Nhập khóa bí mật của bạn" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "Chọn trò chơi", + "placeholder": "Chọn một trò chơi" + }, + "port": { + "label": "Cổng cần giám sát", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "Tên dịch vụ", + "placeholder": "vd: my.service.v1 (để trống cho kiểm tra sức khỏe tổng thể)" + }, + "wsUrl": { + "label": "URL WebSocket", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Điểm cuối Capture", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "Cài đặt chung", + "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": "", + "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 mức sử dụng CPU, bộ nhớ, ổ đĩa và nhiệt độ của hạ tầng. URL điểm cuối Capture phải bao gồm đường dẫn đầy đủ /api/v1/metrics (ví dụ: http://192.168.1.10:5001/api/v1/metrics). Đối với khóa xác thực, chỉ dán chuỗi token — không bao gồm 'Bearer'." + } + }, + "ignoreTls": { + "description": "Cấu hình xác thực chứng chỉ TLS/SSL cho kết nối HTTPS.", + "option": { + "tls": { + "label": "Bỏ qua lỗi TLS/SSL" + } + }, + "title": "Cài đặt TLS/SSL" + }, + "incidents": { + "description": "Cửa sổ trượt được sử dụng để xác định khi nào trình giám sát ngừng hoạt động. Trạng thái của trình giám sát chỉ thay đổi khi tỷ lệ kiểm tra trong cửa sổ trượt đạt giá trị được chỉ định.", + "option": { + "checks": { + "label": "Số lần kiểm tra trong cửa sổ trượt" + }, + "percentage": { + "label": "Bao nhiêu phần trăm kiểm tra trong cửa sổ trượt thất bại/thành công trước khi trạng thái trình giám sát thay đổi?" + } + }, + "title": "Sự cố" + }, + "notifications": { + "description": "Chọn các kênh thông báo bạn muốn sử dụng", + "title": "Thông báo" + }, + "type": { + "description": "Chọn loại kiểm tra để thực hiện", + "optionDockerDescription": "Sử dụng Docker để giám sát xem container có đang chạy không.", + "optionGameDescription": "Giám sát xem máy chủ trò chơi cụ thể có trực tuyến không.", + "optionGrpcDescription": "Giám sát dịch vụ gRPC bằng Giao thức Kiểm tra Sức khỏe tiêu chuẩn.", + "optionWebSocketDescription": "Giám sát điểm cuối WebSocket về sức khỏe kết nối và thời gian phản hồi.", + "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" + }, + "thresholds": { + "title": "Ngưỡng cảnh báo", + "description": "Xác định các ngưỡng kích hoạt cảnh báo cho trình giám sát phần cứng này.", + "option": { + "cpuThreshold": { + "label": "Ngưỡng cảnh báo CPU (%)" + }, + "memoryThreshold": { + "label": "Ngưỡng cảnh báo bộ nhớ (%)" + }, + "diskThreshold": { + "label": "Ngưỡng cảnh báo ổ đĩa (%)" + }, + "tempThreshold": { + "label": "Ngưỡng cảnh báo nhiệt độ (°C)" + } + } + }, + "geoChecks": { + "title": "Kiểm tra phân tán địa lý", + "description": "Chạy kiểm tra từ nhiều vị trí địa lý để giám sát tính khả dụng và hiệu suất toàn cầu.", + "option": { + "enabled": { + "label": "Bật kiểm tra phân tán địa lý" + }, + "locations": { + "label": "Vị trí", + "placeholder": "Chọn vị trí", + "options": { + "EU": "Châu Âu", + "NA": "Bắc Mỹ", + "AS": "Châu Á", + "SA": "Nam Mỹ", + "AF": "Châu Phi", + "OC": "Châu Đại Dương" + } + }, + "interval": { + "label": "Khoảng thời gian kiểm tra", + "value": { + "fiveMinutes": "5 phút", + "tenMinutes": "10 phút", + "fifteenMinutes": "15 phút", + "thirtyMinutes": "30 phút" + } + } + } + }, + "url": { + "title": "Giám sát IP/URL trên trang trạng thái", + "description": "Hiển thị địa chỉ IP hoặc URL của trình giám sát trên trang trạng thái công khai. Nếu tắt, chỉ tên trình giám sát được hiển thị để bảo vệ thông tin nhạy cảm.", + "option": { + "showURL": { + "label": "Hiển thị IP/URL trên trang trạng thái", + "enabled": "Đã bật", + "disabled": "Đã tắt" + } + } + }, + "stats": { + "title": "Lịch sử giám sát", + "description": "Xóa tất cả lịch sử giám sát và thống kê cho nhóm của bạn. Hành động này không thể hoàn tác.", + "buttonText": "Xóa tất cả thống kê", + "dialog": { + "title": "Xóa tất cả lịch sử giám sát?", + "description": "Điều này sẽ xóa vĩnh viễn tất cả lịch sử giám sát, thống kê và dữ liệu kiểm tra cho nhóm của bạn. Hành động này không thể hoàn tác.", + "confirm": "Có, xóa tất cả thống kê" + } + } + } }, - "emailSettings": { - "buttonSendTestEmail": "", - "description": "", - "descriptionTransport": "", - "labelAddress": "", - "labelConnectionHost": "", - "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", - "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", - "labelRequireTLS": "", - "labelSecure": "", - "labelTLSServername": "", - "labelUser": "", - "linkTransport": "", - "placeholderUser": "", - "title": "" + "editUser": { + "form": { + "roles": { + "title": "Vai trò", + "description": "Gán vai trò cho người dùng. Có thể chọn nhiều vai trò." + } + }, + "dialog": { + "removeUser": { + "title": "Xóa người dùng", + "content": "Bạn có chắc chắn muốn xóa {{name}} khỏi nhóm không? Hành động này không thể hoàn tác." + } + } }, - "pageSpeedSettings": { - "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "" + "incidents": { + "fallback": { + "title": "Không có sự cố — tất cả hệ thống đều khỏe mạnh", + "description": "Khi một trình giám sát ngừng hoạt động hoặc phục hồi, sự cố sẽ xuất hiện tại đây kèm thời gian, chi tiết trình giám sát và lịch sử giải quyết." + }, + "dialog": { + "resolveIncident": { + "title": "Giải quyết sự cố", + "option": { + "comment": { + "label": "Nhận xét (tùy chọn)", + "placeholder": "Thêm nhận xét về cách giải quyết..." + } + } + }, + "details": { + "analysis": "Phân tích sự cố", + "comment": "Nhận xét:", + "detailsLabel": "Chi tiết", + "downtime": "Thời gian ngừng:", + "message": "Tin nhắn:", + "monitor": "Trình giám sát:", + "overview": "Tổng quan", + "resolutionDetails": "Chi tiết giải quyết", + "resolutionType": "Loại:", + "resolutionTypes": { + "automatic": "Tự động", + "manual": "Thủ công" + }, + "resolve": "Giải quyết sự cố", + "resolvedAt": "Đã giải quyết lúc:", + "resolvedBy": "Giải quyết bởi:", + "startedAt": "Bắt đầu lúc:", + "status": "Trạng thái:", + "statusCode": "Mã trạng thái:", + "timeline": "Dòng thời gian", + "title": "Chi tiết sự cố", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "Tất cả trình giám sát", + "monitor": "Trình giám sát", + "resolutionType": "Loại giải quyết", + "resolutionTypes": { + "manual": "Thủ công", + "automatic": "Tự động", + "all": "Tất cả" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "Sự cố đang xảy ra", + "active_zero": "Không có sự cố đang xảy ra", + "active_one": "{{count}} sự cố đang xảy ra", + "active_other": "{{count}} sự cố đang xảy ra" + }, + "incidentStats": { + "avgResolutionTime": "Thời gian giải quyết TB", + "mostAffectedMonitor": "Trình giám sát bị ảnh hưởng nhiều nhất", + "title": "Thống kê sự cố", + "totalIncidents": "Tổng số sự cố" + }, + "latestIncidents": { + "title": "Sự cố mới nhất" + } + }, + "table": { + "actions": { + "details": "Chi tiết", + "goToMonitor": "Đi đến trình giám sát", + "resolveManually": "Giải quyết thủ công" + }, + "activeIncidents": "Sự cố đang xảy ra", + "headers": { + "endTime": "Thời gian kết thúc", + "resolutionType": "Loại giải quyết", + "startTime": "", + "statusCode": "Mã trạng thái" + }, + "resolvedIncidents": "Sự cố đã giải quyết", + "status": { + "active": "Đang hoạt động", + "resolved": "Đã giải quyết" + } + }, + "header": { + "title": "Sự cố", + "description": "Khi một trình giám sát ngừng hoạt động hoặc phục hồi, sự cố sẽ xuất hiện tại đây kèm thời gian, chi tiết trình giám sát và lịch sử giải quyết." + } }, - "saveButtonLabel": "", - "statsSettings": { - "clearAllStatsButton": "", - "clearAllStatsDescription": "", - "clearAllStatsDialogConfirm": "", - "clearAllStatsDialogDescription": "", - "clearAllStatsDialogTitle": "", - "description": "", - "labelTTL": "", - "labelTTLOptional": "", - "title": "" + "infrastructure": { + "charts": { + "labels": { + "cpu": "Sử dụng CPU", + "disk": "Sử dụng ổ đĩa", + "memory": "Sử dụng bộ nhớ", + "netBytesRecv": "{{name}} - Byte nhận", + "netBytesSent": "{{name}} - Byte gửi", + "netNoTraffic": "Capture đang báo cáo lưu lượng bằng không trên mọi giao diện. Điều này là bình thường khi Capture chạy bên trong một container chỉ có thể thấy các giao diện loopback — hãy chạy Capture trực tiếp trên máy chủ để xem hoạt động mạng thực tế.", + "temp": "Nhiệt độ" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "Tần số tối đa", + "title": "Sử dụng CPU", + "upperLabel": "Tần số hiện tại" + }, + "disk": { + "lowerLabel": "Còn trống", + "title": "Sử dụng ổ đĩa {{idx}}", + "upperLabel": "Đã dùng" + }, + "memory": { + "lowerLabel": "Còn trống", + "title": "Sử dụng bộ nhớ", + "upperLabel": "Đã dùng" + } + }, + "statBoxes": { + "avgCpuTemperature": "", + "cpuFrequency": "", + "cpuLogical": "", + "cpuPhysical": "", + "disk": "Ổ đĩa", + "memory": "Bộ nhớ", + "os": "" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "Ổ đĩa", + "memory": "Bộ nhớ" + } + }, + "tabs": { + "labels": { + "network": "Mạng", + "overview": "Tổng quan" + } + }, + "fallback": { + "actionButton": "Tạo trình giám sát!", + "title": "Chưa có trình giám sát hạ tầng", + "description": "Theo dõi hiệu suất máy chủ, xác định nút thắt cổ chai và đảm bảo độ tin cậy với giám sát hạ tầng theo thời gian thực." + }, + "header": { + "title": "Trình giám sát hạ tầng", + "description": "Theo dõi hiệu suất máy chủ, xác định nút thắt cổ chai và đảm bảo độ tin cậy với giám sát hạ tầng theo thời gian thực." + } }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", - "dialogDescription": "", - "dialogTitle": "", - "title": "" + "logs": { + "fallback": { + "title": "Chưa có nhật ký", + "description": "Hoạt động máy chủ, cảnh báo và lỗi sẽ xuất hiện tại đây khi Checkmate đang chạy." + }, + "tabs": { + "diagnostics": "Chẩn đoán", + "logs": "Nhật ký", + "queue": "Hàng đợi tác vụ" + }, + "logLevelSelect": { + "label": "Mức nhật ký" + }, + "jobQueue": "Hàng đợi công việc", + "jobQueueExplainer": "Các hàng được làm nổi bật là các công việc hiện đang được worker xử lý.", + "failedJobs": "Công việc thất bại", + "failedJobsExplainer": "Các lần thất bại gần đây theo trình giám sát — thời gian thất bại gần nhất, số lần thất bại và lý do.", + "noLogs": "Không tìm thấy nhật ký", + "metrics": { + "jobs": "Công việc", + "activeJobs": "Công việc đang chạy", + "failingJobs": "Công việc đang lỗi", + "totalRuns": "Tổng số lần chạy", + "totalFailures": "Tổng số lỗi" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "Độ trễ vòng lặp sự kiện", + "uptime": "Thời gian hoạt động", + "usedHeapSize": "Kích thước heap đã dùng", + "totalHeapSize": "Tổng kích thước heap", + "osMemoryLimit": "Giới hạn bộ nhớ hệ điều hành" + }, + "gauges": { + "heapAllocation": "Cấp phát heap", + "heapUsage": "Sử dụng heap", + "heapUtilization": "Hiệu suất sử dụng heap", + "instantCpuUsage": "Sử dụng CPU tức thời", + "availableMemoryPercentage": "% bộ nhớ khả dụng", + "allocatedPercentage": "% đã cấp phát", + "usedSPercentage": "% của 1 giây CPU sử dụng", + "total": "Tổng", + "used": "Đã dùng" + }, + "sections": { + "runtimeStats": { + "title": "Thống kê thời gian chạy", + "description": "Ảnh chụp nhanh tiến trình Node.js và máy chủ. Cập nhật mỗi 5 giây." + }, + "memoryCpu": { + "title": "Bộ nhớ & CPU", + "description": "Cách tiến trình sử dụng bộ nhớ được cấp phát và CPU của máy chủ." + } + } + }, + "table": { + "headers": { + "timestamp": "Thời gian", + "level": "Mức", + "service": "Dịch vụ", + "method": "Phương thức", + "monitorId": "ID trình giám sát", + "runCount": "Số lần chạy", + "failCount": "Số lần lỗi", + "lastRunAt": "Chạy lần cuối lúc", + "lockedAt": "Khóa lúc", + "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" + } + }, + "queueFallback": { + "title": "Hàng đợi không có công việc", + "description": "Các kiểm tra trình giám sát đã lên lịch hoặc đang chạy sẽ xuất hiện tại đây. Một hàng đợi trống có nghĩa là mọi thứ đều cập nhật." + }, + "header": { + "title": "Nhật ký & hàng đợi", + "description": "Kiểm tra nhật ký máy chủ, hàng đợi công việc giám sát và chẩn đoán runtime tại một nơi." + } }, - "timezoneSettings": { - "description": "", - "label": "", - "title": "" + "maintenanceWindow": { + "fallback": { + "actionButton": "Tạo cửa sổ bảo trì!", + "title": "Chưa có cửa sổ bảo trì nào được lên lịch", + "description": "Lên lịch thời gian chết để các cảnh báo được tạm dừng và đội ngũ cũng như người dùng biết điều cần mong đợi." + }, + "table": { + "headers": { + "nextWindow": "", + "repeat": "" + } + }, + "form": { + "general": { + "title": "Cài đặt chung", + "description": "Đặt tên và tùy chọn lặp lại cho cửa sổ bảo trì.", + "option": { + "name": { + "label": "Tên", + "placeholder": "vd: Bảo trì hàng tuần" + }, + "repeat": { + "label": "Lặp lại" + } + } + }, + "startDate": { + "title": "Ngày bắt đầu", + "description": "Chọn ngày bắt đầu cho cửa sổ bảo trì.", + "option": { + "startDate": { + "label": "Ngày bắt đầu" + } + } + }, + "startTime": { + "title": "Thời gian bắt đầu", + "description": "Đặt thời gian bắt đầu và thời lượng cho cửa sổ bảo trì. Tất cả giá trị theo UTC", + "option": { + "duration": { + "label": "Thời lượng" + }, + "startTime": { + "label": "Thời gian bắt đầu" + } + }, + "monitors": { + "title": "Trình giám sát", + "description": "Trình giám sát mà cửa sổ bảo trì áp dụng", + "option": { + "addMonitors": { + "label": "Thêm trình giám sát" + } + } + } + } + }, + "header": { + "title": "Cửa sổ bảo trì", + "description": "Lên lịch thời gian chết để các cảnh báo được tạm dừng và đội ngũ cũng như người dùng biết điều cần mong đợi." + } }, - "title": "", - "uiSettings": { - "description": "", - "labelLanguage": "", - "labelTheme": "", - "title": "" + "notifications": { + "fallback": { + "actionButton": "Tạo kênh", + "title": "Chưa có kênh thông báo", + "description": "Kết nối Slack, Discord, email, webhook và nhiều hơn để đúng người nhận được thông báo ngay khi có sự cố." + }, + "form": { + "accessToken": { + "optionAccessToken": "Token truy cập", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "Địa chỉ nơi thông báo sẽ được gửi đến.", + "optionAddress": "Địa chỉ", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "Địa chỉ" + }, + "homeServer": { + "optionHomeServer": "Máy chủ chính", + "placeholder": "example.com" + }, + "matrix": { + "description": "Cấu hình kết nối máy chủ Matrix cho thông báo.", + "title": "Cấu hình Matrix" + }, + "notificationName": { + "description": "Tên mô tả cho kênh thông báo", + "optionName": "Tên kênh", + "placeholder": "vd: Cảnh báo sản xuất", + "title": "Tên kênh" + }, + "pagerDuty": { + "description": "Khóa tích hợp PagerDuty để nhận cảnh báo.", + "optionIntegrationKey": "Khóa tích hợp", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "Khóa tích hợp" + }, + "roomId": { + "optionRoomId": "ID phòng", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "Chọn loại kênh thông báo để tạo.", + "optionType": "Loại", + "title": "Loại kênh" + }, + "telegram": { + "title": "Cấu hình Telegram", + "description": "", + "optionBotToken": "Token bot", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "ID cuộc trò chuyện", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Cấu hình Pushover", + "description": "Nhập mã thông báo ứng dụng và khóa người dùng Pushover của bạn.", + "optionAppToken": "Mã thông báo ứng dụng", + "placeholderAppToken": "Nhập mã thông báo ứng dụng", + "optionUserKey": "Khóa người dùng", + "placeholderUserKey": "Nhập khóa người dùng" + }, + "twilio": { + "title": "Cấu hình Twilio", + "description": "Nhập thông tin xác thực tài khoản Twilio để gửi SMS.", + "optionAccountSid": "SID tài khoản", + "placeholderAccountSid": "Nhập SID tài khoản", + "optionAuthToken": "Mã thông báo xác thực", + "placeholderAuthToken": "Nhập mã thông báo xác thực", + "optionFromNumber": "Số gửi", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "Số nhận", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "Đích đến" + } + }, + "header": { + "title": "Thông báo", + "description": "Kết nối Slack, Discord, email, webhook và nhiều hơn để đúng người nhận được thông báo ngay khi có sự cố." + } }, - "urlSettings": { - "description": "", - "label": "", - "selectDisabled": "", - "selectEnabled": "", - "title": "" + "pageSpeed": { + "charts": { + "common": { + "cls": "Cumulative Layout Shift (CLS)", + "fcp": "First Contentful Paint (FCP)", + "lcp": "Largest Contentful Paint (LCP)", + "si": "Speed Index (SI)", + "tbt": "Total Blocking Time (TBT)" + }, + "legend": { + "title": "Báo cáo PageSpeed", + "weight": "Trọng số" + }, + "pie": { + "title": "Báo cáo hiệu suất" + } + }, + "table": { + "headers": { + "pageSpeedScore": "Điểm PageSpeed" + } + }, + "fallback": { + "actionButton": "Tạo trình giám sát!", + "title": "Chưa có 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." + }, + "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." + } + }, + "settings": { + "form": { + "timezone": { + "title": "Múi giờ hiển thị", + "description": "Chọn múi giờ dùng để hiển thị ngày và giờ trong toàn bộ ứng dụng.", + "option": { + "timezone": { + "label": "Múi giờ hiển thị" + } + } + }, + "ui": { + "title": "Giao diện", + "description": "Chuyển đổi giữa chế độ sáng và tối, thay đổi ngôn ngữ hoặc tùy chỉnh kiểu hiển thị biểu đồ.", + "option": { + "theme": { + "label": "Chế độ giao diện", + "light": "Sáng", + "dark": "Tối" + }, + "language": { + "label": "Ngôn ngữ" + }, + "chartType": { + "label": "Loại biểu đồ", + "histogram": "Biểu đồ cột", + "heatmap": "Bản đồ nhiệt" + } + } + }, + "pagespeed": { + "title": "Khóa API Google PageSpeed", + "description": "Nhập khóa API Google PageSpeed để bật giám sát PageSpeed. Nhấn Đặt lại để cập nhật khóa.", + "option": { + "apiKey": { + "label": "Khóa API PageSpeed", + "labelSet": "Khóa API đã được đặt. Nhấn Đặt lại để thay đổi.", + "placeholder": "Nhập khóa API Google PageSpeed của bạn" + } + } + }, + "url": { + "title": "Giám sát IP/URL trên trang trạng thái", + "description": "Hiển thị địa chỉ IP hoặc URL của trình giám sát trên trang trạng thái công khai. Nếu tắt, chỉ tên trình giám sát được hiển thị để bảo vệ thông tin nhạy cảm.", + "option": { + "showURL": { + "label": "Hiển thị IP/URL trên trang trạng thái", + "enabled": "Đã bật", + "disabled": "Đã tắt" + } + } + }, + "stats": { + "title": "Lịch sử giám sát", + "description": "Xóa tất cả lịch sử giám sát và thống kê cho nhóm của bạn. Hành động này không thể hoàn tác.", + "option": { + "clear": { + "label": "Xóa tất cả thống kê. Hành động này không thể hoàn tác." + } + }, + "dialog": { + "title": "Xóa tất cả lịch sử giám sát?", + "description": "Không thể hoàn tác" + } + }, + "retention": { + "title": "Lưu giữ dữ liệu kiểm tra", + "description": "Đặt thời gian lưu giữ dữ liệu kiểm tra trước khi tự động dọn dẹp.", + "option": { + "days": { + "label": "Thời gian lưu giữ (ngày)", + "unlimited": "Không giới hạn" + } + } + }, + "thresholds": { + "title": "Ngưỡng toàn cục", + "description": "Đặt ngưỡng CPU, Bộ nhớ, Ổ đĩa và Nhiệt độ toàn cục cho giám sát hạ tầng. Áp dụng cho tất cả trình giám sát phần cứng trừ khi bị ghi đè.", + "option": { + "cpu": { + "label": "Ngưỡng CPU (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "Ngưỡng bộ nhớ (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "Ngưỡng ổ đĩa (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "Ngưỡng nhiệt độ (°C)", + "placeholder": "1 - 150" + } + } + }, + "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": "", + "descriptionTransportLink": "Xem thông số kỹ thuật tại đây", + "option": { + "host": { + "label": "Máy chủ email - Tên máy chủ hoặc địa chỉ IP để kết nối", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "Cổng email - Cổng để kết nối", + "placeholder": "587" + }, + "address": { + "label": "Địa chỉ email - Dùng để xác thực", + "placeholder": "you@example.com" + }, + "user": { + "label": "Người dùng email - Tên đăng nhập để xác thực, ghi đè địa chỉ email nếu được chỉ định", + "placeholder": "Để trống nếu không cần" + }, + "password": { + "label": "Mật khẩu email - Mật khẩu để xác thực", + "labelSet": "Mật khẩu đã được đặt. Nhấn Đặt lại để thay đổi.", + "placeholder": "Nhập mật khẩu của bạn" + }, + "tlsServername": { + "label": "Tên máy chủ TLS - Tên máy chủ tùy chọn để xác thực TLS khi host là IP", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "Máy chủ kết nối email - Tên máy chủ dùng trong lời chào HELO/EHLO", + "placeholder": "localhost" + }, + "secure": { + "label": "Sử dụng SSL (khuyến nghị): Mã hóa kết nối bằng SSL/TLS" + }, + "pool": { + "label": "Bật gộp kết nối: Tái sử dụng kết nối hiện có để cải thiện hiệu suất" + }, + "ignoreTLS": { + "label": "Tắt STARTTLS: Không sử dụng TLS ngay cả khi máy chủ hỗ trợ" + }, + "requireTLS": { + "label": "Bắt buộc STARTTLS: Yêu cầu nâng cấp TLS, thất bại nếu không hỗ trợ" + }, + "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" + } + } + }, + "demoMonitors": { + "title": "Trình giám sát mẫu", + "description": "Thêm trình giám sát mẫu cho mục đích trình diễn." + }, + "removeMonitors": { + "title": "Đặt lại hệ thống", + "description": "Xóa tất cả các monitor khỏi hệ thống của bạn.", + "dialog": { + "title": "Xóa tất cả trình giám sát?", + "description": "Hành động này không thể hoàn tác.", + "paragraph": "Toàn bộ lịch sử kiểm tra, sự cố, cửa sổ bảo trì đã lên lịch và các trang trạng thái sử dụng những trình giám sát này cũng sẽ bị xóa. Hãy xuất trình giám sát trước nếu bạn muốn giữ một bản sao lưu dữ liệu." + } + }, + "exportMonitors": { + "title": "Xuất trình giám sát" + }, + "importExportMonitors": { + "title": "Nhập / Xuất trình giám sát", + "description": "Nhập hoặc xuất dữ liệu trình giám sát dưới dạng tệp JSON để sao lưu hoặc chuyển giao." + }, + "about": { + "title": "Giới thiệu", + "developedBy": "Phát triển bởi Bluewave Labs" + }, + "validation": { + "errorMessage": "Vui lòng sửa các lỗi xác thực sau:" + } + }, + "header": { + "title": "Cài đặt", + "description": "Cấu hình SMTP, tích hợp, lưu trữ và hành vi ứng dụng toàn cục." + } + }, + "statusPages": { + "deleteSuccess": "Đã xóa trang trạng thái thành công", + "fallback": { + "title": "Chưa có trang trạng thái", + "actionButton": "Tạo trang trạng thái!", + "description": "Công bố một 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ố." + }, + "monitorsList": { + "chartTypeHeatmap": "Bản đồ nhiệt", + "chartTypeHistogram": "Biểu đồ cột", + "noData": "Không có dữ liệu", + "infrastructure": { + "title": "Hạ tầng", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "Bộ nhớ", + "disk": "Ổ đĩa", + "usage": "Sử dụng", + "used": "Đã dùng", + "total": "Tổng" + }, + "uptime": { + "title": "Thời gian hoạt động", + "responseTime": "Thời gian phản hồi" + } + }, + "statusBar": { + "allDown": "Tất cả hệ thống đều ngừng hoạt động", + "allUp": "Tất cả hệ thống đang hoạt động", + "degraded": "Một số hệ thống đang gặp sự cố", + "unknown": "Không thể xác định trạng thái hệ thống", + "allBreached": "Tất cả hệ thống đã vượt ngưỡng", + "allMaintenance": "Tất cả hệ thống đang bảo trì", + "allPaused": "Tất cả hệ thống đã tạm dừng", + "allInitializing": "Tất cả hệ thống đang khởi tạo", + "breachedAndDown": "Một số hệ thống đã vượt ngưỡng và đang ngừng hoạt động", + "breached": "Một số hệ thống đã vượt ngưỡng", + "maintenanceAndDown": "Một số hệ thống ngừng hoạt động trong quá trình bảo trì", + "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" + }, + "table": { + "headers": { + "name": "Tên trang trạng thái", + "url": "URL công khai" + }, + "published": "Đã xuất bản", + "unpublished": "Chưa xuất bản" + }, + "title": "Trang trạng thái", + "details": { + "empty": { + "title": "Chưa có gì ở đây", + "addMonitor": "Thêm trình giám sát để bắt đầu" + } + }, + "form": { + "access": { + "title": "Truy cập", + "description": "Nếu trang trạng thái đã sẵn sàng, bạn có thể đánh dấu là đã xuất bản.", + "option": { + "published": { + "name": "Đã xuất bản và hiển thị công khai" + } + } + }, + "basicInfo": { + "title": "Thông tin cơ bản", + "description": "Xác định tên công ty và tên miền phụ mà trang trạng thái trỏ đến.", + "option": { + "name": { + "label": "Tên công ty", + "placeholder": "Acme Inc." + }, + "url": { + "label": "Địa chỉ trang trạng thái", + "placeholder": "my-status-page" + } + } + }, + "monitors": { + "title": "Trình giám sát", + "description": "Chọn trình giám sát hiển thị trên trang trạng thái.", + "noMonitors": "Chưa chọn trình giám sát", + "option": { + "monitors": { + "label": "Chọn trình giám sát", + "placeholder": "Tìm kiếm và chọn trình giám sát..." + } + } + }, + "timezone": { + "title": "Múi giờ", + "description": "Chọn múi giờ hiển thị cho trang trạng thái.", + "option": { + "timezone": { + "label": "Múi giờ", + "placeholder": "Chọn múi giờ..." + } + } + }, + "appearance": { + "title": "Giao diện", + "description": "Xác định giao diện mặc định cho trang trạng thái công khai.", + "option": { + "logo": { + "label": "Logo" + }, + "color": { + "label": "Màu thương hiệu" + } + } + }, + "features": { + "title": "Tính năng", + "description": "Cấu hình thông tin hiển thị trên trang trạng thái.", + "option": { + "showCharts": { + "label": "Hiển thị biểu đồ thời gian phản hồi" + }, + "showUptimePercentage": { + "label": "Hiển thị phần trăm thời gian hoạt động" + }, + "showAdminLoginLink": { + "label": "Hiển thị liên kết đăng nhập quản trị" + }, + "showInfrastructure": { + "label": "Hiển thị số liệu hạ tầ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ố." + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "Tìm kiếm trình giám sát..." + } + }, + "table": { + "headers": { + "responseTime": "Thời gian phản hồi" + } + }, + "fallback": { + "actionButton": "Tạo trình giám sát!", + "title": "Chưa có trình giám sát uptime", + "description": "Theo dõi các endpoint HTTP, ping, container và cổng — nhận cảnh báo ngay khi có sự cố." + }, + "header": { + "title": "Trình giám sát uptime", + "description": "Theo dõi các endpoint HTTP, ping, container và cổng — nhận cảnh báo ngay khi có sự cố." + } } } } diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 67b419445..7b369e9b8 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -1,1132 +1,1447 @@ { - "submit": "提交", - "title": "标题", - "distributedStatusHeaderText": "实时、真实设备覆盖", - "distributedStatusSubHeaderText": "在全球数百万设备的支持下,按全球区域、国家或城市查看系统性能", - "settingsDisabled": "禁用", - "settingsSuccessSaved": "设置保存成功", - "settingsFailedToSave": "保存设置失败", - "settingsStatsCleared": "统计数据已成功清除", - "settingsFailedToClearStats": "清除统计数据失败", - "settingsMonitorsDeleted": "已成功删除所有监视器", - "settingsFailedToDeleteMonitors": "删除所有监视器失败", - "starPromptTitle": "打星Checkmate", - "starPromptDescription": "在 GitHub 上查看最新版本,并帮助社区发展壮大", - "https": "HTTPS", - "http": "HTTP", - "monitor": "监控", - "aboutus": "关于我们", - "now": "现在", - "delete": "删除", - "configure": "配置", - "responseTime": "响应时间", - "ms": "毫秒", - "bar": "条", - "area": "区域", - "country": "国家", - "city": "城市", - "response": "响应", - "monitorStatusUp": "监控目标 {name} ({url}) 现在处于正常状态并正在响应", - "monitorStatusDown": "监控目标 {name} ({url}) 已下线且无响应", - "webhookSendSuccess": "Webhook通知发送成功", - "webhookSendError": "向 {platform} 发送 Webhook 通知时出错", - "webhookUnsupportedPlatform": "不支持的平台:{platform}", - "distributedRightCategoryTitle": "监控", - "distributedStatusServerMonitors": "服务器监控", - "distributedStatusServerMonitorsDescription": "监控相关服务器的状态", - "distributedUptimeCreateSelectURL": "在这里,您可以选择主机的URL以及监控类型。", - "distributedUptimeCreateChecks": "分布式正常运行时间创建检查", - "distributedUptimeCreateChecksDescription": "在添加站点之后,您随时可以增加或删除检查项。", - "distributedUptimeCreateIncidentNotification": "事件通知", - "distributedUptimeCreateIncidentDescription": "当发生事件时,通知用户。", - "distributedUptimeCreateAdvancedSettings": "高级设置", - "distributedUptimeDetailsNoMonitorHistory": "此监控器尚未有检查历史记录。", - "distributedUptimeDetailsStatusHeaderUptime": "运行时间:", - "distributedUptimeDetailsStatusHeaderLastUpdate": "最后更新于", - "notifications": { - "enableNotifications": "启用 {{platform}} 通知", - "testNotification": "测试通知", - "addOrEditNotifications": "添加或修改通知方式", - "slack": { - "label": "Slack", - "description": "要启用 Slack 通知,请先创建一个 Slack 应用并启用传入的 Webhook。之后,只需在此处提供 Webhook URL 即可。", - "webhookLabel": "Webhook 网址", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "需要 Slack Webhook URL" - }, - "discord": { - "label": "Discord", - "description": "要通过 Discord 通知将数据从 Checkmate 发送到 Discord 频道,可以使用 Discord 的入站 WebHooks 功能。", - "webhookLabel": "Discord 接收网址", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "需要 Discord Webhook URL" - }, - "telegram": { - "label": "Telegram", - "description": "要启用 Telegram 通知,请使用 BotFather创建一个 Telegram 机器人。然后,获取 API 令牌和聊天 ID 并在此处填写。", - "tokenLabel": "你的机器人token", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "你的Chat ID", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "需要 Telegram 令牌和聊天 ID" - }, - "webhook": { - "label": "Webhooks", - "description": "你可以设置自定义的 Webhook,以便在事件发生时接收通知。", - "urlLabel": "Webhook URL", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "Webhook URL 是必填项" - }, - "testNotificationDevelop": "测试通知 2", - "integrationButton": "通知集成", - "testSuccess": "测试通知发送成功!", - "testFailed": "测试通知发送失败", - "unsupportedType": "不支持的通知类型", - "networkError": "发生网络错误", - "fallback": { - "title": "通知通道用于:", - "checks": [ - "提醒团队有关停机时间或性能问题", - "让工程师知道事件发生时", - "让管理员了解系统更改" - ], - "actionButton": "让我们创建您的第一个通知渠道!" - }, - "createButton": "创建通知渠道", - "createTitle": "通知渠道", - "create": { - "success": "通知创建成功", - "failed": "通知创建失败" - }, - "fetch": { - "success": "成功获取通知", - "failed": "无法获取通知" - }, - "delete": { - "success": "通知已成功删除", - "failed": "删除通知失败" - }, - "edit": { - "success": "通知已成功更新", - "failed": "无法更新通知" - }, - "test": { - "success": "测试通知发送成功", - "failed": "测试通知发送失败" - } - }, - "testLocale": "测试区域设置", - "add": "添加", - "monitors": "监视器", - "distributedUptimeStatusCreateStatusPage": "状态页", - "distributedUptimeStatusCreateStatusPageAccess": "访问", - "distributedUptimeStatusCreateStatusPageReady": "如果状态页面准备就绪,您可以标记为已发布。", - "distributedUptimeStatusBasicInfoHeader": "基础信息", - "distributedUptimeStatusBasicInfoDescription": "定义公司名称和状态页指向的子域名。", - "distributedUptimeStatusLogoHeader": "Logo", - "distributedUptimeStatusLogoDescription": "为您的状态页面上传一个 Logo", - "distributedUptimeStatusLogoUploadButton": "上传 Logo", - "distributedUptimeStatusStandardMonitorsHeader": "标准监视器", - "distributedUptimeStatusStandardMonitorsDescription": "将标准监视器附加到您的状态页面。", - "distributedUptimeStatusCreateYour": "创建您的", - "distributedUptimeStatusEditYour": "编辑您的", - "distributedUptimeStatusPublishedLabel": "发布并公开可见", - "distributedUptimeStatusCompanyNameLabel": "公司名称", - "distributedUptimeStatusPageAddressLabel": "您的状态页面地址", - "distributedUptimeStatus30Days": "30 天", - "distributedUptimeStatus60Days": "60 天", - "distributedUptimeStatus90Days": "90 天", - "distributedUptimeStatusPageNotSetUp": "未设置状态页面。", - "distributedUptimeStatusContactAdmin": "请联系您的管理员", - "distributedUptimeStatusPageNotPublic": "此状态页面不公开。", - "distributedUptimeStatusPageDeleteDialog": "你想删除这个状态页吗?", - "distributedUptimeStatusPageDeleteConfirm": "确定,删除状态页", - "distributedUptimeStatusPageDeleteDescription": "删除后将无法恢复您的状态页面。", - "distributedUptimeStatusDevices": "设备", - "distributedUptimeStatusUpt": "UPT", - "distributedUptimeStatusUptBurned": "分布式正常运行时间状态运行已完成", - "distributedUptimeStatusUptLogo": "分布式UptimeStatusUptLogo", - "incidentsTableNoIncidents": "无事件记录", - "incidentsTablePaginationLabel": "事件", - "incidentsTableMonitorName": "监控名称", - "incidentsTableStatus": "状态", - "incidentsTableDateTime": "日期&时间", - "incidentsTableStatusCode": "状态码", - "incidentsTableMessage": "信息", - "incidentsOptionsHeader": "事件:", - "incidentsOptionsHeaderFilterBy": "筛选:", - "incidentsOptionsHeaderFilterAll": "全部", - "incidentsOptionsHeaderFilterDown": "离线", - "incidentsOptionsHeaderFilterCannotResolve": "无法解析", - "incidentsOptionsHeaderShow": "显示:", - "incidentsOptionsHeaderLastHour": "过去一小时", - "incidentsOptionsHeaderLastDay": "过去一天", - "incidentsOptionsHeaderLastWeek": "过去一周", - "incidentsOptionsPlaceholderAllServers": "所有服务器", - "infrastructureCreateYour": "创建你的", - "infrastructureCreateGeneralSettingsDescription": "在这里您可以选择主机的 URL,以及连接到服务器代理时使用的友好名称和授权密钥。", - "infrastructureServerRequirement": "你正在监控的服务器必须运行在", - "infrastructureCustomizeAlerts": "自定义告警", - "infrastructureAlertNotificationDescription": "当阈值超过指定百分比时向用户(们)发送通知。", - "infrastructureCreateMonitor": "创建基础设施监控", - "infrastructureProtocol": "协议", - "infrastructureServerUrlLabel": "服务器 URL", - "infrastructureDisplayNameLabel": "显示名称", - "infrastructureAuthorizationSecretLabel": "授权密钥", - "gb": "GB", - "mb": "MB", - "mem": "内存", - "memoryUsage": "内存使用率", - "cpu": "CPU", - "cpuUsage": "CPU 使用率", - "cpuTemperature": "CPU 温度", - "diskUsage": "硬盘使用率", - "used": "已使用", - "total": "总共", - "cores": "核心", - "frequency": "频率", - "status": "状态", - "cpuPhysical": "CPU(物理核心)", - "cpuLogical": "CPU(逻辑核心)", - "cpuFrequency": "CPU 频率", - "avgCpuTemperature": "CPU 平均温度", - "memory": "内存", - "disk": "硬盘", - "uptime": "运行时间", - "os": "系统", - "host": "主机", - "actions": "动作", - "integrations": "继承", - "integrationsPrism": "将 Prism 连接到您喜欢的服务。", - "integrationsSlack": "集成Slack", - "integrationsSlackInfo": "集成SlackInfo", - "integrationsDiscord": "集成Discord", - "integrationsDiscordInfo": "集成Discord信息", - "integrationsZapier": "集成Zapier", - "integrationsZapierInfo": "集成ZapierInfo", - "commonSave": "保存", - "createYour": "创建你的", - "createMonitor": "创建监控", - "pause": "暂停", - "resume": "恢复", - "editing": "编辑中...", - "url": "网址", - "access": "访问", - "timezone": "时区", - "features": "特征", - "administrator": "管理员?", - "loginHere": "点击此处登录", - "displayName": "显示名称", - "urlMonitor": "监控的 URL", - "portToMonitor": "监控端口", - "websiteMonitoring": "网站监控", - "websiteMonitoringDescription": "使用 HTTP(s)监控您的网站或 API 端点。", - "pingMonitoring": "ping监控", - "pingMonitoringDescription": "检查您的服务器是否可用。", - "dockerContainerMonitoring": "Docker容器监控", - "dockerContainerMonitoringDescription": "检查你的 Docker 容器是否正在运行。", - "portMonitoring": "端口监控", - "portMonitoringDescription": "检查你的端口是否已打开。", - "createMaintenanceWindow": "创建维护窗口", - "createMaintenance": "创建维护", - "editMaintenance": "编辑维护", - "maintenanceWindowName": "维护窗口名称", - "friendlyNameInput": "友好名称", - "friendlyNamePlaceholder": "维护时间于 __:__ ,持续 ___ 分钟", - "maintenanceRepeat": "维护重复", - "maintenance": "维护", - "duration": "期间", - "addMonitors": "添加监视器", - "window": "窗口", - "cancel": "取消", - "message": "信息", - "low": "低的", - "high": "高的", - "statusCode": "状态码", - "date&Time": "日期&时间", - "type": "类型", - "statusPageName": "状态页名称", - "publicURL": "公共 URL", - "repeat": "重复", - "edit": "编辑", - "createA": "创建A", - "remove": "移除", - "maintenanceWindowDescription": "在维护时段内,所选监视器的所有监视都将暂停。不会执行任何网络检查,从而阻止触发任何状态更新或通知。您的显示器将冻结在其最后的已知状态,并且状态页面将显示维护指示器。维护时段结束后,监控会自动恢复,如果检测到问题,将触发警报。维护期不计入正常运行时间计算。", - "startTime": "开始时间", - "timeZoneInfo": "所有日期和时间均为 GMT+0 时区。", - "monitorsToApply": "监控器应用", - "nextWindow": "下一个窗口", - "notFoundButton": "未找到按钮", - "pageSpeedConfigureSettingsDescription": "您可以在此处选择主机的 URL 以及监视器类型。", - "monitorDisplayName": "监视器显示名称", - "whenNewIncident": "当新事件发生时", - "notifySMS": "通过短信通知(即将推出)", - "notifyEmails": "还通过电子邮件通知多个地址(即将推出)", - "seperateEmails": "单独的电子邮件", - "checkFrequency": "检查频率", - "matchMethod": "match方法", - "expectedValue": "预期值", - "deleteDialogTitle": "确定要删除此监视器吗?", - "deleteDialogDescription": "一旦删除,将无法检索此监视器。", - "pageSpeedMonitor": "页面速度监控器", - "shown": "显示", - "ago": "前", - "companyName": "公司名称", - "pageSpeedDetailsPerformanceReport": "值是估计值,可能会有所不同。", - "pageSpeedDetailsPerformanceReportCalculator": "页面速度详情性能报告计算器", - "checkingEvery": "检查每一个", - "statusPageCreateSettings": "如果您的状态页面已准备就绪,您可以将其标记为已发布。", - "basicInformation": "基础信息", - "statusPageCreateBasicInfoDescription": "定义公司名称和状态页面指向的子域。", - "statusPageCreateSelectTimeZoneDescription": "选择状态页面的显示时区。", - "statusPageCreateAppearanceDescription": "定义公共状态页面的默认外观。", - "statusPageCreateSettingsCheckboxLabel": "已发布并向公众可见", - "statusPageCreateBasicInfoStatusPageAddress": "您的状态页面地址", - "statusPageCreateTabsContent": "状态页服务器", - "statusPageCreateTabsContentDescription": "您可以将监视的任意数量的服务器添加到状态页面。您还可以重新排序它们以获得最佳观看体验。", - "statusPageCreateTabsContentFeaturesDescription": "在状态页上显示更多详细信息", - "showCharts": "显示图表", - "showUptimePercentage": "显示正常运行时间百分比", - "removeLogo": "移除Logo", - "statusPageStatus": "未设置公开状态页面。", - "statusPageStatusContactAdmin": "请联系您的管理员", - "statusPageStatusNotPublic": "此状态页面不公开。", - "statusPageStatusNoPage": "这里没有状态页面。", - "statusPageStatusServiceStatus": "服务状态", - "deleteStatusPage": "要删除此状态页面吗?", - "deleteStatusPageConfirm": "是,删除状态页", - "deleteStatusPageDescription": "一旦删除,您的状态页面将无法检索。", - "uptimeCreate": "期望值用于与响应结果进行匹配,匹配确定状态。", - "uptimeCreateJsonPath": "将根据响应 JSON 数据评估此表达式,并将结果用于与预期值进行匹配。看", - "uptimeCreateJsonPathQuery": "查询语言文档。", - "maintenanceTableActionMenuDialogTitle": "您真的想删除此维护窗口吗?", - "infrastructureEditYour": "编辑您的", - "infrastructureEditMonitor": "保存基础架构监视器", - "infrastructureMonitorCreated": "基础设施监控创建成功!", - "infrastructureMonitorUpdated": "基础设施监控更新成功!", - "errorInvalidTypeId": "提供的通知类型无效", - "errorInvalidFieldId": "提供的字段 ID 无效", - "inviteNoTokenFound": "未找到邀请令牌", - "pageSpeedWarning": "警告:您尚未添加 Google PageSpeed API 密钥。没有它,PageSpeed 监视器将无法运行。", - "pageSpeedLearnMoreLink": "点击这里", - "pageSpeedAddApiKey": "以添加您的 API 密钥。", - "update": "更新", - "invalidFileFormat": "不支持的文件格式!", - "invalidFileSize": "文件太大了!", - "ClickUpload": "点击上传", - "DragandDrop": "拖放", - "MaxSize": "最大尺寸", - "SupportedFormats": "支持的格式", - "FirstName": "名", - "LastName": "姓", - "EmailDescriptionText": "这是您当前的电子邮件地址 - 无法更改。", - "YourPhoto": "个人资料照片", - "PhotoDescriptionText": "这张照片将显示在您的个人资料页面中。", - "save": "保存", - "DeleteDescriptionText": "这将从服务器中删除帐户和所有关联数据。这是不可逆的。", - "DeleteAccountWarning": "删除您的帐户意味着您将无法再次登录,并且您的所有数据都将被删除。这是不可逆的。", - "DeleteWarningTitle": "真的删除这个帐户?", - "bulkImport": { - "title": "批量导入", - "selectFileTips": "选择要上传的 CSV 文件", - "selectFileDescription": "您可以下载我们的或示例", - "selectFile": "选择文件", - "parsingFailed": "解析失败", - "uploadSuccess": "监视器创建成功!", - "validationFailed": "验证失败", - "noFileSelected": "未选择文件", - "fallbackPage": "导入文件以批量上传服务器列表", - "invalidFileType": "文件类型无效", - "uploadFailed": "上传失败" - }, - "DeleteAccountTitle": "删除账户", - "DeleteAccountButton": "删除帐户", - "publicLink": "公共链接", - "maskedPageSpeedKeyPlaceholder": "*************************************", - "reset": "重置", - "ignoreTLSError": "忽略 TLS/SSL 错误", - "tlsErrorIgnored": "已忽略 TLS/SSL 错误", - "ignoreTLSErrorDescription": "忽略 TLS/SSL 错误并继续检查网站可用性", - "createNew": "新建", - "greeting": { - "prepend": "你好", - "append": "下午时间,尽情发挥!", - "overview": "以下是您的 {{type}} 监视器的概述。" - }, - "roles": { - "superAdmin": "超级管理员", - "admin": "管理", - "teamMember": "团队成员", - "demoUser": "演示用户" - }, - "teamPanel": { - "teamMembers": "团队成员", - "filter": { - "all": "全部", - "member": "成员" - }, - "inviteTeamMember": "邀请新团队成员", - "inviteNewTeamMember": "邀请新团队成员", - "inviteDescription": "当您添加新的团队成员时,他们将可以访问所有监视器。", - "email": "电子邮件", - "selectRole": "选择角色", - "inviteLink": "邀请链接", - "cancel": "取消", - "noMembers": "没有具有此角色的团队成员", - "getToken": "获取Token", - "emailToken": "电子邮件令牌", - "table": { - "name": "名字", - "email": "邮箱", - "role": "角色", - "created": "创建" - }, - "addTeamMember": { - "addMemberMenu": "添加团队成员", - "title": "注册新团队成员", - "description": "创建一个新用户并与他们共享凭据。此方法使成员能够立即访问所有监视器。", - "addButton": "添加成员" - }, - "register": "注册团队成员", - "registerToast": { - "success": "用户创建,安全地与成员共享凭据。", - "dbUserExists": "用户已存在。", - "unknownError": "发生未知错误。" - }, - "registerTeamMember": { - "title": "注册团队成员", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "请输入姓名", - "pattern": "名称只能包含字母、空格、撇号或连字符" - } - }, - "lastName": { - "errors": { - "empty": "请输入姓氏", - "pattern": "姓氏只能包含字母、空格、撇号或连字符" - } - }, - "email": { - "errors": { - "empty": "要继续,请输入电子邮件地址", - "invalid": "请重新检查输入的电子邮件地址的有效性" - } - }, - "role": { - "errors": { - "empty": "角色是必需的" - } - } - } - } - } - }, - "role": "角色", - "changeTeamPassword": { - "changePasswordMenu": "", - "title": "", - "description": "", - "success": "" - } - }, - "monitorState": { - "paused": "暂停", - "resumed": "恢复", - "active": "已激活" - }, - "menu": { - "uptime": "上线时间", - "pagespeed": "页面速度", - "infrastructure": "基础设施", - "incidents": "事件", - "statusPages": "状态页", - "maintenance": "维护", - "integrations": "集成", - "settings": "设置", - "support": "支持", - "discussions": "讨论", - "docs": "文档", - "changelog": "变更日志", - "profile": "用户配置", - "password": "密码", - "team": "团队", - "logOut": "登出", - "notifications": "通知", - "logs": "日志" - }, - "settingsEmailUser": "电子邮件用户 - 用于身份验证的用户名,如果指定,则覆盖电子邮件地址", - "state": "状态", - "statusBreadCrumbsStatusPages": "状态页", - "statusBreadCrumbsDetails": "详情", - "commonSaving": "保存中...", - "navControls": "控制", - "incidentsPageTitle": "事件页面标题", - "passwordPanel": { - "passwordChangedSuccess": "您的密码已成功更改。", - "passwordInputIncorrect": "您的密码输入错误。", - "currentPassword": "当前密码", - "enterCurrentPassword": "输入当前密码", - "newPassword": "新密码", - "enterNewPassword": "输入新密码", - "confirmNewPassword": "确认新密码", - "passwordRequirements": "新密码必须至少包含 8 个字符,并且必须至少包含一个大写字母、一个小写字母、一个数字和一个特殊字符。", - "saving": "保存..." - }, - "emailSent": "电子邮件已发送", - "failedToSendEmail": "电子邮件发送失败", - "settingsTestEmailSuccess": "设置测试电子邮件成功", - "settingsTestEmailFailed": "发送测试电子邮件失败", - "settingsTestEmailFailedWithReason": "发送测试电子邮件失败: {{reason}}", - "settingsTestEmailUnknownError": "未知错误", - "statusMsg": { - "paused": "监视已暂停。", - "up": "您的网站已上线。", - "down": "您的网站已下线。", - "pending": "等待中..." - }, - "uptimeGeneralInstructions": { - "http": "输入要监控的 URL 或 IP(例如.. https://example.com/ or 192.168.1.100)并添加仪表板上显示的清晰显示名称。", - "ping": "输入要 ping 的 IP 地址或主机名(例如.. 192.168.1.100 或 example.com),并添加显示在仪表板上的清晰显示名称。", - "docker": "码输入 Docker 容器名称或 ID。您可以使用容器名称(例如.. my-app)或容器 ID(完整的 64 字符 ID 或短 ID)。", - "port": "输入服务器的 URL 或 IP、端口号和仪表板上显示的清晰显示名称。", - "game": "输入要 ping 的 IP 地址或主机名和端口号(例如.. 192.168.1.100 或 example.com)并选择游戏类型。", - "https": "" - }, "common": { - "appName": "Checkmate", - "monitoringAgentName": "捕获", - "buttons": { - "toggleTheme": "切换主题明&暗" + "auth": { + "roles": { + "admin": "管理员", + "demo": "演示", + "superadmin": "超级管理员", + "user": "用户" + } }, - "toasts": { - "networkError": "网络错误", - "checkConnection": "请检查你的网络连接", - "unknownError": "未知错误" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "继续", - "back": "返回" + "alerts": { + "pageSpeedApiKey": { + "content": "警告:您尚未添加 Google PageSpeed API 密钥。请访问设置添加。没有密钥,PageSpeed 监控将无法运行。" + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "详情", + "home": "首页" + }, + "buttons": { + "addMember": "添加成员", + "cancel": "取消", + "close": "关闭", + "configure": "配置", + "confirm": "确认", + "create": "创建", + "delete": "删除", + "generateToken": "生成令牌", + "incidents": "事件", + "inviteMember": "邀请成员", + "pause": "暂停", + "resume": "恢复", + "save": "保存", + "sendInvite": "发送邀请", + "test": "测试", + "testNotifications": "测试通知", + "toggleTheme": "切换主题明&暗", + "flushQueue": "清空队列", + "notFound": "前往主控制台", + "resetPassword": "重置密码", + "reset": "重置", + "clear": "清除", + "addDemo": "添加演示监控", + "removeMonitors": "移除监控", + "sendTestEmail": "发送测试邮件", + "exportToJSON": "导出为 JSON", + "importFromJSON": "从 JSON 导入", + "clearFilters": "清除筛选", + "removeUser": "移除用户" + }, + "charts": { + "labels": { + "averageResponseTime": "平均响应时间", + "downtime": "停机时间", + "high": "高的", + "low": "低的", + "uptime": "正常运行时间" }, - "inputs": { - "email": { - "label": "电子邮件", - "placeholder": "jordan.ellis@domain.com", - "errors": { - "empty": "要继续,请输入您的电子邮件地址", - "invalid": "请重新检查输入的电子邮件地址的有效性" - } - }, - "password": { - "label": "密码", - "rules": { - "length": { - "beginning": "必须至少", - "highlighted": "8 个字符长" - }, - "special": { - "beginning": "必须至少包含", - "highlighted": "一个特殊字符" - }, - "number": { - "beginning": "必须至少包含", - "highlighted": "一个数字" - }, - "uppercase": { - "beginning": "必须至少包含", - "highlighted": "一个大写字母" - }, - "lowercase": { - "beginning": "必须至少包含", - "highlighted": "一个小写字母" - }, - "match": { - "beginning": "密码", - "highlighted": "必须匹配" - } - }, - "errors": { - "empty": "请输入您的密码", - "length": "密码长度必须至少为 8 个字符", - "uppercase": "密码必须至少包含 1 个大写字母", - "lowercase": "密码必须至少包含 1 个小写字母", - "number": "密码必须至少包含 1 个数字", - "special": "密码必须至少包含 1 个特殊字符", - "incorrect": "您提供的密码与我们的记录不符" - } - }, - "passwordConfirm": { - "label": "确认密码", - "placeholder": "重新输入密码以确认", - "errors": { - "empty": "请再次输入您的密码以进行确认(有助于解决拼写错误)", - "different": "输入的密码不匹配,因此其中一个密码可能输入错误" - } - }, - "firstName": { - "label": "名字", - "placeholder": "Jordan", - "errors": { - "empty": "请输入您的姓名", - "length": "姓名必须少于 50 个字符", - "pattern": "名称只能包含字母、空格、撇号或连字符" - } - }, - "lastName": { - "label": "姓", - "placeholder": "Ellis", - "errors": { - "empty": "请输入您的姓氏", - "length": "姓氏必须少于 50 个字符", - "pattern": "姓氏只能包含字母、空格、撇号或连字符" + "histogram": { + "avg": "平均: {{value}} ms", + "max": "最大: {{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "此操作无法撤销。", + "title": "确定要删除吗?" + } + }, + "labels": { + "active": "活跃", + "paused": "已暂停", + "na": "不适用", + "resolved": "已解决", + "responseTime": "响应时间" + }, + "form": { + "role": { + "option": { + "role": { + "label": "角色" } } }, - "errors": { - "validation": "验证数据时出错。" - }, - "fields": { - "password": { - "errors": { - "incorrect": "您提供的密码与我们的记录不符" + "name": { + "option": { + "firstName": { + "label": "名字", + "placeholder": "Jordan" + }, + "lastName": { + "label": "姓", + "placeholder": "Ellis" } - }, - "role": { - "errors": { - "min": "必须至少设置一个角色" + } + }, + "email": { + "option": { + "email": { + "label": "邮箱", + "placeholder": "you@example.com" } } } }, - "login": { - "heading": "登录以继续", - "subheadings": { - "stepOne": "输入您的电子邮件", - "stepTwo": "输入您的密码" - }, - "links": { - "forgotPassword": "忘记密码?", - "register": "没有帐户?", - "forgotPasswordLink": "重置密码", - "registerLink": "注册链接" - }, - "toasts": { - "success": "欢迎回来!您已成功登录。", - "incorrectPassword": "密码错误" - }, + "table": { + "empty": "暂无数据", + "headers": { + "actions": "动作", + "dateTime": "日期和时间", + "message": "消息", + "monitor": "监控", + "monitorId": "监控 ID", + "name": "名称", + "status": "状态", + "type": "类型", + "url": "URL", + "interval": "间隔", + "active": "活跃", + "responseTime": "响应时间" + } + } + }, + "components": { + "imageUpload": { + "clickToUpload": "点击上传", + "dragAndDrop": "拖放", + "supportedFormats": "支持的格式", + "maxSize": "最大尺寸", + "orDragAndDrop": "或拖放文件", "errors": { - "password": { - "incorrect": "您提供的密码与我们的记录不符" - } - }, - "welcome": "欢迎回到 Checkmate!" + "invalidFileSize": "文件太大了!", + "invalidFileFormat": "不支持的文件格式!" + } }, - "registration": { - "heading": { - "superAdmin": "创建超级管理员", - "user": "注册" - }, - "subheadings": { - "stepOne": "输入您的个人详细信息", - "stepTwo": "输入您的电子邮件", - "stepThree": "创建密码" + "headerStatusPageControls": { + "publicLink": "公共链接" + }, + "headerTimeRange": { + "labels": { + "day": "日", + "month": "月", + "recent": "最近", + "week": "周" }, "description": { - "superAdmin": "创建超级用户帐户以开始使用", - "user": "注册为用户并请求超级管理员访问您的监视器" + "recent": "显示过去 2 小时的统计数据。", + "day": "显示过去 24 小时的统计数据。", + "week": "显示过去 7 天的统计数据。", + "month": "显示过去 30 天的统计数据。" + } + }, + "offlineBanner": { + "serverUnreachable": "无法连接到服务器", + "retry": "重试", + "retrying": "重试中...", + "reconnected": "已成功重新连接到服务器。" + }, + "sidebar": { + "menu": { + "uptime": "上线时间", + "pagespeed": "页面速度", + "infrastructure": "基础设施", + "notifications": "通知", + "checks": "检查", + "incidents": "事件", + "statusPages": "状态页", + "maintenance": "维护", + "logs": "日志", + "settings": "设置" }, - "gettingStartedButton": { - "superAdmin": "创建超级管理员帐户", - "user": "注册普通用户" + "bottomMenu": { + "support": "支持", + "discussions": "讨论", + "docs": "文档", + "changelog": "变更日志" }, - "termsAndPolicies": "创建帐户即表示您同意我们的服务条款和隐私政策 。", + "accountMenu": { + "profile": "个人资料", + "password": "密码", + "team": "团队" + }, + "starPrompt": { + "title": "Star Checkmate", + "description": "查看最新版本并在 GitHub 上帮助发展社区" + }, + "authFooter": { + "navControls": "控制", + "logOut": "登出", + "roles": { + "superAdmin": "超级管理员", + "admin": "管理员", + "user": "用户", + "demoUser": "演示用户" + } + } + }, + "starPrompt": { + "title": "Star Checkmate", + "description": "查看最新版本并在 GitHub 上帮助发展社区" + }, + "pageHeader": { "links": { - "login": "已拥有帐号? 登录" - }, - "toasts": { - "success": "欢迎!您的账号已成功创建。" - }, - "welcome": "欢迎来到Checkmate!" - }, - "forgotPassword": { - "heading": "忘记密码?", - "subheadings": { - "stepOne": "不用担心,我们会向您发送重置说明。", - "stepTwo": "我们已发送密码重置链接到 ", - "stepThree": "您的新密码必须与以前使用的密码不同。", - "stepFour": "您的密码已成功重置。点击下方登录。" - }, - "buttons": { - "openEmail": "打开电子邮件应用", - "resetPassword": "重置密码" - }, - "imageAlts": { - "passwordKey": "密码图标", - "email": "电子邮件图标", - "lock": "锁定图标", - "passwordConfirm": "密码确认图标" - }, - "links": { - "login": "返回到登录", - "resend": "没有收到电子邮件? 点击重新发送" - }, - "toasts": { - "sent": "指令发送至 .", - "emailNotFound": "未找到电子邮件。", - "redirect": "将在秒后重定向...", - "success": "您的密码已成功重置。", - "error": "无法重置密码。请稍后重试或联系支持人员。" + "support": "支持", + "discussions": "讨论", + "docs": "文档", + "changelog": "更新日志" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "已成功重新连接到服务器。", - "stillUnreachable": "服务器仍然无法访问。请稍后重试。" + "pages": { + "notFound": { + "title": "哦不!你的寿司掉了!", + "subtitle": "该 URL 不存在,或者您没有访问权限。" + }, + "account": { + "tabs": { + "profile": "个人资料", + "password": "密码", + "team": "团队" }, - "alertBox": "服务器连接错误", - "description": "我们无法连接到服务器。如果问题仍然存在,请检查您的互联网连接或验证您的部署配置。", - "retryButton": { - "default": "尝试重连", - "processing": "连接中..." + "form": { + "name": { + "title": "名称", + "description": "更新您的个人信息" + }, + "photo": { + "title": "个人头像", + "description": "上传个人头像" + }, + "currentPassword": { + "title": "当前密码", + "description": "输入当前密码以验证您的身份", + "option": { + "label": "当前密码", + "placeholder": "输入当前密码" + } + }, + "newPassword": { + "title": "新密码", + "description": "请选择至少 8 个字符的强密码", + "option": { + "newPassword": { + "label": "新密码", + "placeholder": "输入新密码" + }, + "confirm": { + "label": "确认密码", + "placeholder": "确认新密码" + } + } + }, + "deleteAccount": { + "title": "删除账户", + "description": "此操作是永久的,无法撤销" + } + }, + "team": { + "filter": { + "placeholder": "按角色筛选", + "all": "全部", + "admin": "管理员", + "member": "成员" + }, + "table": { + "headers": { + "email": "邮箱", + "role": "角色", + "created": "创建" + } + }, + "addMember": { + "title": "注册新团队成员", + "description": "创建新用户账户。创建后请安全地分享凭据。" + }, + "invite": { + "title": "邀请团队成员", + "description": "发送邀请以加入您的团队", + "email": { + "label": "邮箱地址", + "placeholder": "输入邮箱地址" + }, + "role": { + "label": "角色", + "placeholder": "选择角色" + }, + "linkLabel": "邀请链接" + }, + "fallback": { + "title": "暂无团队成员", + "description": "通过电子邮件邀请队友或直接添加他们。一旦加入,他们将与角色一起显示在这里。" + } + }, + "header": { + "title": "账户", + "description": "管理个人资料、密码、团队和工作区设置。" } - } - }, - "createNotifications": { - "title": "创建通知通道", - "nameSettings": { - "title": "名字", - "description": "集成的描述性名称。", - "nameLabel": "名字", - "namePlaceholder": "例如.. Slack 通知" }, - "typeSettings": { - "title": "类型", - "description": "选择要创建的通知通道类型。", - "typeLabel": "类型" - }, - "emailSettings": { - "title": "邮箱", - "description": "目标电子邮件地址。", - "emailLabel": "电子邮件地址", - "emailPlaceholder": "例如.. john@example.com" - }, - "slackSettings": { - "title": "Slack", - "description": "在此处配置您的 Slack Webhook", - "webhookLabel": "Slack webhook 网址", - "webhookPlaceholder": "https://hooks.slack.com/services/..." - }, - "pagerdutySettings": { - "title": "PagerDuty", - "description": "在此处配置您的 PagerDuty 集成", - "integrationKeyLabel": "集成密钥", - "integrationKeyPlaceholder": "1234567890" - }, - "discordSettings": { - "title": "Discord", - "description": "在此处配置您的 Discord Webhook", - "webhookLabel": "Discord Webhook 网址", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "webhookSettings": { - "title": "Webhook", - "description": "在此处配置 Webhook", - "webhookLabel": "Webhook网址", - "webhookPlaceholder": "https://your-server.com/webhook" - }, - "testNotification": "测试通知", - "dialogDeleteTitle": "你确认要删除此通知吗?", - "dialogDeleteConfirm": "删除" - }, - "notificationConfig": { - "title": "通知", - "description": "选择要使用的通知渠道" - }, - "monitorStatus": { - "checkingEvery": "每隔 {{interval}} 检查一次", - "withCaptureAgent": "使用 Capture 代理 {{version}}", - "up": "在线", - "down": "离线", - "paused": "暂停" - }, - "advancedMatching": "高级匹配", - "sendTestNotifications": "发送测试通知", - "selectAll": "全选", - "showAdminLoginLink": "显示“管理员?在此处登录“状态页面上的链接", - "logsPage": { - "title": "日志", - "description": "系统日志 - 最后 1000 行", - "tabs": { - "queue": "作业队列", - "logs": "服务器日志", - "diagnostics": "诊断" - }, - "toast": { - "fetchLogsSuccess": "日志获取成功" - }, - "logLevelSelect": { - "title": "日志等级", - "values": { - "all": "全部", - "info": "信息", - "warn": "警告", - "error": "错误", - "debug": "调试" + "auth": { + "brandPanel": { + "eyebrow": "开源基础设施监控", + "tagline": "关注您服务器的每一步动作。", + "description": "实时跟踪运行状态、响应时间和事件 - 自托管,完全属于您。" + }, + "common": { + "passwordRules": { + "length": { + "beginning": "必须至少", + "highlighted": "8 个字符长" + }, + "lowercase": { + "beginning": "必须至少包含", + "highlighted": "一个小写字母" + }, + "match": { + "beginning": "密码", + "highlighted": "必须匹配" + }, + "number": { + "beginning": "必须至少包含", + "highlighted": "一个数字" + }, + "special": { + "beginning": "必须至少包含", + "highlighted": "一个特殊字符" + }, + "uppercase": { + "beginning": "必须至少包含", + "highlighted": "一个大写字母" + } + }, + "form": { + "option": { + "email": { + "label": "邮箱", + "placeholder": "me@example.com" + }, + "password": { + "label": "密码", + "placeholder": "********" + }, + "confirmPassword": { + "label": "确认密码" + } + } + } + }, + "login": { + "title": "欢迎回到 Checkmate!", + "subtitle": "登录以继续", + "submit": "登录", + "links": { + "forgotPassword": { + "text": "忘记密码?", + "linkText": "重置密码" + }, + "register": { + "text": "还没有账户?", + "linkText": "在此注册" + } + } + }, + "register": { + "title": "欢迎回到 Checkmate!", + "subtitle": "注册以开始使用", + "submit": "注册", + "setupNotice": "此页面仅在初始设置期间可用。注册超级管理员后,可以从团队设置中邀请更多用户。" + }, + "forgotPassword": { + "title": "忘记密码了?", + "subtitle": "别担心,我们会给您发送重置说明。", + "submit": "请求恢复", + "links": { + "login": { + "text": "返回到", + "linkText": "登录" + } + } + }, + "setNewPassword": { + "title": "重置您的密码", + "subtitle": "您的新密码必须与之前使用过的密码不同。" } - } - }, - "queuePage": { - "title": "队列", - "refreshButton": "刷新", - "flushButton": "刷新队列", - "jobTable": { - "title": "当前在队列中的作业", - "idHeader": "监视器 ID", - "urlHeader": "网址", - "typeHeader": "类型", - "activeHeader": "有效", - "lockedAtHeader": "锁定在", - "runCountHeader": "运行计数", - "failCountHeader": "失败计数", - "lastRunHeader": "上次运行时间", - "lastFinishedAtHeader": "最后完成时间", - "lastRunTookHeader": "上次运行花费了", - "intervalHeader": "间隔" }, - "metricsTable": { - "title": "队列指标", - "metricHeader": "度量", - "valueHeader": "值" + "checks": { + "selects": { + "monitor": { + "all": "所有监控" + }, + "status": { + "all": "全部", + "down": "宕机", + "up": "正常" + } + }, + "table": { + "empty": "此时间范围内没有宕机检查", + "headers": { + "statusCode": "状态码", + "location": "位置" + } + }, + "fallback": { + "title": "暂无检查", + "description": "创建监控器后,每次状态探测和响应时间都会以一次检查的形式显示在这里。" + }, + "header": { + "title": "检查", + "description": "Checkmate 对监控器执行的每次探测都会在此显示,包含状态码和响应时间。" + } }, - "failedJobTable": { - "title": "失败的作业", - "monitorIdHeader": "监视器ID", - "monitorUrlHeader": "监控网址", - "failCountHeader": "失败计数", - "failedAtHeader": "上次失败时于", - "failReasonHeader": "失败原因" - } - }, - "export": { - "title": "导出监视器", - "success": "监视器导出成功!", - "failed": "导出监视器失败" - }, - "monitorActions": { - "title": "导出/导入", - "import": "导入监视器", - "export": "导出监视器", - "deleteSuccess": "监视项删除成功", - "deleteFailed": "删除监视项失败", - "details": "详情" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "由 Bluewave Labs 开发", - "labelVersion": "版本", - "title": "关于" + "common": { + "monitors": { + "actions": { + "configure": "配置", + "delete": "删除", + "generateToken": "生成令牌", + "details": "详情", + "incidents": "事件", + "inviteMember": "邀请成员", + "openSite": "打开站点", + "pause": "暂停", + "resume": "恢复", + "selectAll": "选择所有监视器", + "selectMonitor": "选择 {{name}}", + "bulkSelected_one": "已选择 {{count}} 个监视器", + "bulkSelected_other": "已选择 {{count}} 个监视器" + }, + "statBoxes": { + "activeFor": "已活跃", + "certificateExpiry": "证书到期", + "lastCheck": "上次检查", + "lastResponseTime": "上次响应时间", + "serviceIsDown": "服务已停止" + }, + "status": { + "down": "宕机", + "breached": "超出阈值", + "initializing": "初始化中", + "maintenance": "维护中", + "paused": "已暂停", + "total": "总计", + "up": "正常" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "游戏", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "端口", + "optionPagespeed": "PageSpeed", + "optionHardware": "基础设施", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "已暂停 {{count}} 个监视器", + "paused_other": "已暂停 {{count}} 个监视器", + "resumed_one": "已恢复 {{count}} 个监视器", + "resumed_other": "已恢复 {{count}} 个监视器", + "alreadyPaused_one": "所选监视器已暂停", + "alreadyPaused_other": "所有选定的监视器均已暂停", + "alreadyRunning_one": "所选监视器已经在运行", + "alreadyRunning_other": "所有选定的监视器均已在运行" + } + } }, - "demoMonitorsSettings": { - "buttonAddMonitors": "添加演示监视器", - "description": "添加示例监视器以进行演示。", - "title": "示例监视器" + "createMonitor": { + "form": { + "advanced": { + "description": "高级用例的可选设置", + "option": { + "advancedMatching": { + "label": "使用高级匹配" + }, + "expectedValue": { + "label": "期望值" + }, + "jsonPath": { + "description": "此表达式将针对响应 JSON 数据进行计算,结果将用于与期望值进行匹配。查询语言文档请参见 jmespath.org。", + "label": "JSONPath 表达式" + }, + "matchMethod": { + "label": "匹配方法", + "equal": "相等", + "include": "包含", + "regex": "正则表达式" + } + }, + "title": "高级设置" + }, + "frequency": { + "description": "您希望多久检查一次此监控的状态?", + "option": { + "frequency": { + "label": "检查频率", + "value": { + "fifteenMinutes": "15 分钟", + "fifteenSeconds": "15 秒", + "fiveMinutes": "5 分钟", + "fourMinutes": "4 分钟", + "oneMinute": "1 分钟", + "tenMinutes": "10 分钟", + "thirtyMinutes": "30 分钟", + "thirtySeconds": "30 秒", + "threeMinutes": "3 分钟", + "twoMinutes": "2 分钟" + } + } + }, + "title": "检查频率" + }, + "general": { + "option": { + "container": { + "label": "容器名称/ID", + "placeholder": "my-app 或 abcd1234" + }, + "host": { + "label": "主机", + "placeholder": "192.168.1.100 或 example.com" + }, + "name": { + "label": "显示名称", + "placeholder": "例如 My Website" + }, + "secret": { + "label": "授权密钥", + "placeholder": "输入您的密钥" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "丢包监控网址", + "placeholder": "localhost" + }, + "port": { + "label": "要监控的端口", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "服务名称", + "placeholder": "例如 my.service.v1(留空则检查整体健康状态)" + }, + "wsUrl": { + "label": "WebSocket URL", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture 端点", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "常规设置", + "description": { + "http": "输入要监控的 URL 或 IP(例如 https://example.com/ 或 192.168.1.100),并添加显示在仪表板上的清晰显示名称。", + "ping": "输入要 ping 的 IP 地址或主机名(例如.. 192.168.1.100 或 example.com),并添加显示在仪表板上的清晰显示名称。", + "port": "输入服务器的 URL 或 IP、端口号和仪表板上显示的清晰显示名称。", + "docker": "码输入 Docker 容器名称或 ID。您可以使用容器名称(例如.. my-app)或容器 ID(完整的 64 字符 ID 或短 ID)。", + "game": "输入要 ping 的 IP 地址或主机名和端口号(例如.. 192.168.1.100 或 example.com)并选择游戏类型。某些游戏需要在配置文件中启用服务器查询 — 请查阅你的游戏服务器文档。详见支持的游戏列表了解协议细节。", + "pagespeed": "跟踪网站的页面加载性能、Core Web Vitals 和优化评分。", + "grpc": "输入 gRPC 服务器的主机名和端口,可选指定健康检查的服务名称,并添加显示名称。", + "websocket": "输入要监控的 WebSocket URL(例如 wss://example.com/socket),并添加显示名称。", + "hardware": "监控基础设施的 CPU、内存、磁盘使用情况和温度。Capture 端点 URL 必须包含完整路径 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授权密钥只需粘贴令牌字符串——请勿包含 'Bearer'。" + } + }, + "ignoreTls": { + "description": "配置 HTTPS 连接的 TLS/SSL 证书验证。", + "option": { + "tls": { + "label": "忽略 TLS/SSL 错误" + } + }, + "title": "TLS/SSL 设置" + }, + "incidents": { + "description": "滑动窗口用于判断监控何时宕机。监控的状态只有在滑动窗口中的检查百分比达到指定值时才会改变。", + "option": { + "checks": { + "label": "滑动窗口中的检查次数" + }, + "percentage": { + "label": "滑动窗口中多少百分比的检查失败/成功后监控状态才会改变?" + } + }, + "title": "事件" + }, + "notifications": { + "description": "选择您要使用的通知渠道", + "title": "通知" + }, + "type": { + "description": "选择要执行的检查类型", + "optionDockerDescription": "使用 Docker 监控容器是否正在运行。", + "optionGameDescription": "监控特定游戏服务器是否在线。", + "optionGrpcDescription": "使用标准健康检查协议监控 gRPC 服务。", + "optionWebSocketDescription": "监控 WebSocket 端点的连接健康状况和响应时间。", + "optionHttpDescription": "使用 HTTP(S) 监控您的网站或 API 端点。", + "optionPingDescription": "使用 ICMP Ping 监控服务器是否在线。", + "optionPortDescription": "监控服务器上的特定端口是否开放。", + "title": "类型" + }, + "thresholds": { + "title": "告警阈值", + "description": "定义此硬件监控触发告警的阈值。", + "option": { + "cpuThreshold": { + "label": "CPU 告警阈值 (%)" + }, + "memoryThreshold": { + "label": "内存告警阈值 (%)" + }, + "diskThreshold": { + "label": "磁盘告警阈值 (%)" + }, + "tempThreshold": { + "label": "温度告警阈值 (°C)" + } + } + }, + "geoChecks": { + "title": "地理分布式检查", + "description": "从多个地理位置运行检查,以监控全球可用性和性能。", + "option": { + "enabled": { + "label": "启用地理分布式检查" + }, + "locations": { + "label": "位置", + "placeholder": "选择位置", + "options": { + "EU": "欧洲", + "NA": "北美洲", + "AS": "亚洲", + "SA": "南美洲", + "AF": "非洲", + "OC": "大洋洲" + } + }, + "interval": { + "label": "检查间隔", + "value": { + "fiveMinutes": "5 分钟", + "tenMinutes": "10 分钟", + "fifteenMinutes": "15 分钟", + "thirtyMinutes": "30 分钟" + } + } + } + }, + "url": { + "title": "在状态页上监控 IP/URL", + "description": "在公共状态页上显示监控的 IP 地址或 URL。如果禁用,将仅显示监控名称以保护敏感信息。", + "option": { + "showURL": { + "label": "在状态页上显示 IP/URL", + "enabled": "已启用", + "disabled": "已禁用" + } + } + }, + "stats": { + "title": "监控历史", + "description": "清除您团队的所有监控历史和统计数据。此操作不可逆。", + "buttonText": "清除所有统计", + "dialog": { + "title": "清除所有监控历史?", + "description": "这将永久删除您团队的所有监控历史、统计数据和检查数据。此操作无法撤销。", + "confirm": "是的,清除所有统计" + } + } + } }, - "emailSettings": { - "buttonSendTestEmail": "发送测试电子邮件", - "description": "配置系统的电子邮件设置。这用于发送通知和警报。", - "descriptionTransport": "这将为 NodeMailer 构建 SMTP 传输", - "labelAddress": "电子邮件地址 - 用于身份验证", - "labelConnectionHost": "电子邮件连接主机 - 要在 HELO/EHLO 问候语中使用的主机名", - "labelHost": "电子邮件主机 - 要连接到的主机名或 IP 地址", - "labelIgnoreTLS": "禁用 STARTTLS:即使服务器支持 TLS,也不要使用 TLS", - "labelPassword": "电子邮件密码 - 身份验证密码", - "labelPasswordSet": "已设置密码。单击重置以更改它。", - "labelPool": "启用连接池:重用现有连接以提高性能", - "labelPort": "电子邮件端口 - 要连接的端口", - "labelRejectUnauthorized": "拒绝无效证书:拒绝具有自签名或不受信任的证书的连接", - "labelRequireTLS": "强制 STARTTLS:需要 TLS 升级,如果不支持则失败", - "labelSecure": "使用 SSL(推荐):使用 SSL/TLS 加密连接", - "labelTLSServername": "TLS 服务器名称 - 当主机是 IP 时,用于 TLS 验证的可选主机名", - "labelUser": "电子邮件用户 - 用于身份验证的用户名,如果指定,则覆盖电子邮件地址", - "linkTransport": "在此处查看规格", - "placeholderUser": "如果不需要,请留空", - "title": "电子邮件", - "toastEmailRequiredFieldsError": "需要输入电子邮件地址、主机、端口和密码" + "editUser": { + "form": { + "roles": { + "title": "角色", + "description": "为用户分配角色。可以选择多个角色。" + } + }, + "dialog": { + "removeUser": { + "title": "移除用户", + "content": "确定要将 {{name}} 从您的团队中移除吗?此操作无法撤销。" + } + } }, - "pageSpeedSettings": { - "description": "输入您的 Google PageSpeed API 密钥以启用 Google PageSpeed 监控。单击重置以更新密钥。", - "labelApiKeySet": "API 密钥已设置。单击重置以更改它。", - "labelApiKey": "ageSpeed API 密钥", - "title": "Google PageSpeed API 密钥" + "incidents": { + "fallback": { + "title": "无事件 — 所有系统正常", + "description": "当监控器宕机或恢复时,事件将连同时间、监控器详情和解决记录一起显示在这里。" + }, + "dialog": { + "resolveIncident": { + "title": "解决事件", + "option": { + "comment": { + "label": "备注(可选)", + "placeholder": "添加关于解决方案的备注..." + } + } + }, + "details": { + "analysis": "事件分析", + "comment": "备注:", + "detailsLabel": "详情", + "downtime": "停机时间:", + "message": "消息:", + "monitor": "监控:", + "overview": "概览", + "resolutionDetails": "解决详情", + "resolutionType": "类型:", + "resolutionTypes": { + "automatic": "自动", + "manual": "手动" + }, + "resolve": "解决事件", + "resolvedAt": "解决于:", + "resolvedBy": "解决人:", + "startedAt": "开始于:", + "status": "状态:", + "statusCode": "状态码:", + "timeline": "时间线", + "title": "事件详情", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "所有监控", + "monitor": "监控", + "resolutionType": "解决类型", + "resolutionTypes": { + "manual": "手动", + "automatic": "自动", + "all": "全部" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "活跃事件", + "active_zero": "没有活跃事件", + "active_one": "{{count}} 个活跃事件", + "active_other": "{{count}} 个活跃事件" + }, + "incidentStats": { + "avgResolutionTime": "平均解决时间", + "mostAffectedMonitor": "受影响最大的监控", + "title": "事件统计", + "totalIncidents": "总事件数" + }, + "latestIncidents": { + "title": "最新事件" + } + }, + "table": { + "actions": { + "details": "详情", + "goToMonitor": "前往监控", + "resolveManually": "手动解决" + }, + "activeIncidents": "活跃事件", + "headers": { + "endTime": "结束时间", + "resolutionType": "解决类型", + "startTime": "开始时间", + "statusCode": "状态码" + }, + "resolvedIncidents": "已解决事件", + "status": { + "active": "活跃", + "resolved": "已解决" + } + }, + "header": { + "title": "事件", + "description": "当监控器宕机或恢复时,事件将连同时间、监控器详情和解决记录一起显示在这里。" + } }, - "saveButtonLabel": "保存", - "statsSettings": { - "clearAllStatsButton": "清除所有统计", - "clearAllStatsDescription": "清除所有统计数据。这是不可逆转的。", - "clearAllStatsDialogConfirm": "是的,清除所有统计数据", - "clearAllStatsDialogDescription": "一旦删除,监控历史记录和统计信息将无法检索。", - "clearAllStatsDialogTitle": "你想清除所有的统计数据吗?", - "description": "定义要保留历史数据的时间。您还可以清除所有现有数据。", - "labelTTL": "要保留监控历史记录的天数。", - "labelTTLOptional": "0 表示无限", - "title": "查看历史记录" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "移除所有监视项", - "description": "从系统中移除所有监视项。", - "dialogConfirm": "确定,移除所有监视项", - "dialogDescription": "移除后,无法恢复这些监视项。", - "dialogTitle": "你想移除所有的监视项吗?", - "title": "系统复位" - }, - "timezoneSettings": { - "description": "选择用于在整个应用程序中显示日期和时间时区。", - "label": "显示时区", - "title": "显示时区" - }, - "title": "设置", - "uiSettings": { - "description": "在亮色和暗色模式之间切换,或更改用户界面语言。", - "labelLanguage": "语言", - "labelTheme": "主题模式", - "title": "外观" - }, - "urlSettings": { - "description": "在公共状态页面上显示监控的 IP 地址或 URL。如果禁用,则仅显示监控名称以保护敏感信息。", - "label": "在状态页面显示 IP/URL", - "selectDisabled": "禁用", - "selectEnabled": "启用", - "title": "在状态页面上监控 IP/URL" - }, - "globalThresholds": { - "title": "全局阈值", - "description": "配置 CPU、内存、磁盘和温度阈值。如果提供了阈值,则会自动启用监控。" - } - }, - "statusPageCreate": { - "buttonSave": "保存" - }, - "incidentsOptionsHeaderFilterResolved": "已解决", - "settingsSave": "保存", - "statusPageCreateAppearanceTitle": "外观", - "confirmPassword": "确认密码", - "monitorHooks": { - "failureAddDemoMonitors": "未能添加示例监视器", - "successAddDemoMonitors": "成功添加示例监视器" - }, - "settingsAppearance": "外观", - "settingsDisplayTimezone": "显示时区", - "settingsGeneralSettings": "常规设置", - "incidentsOptionsHeaderTotalIncidents": "事件总数", - "statusPage": { - "deleteSuccess": "状态页面已成功删除", - "deleteFailed": "删除状态页面失败", - "createSuccess": "状态页创建成功", - "updateSuccess": "状态页更新成功", - "generalSettings": "常规设置", - "contents": "内容", - "fallback": { - "checks": [ - "实时监控和显示服务的运行状况", - "跟踪多个服务并共享其状态", - "让用户了解中断和性能" - ], - "title": "状态页用于:", - "actionButton": "让我们创建你的第一个状态页!" - } - }, - "testNotificationsDisabled": "此监视器没有通知设置。您需要通过单击‘配置’按钮来添加一个", - "incidentsTableResolvedAt": "解决于", - "incidentsTableActionResolve": "解决", - "checkHooks": { - "failureResolveOne": "未能解决事件。", - "failureResolveAll": "未能解决所有事件。", - "failureResolveMonitor": "故障排除监控事件失败。" - }, - "checkFormError": "请检查表单中的错误。", - "diagnosticsPage": { - "diagnosticDescription": "系统诊断", - "statsDescription": "系统统计数据", - "gauges": { - "heapAllocationTitle": "堆分配", - "heapAllocationSubtitle": "% 的可用内存占用率", - "heapUsageTitle": "堆使用情况", - "heapUsageSubtitle": "% 的可用内存占用率", - "heapUtilizationTitle": "堆利用率", - "heapUtilizationSubtitle": "% 的分配百分比", - "instantCpuUsageTitle": "当前 CPU 使用率", - "instantCpuUsageSubtitle": "% 的1秒CPU占用率" - }, - "stats": { - "eventLoopDelayTitle": "事件循环延迟", - "uptimeTitle": "运行时间", - "usedHeapSizeTitle": "已用堆内存大小", - "totalHeapSizeTitle": "总堆大小", - "osMemoryLimitTitle": "系统内存限制" - } - }, - "pageSpeedLighthouseAPI": "使用 Lighthouse PageSpeed API 监控您的网站", - "time": { - "threeMinutes": "3 分钟", - "fiveMinutes": "5 分钟", - "tenMinutes": "10 分钟", - "twentyMinutes": "20 分钟", - "oneHour": "1 小时", - "oneDay": "1 天", - "oneWeek": "1 周", - "fourMinutes": "4 分钟", - "oneMinute": "1 分钟", - "twoMinutes": "2 分钟", - "fifteenSeconds": "15 秒", - "thirtySeconds": "30 秒" - }, - "general": { - "noOptionsFound": "{{unit}} 未找到" - }, - "infrastructureMonitor": { - "fallback": { - "checks": [ - "追踪服务器性能表现", - "识别瓶颈并优化资源利用率", - "通过实时监控保障系统可靠性" - ], - "title": "基础设施监视器用于:", - "actionButton": "让我们创建你的第一个基础设施视器!" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": ["标记维护时段", "消除所有误解", "停止在维护窗口内发送警报"], - "title": "维护时段用于:", - "actionButton": "让我们创建您的第一个维护时段!" - } - }, - "pageSpeed": { - "fallback": { - "checks": ["报告页面的用户体验", "帮助分析网页速度", "就如何改进页面提出建议"], - "title": "PageSpeed 监视器用于:", - "actionButton": "让我们创建您的第一个 PageSpeed 监视器!" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [ - "检测网站或服务器是否在线及响应正常", - "向团队发送停机或性能问题警报", - "监控 HTTP 端点、Ping 测试、容器及端口状态", - "追踪历史运行时长与可靠性趋势" - ], - "title": "正常运行时间监视器用于:", - "actionButton": "让我们创建您的第一个正常运行时间监视器!" - } - }, - "editUserPage": { - "form": { - "email": "电子邮件", - "firstName": "名", - "lastName": "姓氏", - "role": "角色", - "save": "保存" - }, - "table": { - "actionHeader": "操作", - "roleHeader": "角色" - }, - "title": "编辑用户", - "toast": { - "successUserUpdate": "用户更新成功", - "validationErrors": "验证错误" - } - }, - "incidentsPageActionResolveMonitor": "解决监控事件", - "incidentsPageActionResolveAll": "解决所有事件", - "matchMethodOptions": { - "equal": "相等", - "equalPlaceholder": "成功", - "include": "包含", - "includePlaceholder": "OK", - "regex": "正则表达式", - "regexPlaceholder": "^(success|ok)$", - "text": "匹配方法" - }, - "monitorType": { - "docker": { - "label": "容器 ID", - "namePlaceholder": "我的容器", - "placeholder": "我的应用程序或 abcd1234" - }, - "http": { - "label": "要监控的 URL", - "namePlaceholder": "Google", - "placeholder": "google.com" - }, - "ping": { - "label": "要监控的 IP 地址", - "namePlaceholder": "Google", - "placeholder": "1.1.1.1" - }, - "port": { - "label": "要监控的 URL", - "namePlaceholder": "localhost:5173", - "placeholder": "localhost" - }, - "game": { - "label": "丢包监控网址", - "namePlaceholder": "localhost:5173", - "placeholder": "localhost" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "JSON 路径" - }, - "bytesPerSecond": "每秒字节数", - "bytesReceived": "已接收字节数", - "bytesSent": "已发送字节数", - "chooseGame": "选择游戏", - "createMonitorPage": { - "incidentConfigDescription": "滑动窗口用于确定监视器何时发生故障。仅当滑动窗口中的检查百分比满足指定值时,监视器的状态才会改变。", - "incidentConfigStatusWindowLabel": "在滑动窗口中应包含多少次检测?", - "incidentConfigStatusWindowThresholdLabel": "在监视器状态改变之前,滑动窗口中的检查失败/成功的百分比是多少?", - "incidentConfigTitle": "事件", - "incidentConfigDescriptionV2": "", - "incidentConfigStatusCheckNumber": "", - "intervalTitle": "", - "intervalDescription": "" - }, - "dataRate": "数据速率", - "dataReceived": "已接收数据", - "dataSent": "已发送数据", - "details": "详情", - "drops": "丢包", - "errors": "错误", - "errorsIn": "错误于", - "errorsOut": "错误输出", - "gameServerMonitoring": "游戏服务器监视", - "gameServerMonitoringDescription": "检查你的游戏服务器是否正在运行", - "network": "网络", - "networkDrops": "网络丢包", - "networkErrors": "网络错误", - "networkInterface": "网络接口", - "noNetworkStatsAvailable": "没有可用的网络统计数据。", - "packetsPerSecond": "每秒数据包数", - "packetsReceived": "已接收数据包", - "packetsReceivedRate": "数据包接收率", - "packetsSent": "已发送数据包", - "rate": "速度", - "selectInterface": "选择接口", - "v1": { "infrastructure": { - "disk_selection_title": "磁盘选择", - "disk_selection_info": "目前没有检测到磁盘。", - "disk_selection_description": "选择您要监控的特定磁盘。" + "charts": { + "labels": { + "cpu": "CPU 使用率", + "disk": "磁盘使用率", + "memory": "内存使用率", + "netBytesRecv": "{{name}} - 接收字节", + "netBytesSent": "{{name}} - 发送字节", + "netNoTraffic": "Capture 报告所有接口的流量均为零。当 Capture 在只能看到回环接口的容器内运行时,这属于正常情况——请直接在主机上运行 Capture 才能看到真实的网络活动。", + "temp": "温度" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "最大频率", + "title": "CPU 使用率", + "upperLabel": "当前频率" + }, + "disk": { + "lowerLabel": "可用", + "title": "磁盘 {{idx}} 使用率", + "upperLabel": "已使用" + }, + "memory": { + "lowerLabel": "可用", + "title": "内存使用率", + "upperLabel": "已使用" + } + }, + "statBoxes": { + "avgCpuTemperature": "CPU 平均温度", + "cpuFrequency": "CPU 频率", + "cpuLogical": "CPU(逻辑核心)", + "cpuPhysical": "CPU(物理核心)", + "disk": "磁盘", + "memory": "内存", + "os": "系统" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "磁盘", + "memory": "内存" + } + }, + "tabs": { + "labels": { + "network": "网络", + "overview": "概览" + } + }, + "fallback": { + "actionButton": "创建监控!", + "title": "暂无基础设施监控器", + "description": "跟踪服务器性能、识别瓶颈,通过实时基础设施监控确保可靠性。" + }, + "header": { + "title": "基础设施监控器", + "description": "跟踪服务器性能、识别瓶颈,通过实时基础设施监控确保可靠性。" + } + }, + "logs": { + "fallback": { + "title": "暂无日志", + "description": "Checkmate 运行时的服务器活动、警告和错误将显示在这里。" + }, + "tabs": { + "diagnostics": "诊断", + "logs": "服务器日志", + "queue": "作业队列" + }, + "logLevelSelect": { + "label": "日志级别" + }, + "jobQueue": "作业队列", + "jobQueueExplainer": "高亮的行是当前正在被工作进程处理的作业。", + "failedJobs": "失败作业", + "failedJobsExplainer": "按监控的最近失败 — 最近失败时间、失败次数及原因。", + "noLogs": "未找到日志", + "metrics": { + "jobs": "作业", + "activeJobs": "活跃作业", + "failingJobs": "失败中的作业", + "totalRuns": "总运行次数", + "totalFailures": "总失败次数" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "事件循环延迟", + "uptime": "正常运行时间", + "usedHeapSize": "已使用堆大小", + "totalHeapSize": "总堆大小", + "osMemoryLimit": "操作系统内存限制" + }, + "gauges": { + "heapAllocation": "堆分配", + "heapUsage": "堆使用率", + "heapUtilization": "堆利用率", + "instantCpuUsage": "即时 CPU 使用率", + "availableMemoryPercentage": "可用内存百分比", + "allocatedPercentage": "已分配百分比", + "usedSPercentage": "CPU 1 秒使用百分比", + "total": "总计", + "used": "已使用" + }, + "sections": { + "runtimeStats": { + "title": "运行时统计", + "description": "Node.js 进程和主机的快照。每 5 秒更新一次。" + }, + "memoryCpu": { + "title": "内存与 CPU", + "description": "进程如何使用其分配的内存和主机 CPU。" + } + } + }, + "table": { + "headers": { + "timestamp": "时间戳", + "level": "级别", + "service": "服务", + "method": "方法", + "monitorId": "监控 ID", + "runCount": "运行次数", + "failCount": "失败次数", + "lastRunAt": "上次运行于", + "lockedAt": "锁定于", + "lastFinishedAt": "上次完成于", + "lastRunTook": "上次运行耗时", + "lastFailedAt": "上次失败于", + "failReason": "失败原因" + } + }, + "queueFallback": { + "title": "队列中没有任务", + "description": "已安排或正在运行的监控检查将显示在这里。空队列意味着一切都是最新的。" + }, + "header": { + "title": "日志与队列", + "description": "在一处查看服务器日志、监控器作业队列和运行时诊断。" + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "创建维护窗口!", + "title": "暂无计划维护窗口", + "description": "安排计划停机时间以抑制告警,让团队和用户知晓预期。" + }, + "table": { + "headers": { + "nextWindow": "下一个窗口", + "repeat": "重复" + } + }, + "form": { + "general": { + "title": "常规设置", + "description": "为维护窗口设置名称和重复选项。", + "option": { + "name": { + "label": "名称", + "placeholder": "例如 每周维护" + }, + "repeat": { + "label": "重复" + } + } + }, + "startDate": { + "title": "开始日期", + "description": "选择维护窗口的开始日期。", + "option": { + "startDate": { + "label": "开始日期" + } + } + }, + "startTime": { + "title": "开始时间", + "description": "设置维护窗口的开始时间和持续时间。所有时间均为 UTC", + "option": { + "duration": { + "label": "持续时间" + }, + "startTime": { + "label": "开始时间" + } + }, + "monitors": { + "title": "监控", + "description": "维护窗口应用的监控对象", + "option": { + "addMonitors": { + "label": "添加监控" + } + } + } + } + }, + "header": { + "title": "维护窗口", + "description": "安排计划停机时间以抑制告警,让团队和用户知晓预期。" + } + }, + "notifications": { + "fallback": { + "actionButton": "创建渠道", + "title": "暂无通知渠道", + "description": "连接 Slack、Discord、邮件、Webhook 等,让事件发生时正确的人立即收到通知。" + }, + "form": { + "accessToken": { + "optionAccessToken": "访问令牌", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "通知将发送到的地址。", + "optionAddress": "地址", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "地址" + }, + "homeServer": { + "optionHomeServer": "主服务器", + "placeholder": "example.com" + }, + "matrix": { + "description": "配置 Matrix 主服务器连接以接收通知。", + "title": "Matrix 配置" + }, + "notificationName": { + "description": "通知渠道的描述性名称", + "optionName": "渠道名称", + "placeholder": "例如 生产告警", + "title": "渠道名称" + }, + "pagerDuty": { + "description": "您的 PagerDuty 集成密钥,用于接收告警。", + "optionIntegrationKey": "集成密钥", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "集成密钥" + }, + "roomId": { + "optionRoomId": "房间 ID", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "选择要创建的通知渠道类型。", + "optionType": "类型", + "title": "渠道类型" + }, + "telegram": { + "title": "Telegram 配置", + "description": "要启用 Telegram 通知,请使用 BotFather创建一个 Telegram 机器人。然后,获取 API 令牌和聊天 ID 并在此处填写。", + "optionBotToken": "机器人令牌", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "聊天 ID", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Pushover 配置", + "description": "输入您的 Pushover 应用令牌和用户密钥。", + "optionAppToken": "应用令牌", + "placeholderAppToken": "输入应用令牌", + "optionUserKey": "用户密钥", + "placeholderUserKey": "输入用户密钥" + }, + "twilio": { + "title": "Twilio 配置", + "description": "输入 Twilio 帐户凭据以发送短信。", + "optionAccountSid": "帐户 SID", + "placeholderAccountSid": "输入帐户 SID", + "optionAuthToken": "认证令牌", + "placeholderAuthToken": "输入认证令牌", + "optionFromNumber": "发送号码", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "接收号码", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "目标" + } + }, + "header": { + "title": "通知", + "description": "连接 Slack、Discord、邮件、Webhook 等,让事件发生时正确的人立即收到通知。" + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "累积布局偏移 (CLS)", + "fcp": "首次内容绘制 (FCP)", + "lcp": "最大内容绘制 (LCP)", + "si": "速度指数 (SI)", + "tbt": "总阻塞时间 (TBT)" + }, + "legend": { + "title": "PageSpeed 报告", + "weight": "权重" + }, + "pie": { + "title": "性能报告" + } + }, + "table": { + "headers": { + "pageSpeedScore": "PageSpeed 评分" + } + }, + "fallback": { + "actionButton": "创建监控!", + "title": "暂无 PageSpeed 监控器", + "description": "持续追踪 Core Web Vitals 和 Lighthouse 评分,在用户察觉之前发现性能回退。" + }, + "header": { + "title": "PageSpeed 监控器", + "description": "持续追踪 Core Web Vitals 和 Lighthouse 评分,在用户察觉之前发现性能回退。" + } + }, + "settings": { + "form": { + "timezone": { + "title": "显示时区", + "description": "选择应用程序中用于显示日期和时间的时区。", + "option": { + "timezone": { + "label": "显示时区" + } + } + }, + "ui": { + "title": "外观", + "description": "在浅色和深色模式之间切换、更改语言或自定义图表显示类型。", + "option": { + "theme": { + "label": "主题模式", + "light": "浅色", + "dark": "深色" + }, + "language": { + "label": "语言" + }, + "chartType": { + "label": "图表类型", + "histogram": "柱状图", + "heatmap": "热力图" + } + } + }, + "pagespeed": { + "title": "Google PageSpeed API 密钥", + "description": "输入您的 Google PageSpeed API 密钥以启用 Google PageSpeed 监控。点击重置以更新密钥。", + "option": { + "apiKey": { + "label": "PageSpeed API 密钥", + "labelSet": "API 密钥已设置。点击重置以更改。", + "placeholder": "输入您的 Google PageSpeed API 密钥" + } + } + }, + "url": { + "title": "在状态页上监控 IP/URL", + "description": "在公共状态页上显示监控的 IP 地址或 URL。如果禁用,将仅显示监控名称以保护敏感信息。", + "option": { + "showURL": { + "label": "在状态页上显示 IP/URL", + "enabled": "已启用", + "disabled": "已禁用" + } + } + }, + "stats": { + "title": "监控历史", + "description": "清除您团队的所有监控历史和统计数据。此操作不可逆。", + "option": { + "clear": { + "label": "清除所有统计数据。此操作不可逆。" + } + }, + "dialog": { + "title": "清除所有监控历史?", + "description": "此操作无法撤销" + } + }, + "retention": { + "title": "检查数据保留", + "description": "设置检查数据在自动清理前保留多长时间。", + "option": { + "days": { + "label": "保留期限(天)", + "unlimited": "无限制" + } + } + }, + "thresholds": { + "title": "全局阈值", + "description": "设置基础设施监控的全局 CPU、内存、磁盘和温度阈值。除非被覆盖,否则适用于所有硬件监控。", + "option": { + "cpu": { + "label": "CPU 阈值 (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "内存阈值 (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "磁盘阈值 (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "温度阈值 (°C)", + "placeholder": "1 - 150" + } + } + }, + "email": { + "title": "邮件设置", + "description": "配置系统的邮件设置。用于发送通知和告警。", + "descriptionTransport": "这将为 NodeMailer 构建 SMTP 传输", + "descriptionTransportLink": "在此查看规范", + "option": { + "host": { + "label": "邮件主机 - 要连接的主机名或 IP 地址", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "邮件端口 - 要连接的端口", + "placeholder": "587" + }, + "address": { + "label": "邮箱地址 - 用于身份验证", + "placeholder": "you@example.com" + }, + "user": { + "label": "邮件用户 - 用于身份验证的用户名,如果指定则覆盖邮箱地址", + "placeholder": "如不需要请留空" + }, + "password": { + "label": "邮件密码 - 用于身份验证的密码", + "labelSet": "密码已设置。点击重置以更改。", + "placeholder": "输入您的密码" + }, + "tlsServername": { + "label": "TLS 服务器名 - 当主机为 IP 时用于 TLS 验证的可选主机名", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "邮件连接主机 - 在 HELO/EHLO 问候中使用的主机名", + "placeholder": "localhost" + }, + "secure": { + "label": "使用 SSL(推荐):使用 SSL/TLS 加密连接" + }, + "pool": { + "label": "启用连接池:重用现有连接以提高性能" + }, + "ignoreTLS": { + "label": "禁用 STARTTLS:即使服务器支持也不使用 TLS" + }, + "requireTLS": { + "label": "强制 STARTTLS:要求 TLS 升级,不支持则失败" + }, + "rejectUnauthorized": { + "label": "拒绝无效证书:拒绝自签名或不受信任证书的连接" + } + } + }, + "demoMonitors": { + "title": "演示监控", + "description": "添加用于演示目的的示例监控。" + }, + "removeMonitors": { + "title": "系统重置", + "description": "从系统中移除所有监视器。", + "dialog": { + "title": "移除所有监控?", + "description": "此操作无法撤销。", + "paragraph": "所有检查历史、事件、计划维护窗口以及使用这些监控的状态页面也将一并被移除。如需保留数据备份,请先导出监控。" + } + }, + "exportMonitors": { + "title": "导出监控" + }, + "importExportMonitors": { + "title": "导入/导出监控", + "description": "将监控数据导入或导出为 JSON 文件,用于备份或迁移。" + }, + "about": { + "title": "关于", + "developedBy": "由 Bluewave Labs 开发" + }, + "validation": { + "errorMessage": "请修复以下验证错误:" + } + }, + "header": { + "title": "设置", + "description": "配置 SMTP、集成、保留和全局应用行为。" + } + }, + "statusPages": { + "deleteSuccess": "状态页已成功删除", + "fallback": { + "title": "暂无状态页面", + "actionButton": "创建状态页!", + "description": "发布一个公开页面,向客户和利益相关者展示实时运行情况和事件历史。" + }, + "monitorsList": { + "chartTypeHeatmap": "热力图", + "chartTypeHistogram": "柱状图", + "noData": "无可用数据", + "infrastructure": { + "title": "基础设施", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "内存", + "disk": "磁盘", + "usage": "使用率", + "used": "已使用", + "total": "总计" + }, + "uptime": { + "title": "正常运行时间", + "responseTime": "响应时间" + } + }, + "statusBar": { + "allDown": "所有系统已宕机", + "allUp": "所有系统运行正常", + "degraded": "部分系统正在出现问题", + "unknown": "无法确定系统状态", + "allBreached": "所有系统已超过阈值", + "allMaintenance": "所有系统正在维护中", + "allPaused": "所有系统已暂停", + "allInitializing": "所有系统正在初始化", + "breachedAndDown": "部分系统超过阈值且已停止", + "breached": "部分系统超过阈值", + "maintenanceAndDown": "部分系统在维护期间停止", + "maintenance": "部分系统正在维护", + "partiallyPaused": "部分系统已暂停", + "initializing": "部分系统正在初始化", + "noMonitors": "尚未配置任何监控器" + }, + "table": { + "headers": { + "name": "状态页名称", + "url": "公共 URL" + }, + "published": "已发布", + "unpublished": "未发布" + }, + "title": "状态页", + "details": { + "empty": { + "title": "这里还没有内容", + "addMonitor": "添加监控以开始" + } + }, + "form": { + "access": { + "title": "访问", + "description": "如果您的状态页已准备就绪,可以将其标记为已发布。", + "option": { + "published": { + "name": "已发布且公众可见" + } + } + }, + "basicInfo": { + "title": "基本信息", + "description": "定义公司名称和状态页指向的子域名。", + "option": { + "name": { + "label": "公司名称", + "placeholder": "Acme Inc." + }, + "url": { + "label": "您的状态页地址", + "placeholder": "my-status-page" + } + } + }, + "monitors": { + "title": "监控", + "description": "选择要在状态页上显示的监控。", + "noMonitors": "未选择监控", + "option": { + "monitors": { + "label": "选择监控", + "placeholder": "搜索并选择监控..." + } + } + }, + "timezone": { + "title": "时区", + "description": "选择状态页显示的时区。", + "option": { + "timezone": { + "label": "时区", + "placeholder": "选择时区..." + } + } + }, + "appearance": { + "title": "外观", + "description": "定义公共状态页的默认外观。", + "option": { + "logo": { + "label": "Logo" + }, + "color": { + "label": "品牌颜色" + } + } + }, + "features": { + "title": "功能", + "description": "配置状态页上显示的信息。", + "option": { + "showCharts": { + "label": "显示响应时间图表" + }, + "showUptimePercentage": { + "label": "显示正常运行时间百分比" + }, + "showAdminLoginLink": { + "label": "显示管理员登录链接" + }, + "showInfrastructure": { + "label": "显示基础设施指标" + } + } + } + }, + "header": { + "title": "状态页面", + "description": "发布公开页面,向客户和利益相关者展示实时运行情况和事件历史。" + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "搜索监控..." + } + }, + "table": { + "headers": { + "responseTime": "响应时间" + } + }, + "fallback": { + "actionButton": "创建监控!", + "title": "暂无运行时间监控器", + "description": "监控 HTTP 端点、Ping、容器和端口 — 一旦故障即刻收到提醒。" + }, + "header": { + "title": "运行时间监控器", + "description": "监控 HTTP 端点、Ping、容器和端口 — 一旦故障即刻收到提醒。" + } } } } diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index e5ee58e58..a15f7ab7d 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -1,1112 +1,1447 @@ { - "submit": "提交", - "title": "名稱", - "distributedStatusHeaderText": "實時、真實設備覆蓋", - "distributedStatusSubHeaderText": "由全球數百萬個設備提供支持,您可以按全球區域、國家或城市查看系統效能", - "settingsDisabled": "已停用", - "settingsSuccessSaved": "設定已成功儲存", - "settingsFailedToSave": "無法儲存設定", - "settingsStatsCleared": "統計數據已成功清除", - "settingsFailedToClearStats": "無法清除統計數據", - "settingsMonitorsDeleted": "已成功刪除所有監視器", - "settingsFailedToDeleteMonitors": "無法刪除所有監視器", - "starPromptTitle": "星標Checkmate", - "starPromptDescription": "查看最新版本發布及在 GitHub 上幫助社區成長", - "https": "HTTPS", - "http": "HTTP", - "monitor": "監控", - "aboutus": "關於我們", - "now": "現在", - "delete": "刪除", - "configure": "設定", - "responseTime": "回應時間", - "ms": "毫秒", - "bar": "條", - "area": "區域", - "country": "國家", - "city": "城市", - "response": "回應", - "monitorStatusUp": "", - "monitorStatusDown": "", - "webhookSendSuccess": "Webhook 通知發送成功", - "webhookSendError": "", - "webhookUnsupportedPlatform": "不支援的平台: {platform}", - "distributedRightCategoryTitle": "監控", - "distributedStatusServerMonitors": "伺服器監控", - "distributedStatusServerMonitorsDescription": "監控相關伺服器狀態", - "distributedUptimeCreateSelectURL": "你可以在這裡選擇主機的 URL,同時選擇監視器類型。", - "distributedUptimeCreateChecks": "要執行的檢查項目", - "distributedUptimeCreateChecksDescription": "你可以在新增網站後,隨時新增或移除檢查項目。", - "distributedUptimeCreateIncidentNotification": "事故通知", - "distributedUptimeCreateIncidentDescription": "事故發生時,向用戶發送通知。", - "distributedUptimeCreateAdvancedSettings": "進階設定", - "distributedUptimeDetailsNoMonitorHistory": "此監控尚無檢查記錄。", - "distributedUptimeDetailsStatusHeaderUptime": "正常運行時間:", - "distributedUptimeDetailsStatusHeaderLastUpdate": "最後更新", - "notifications": { - "enableNotifications": "", - "testNotification": "測試通知", - "addOrEditNotifications": "新增或編輯通知", - "slack": { - "label": "Slack", - "description": "要啟用 Slack 通知,請建立一個 Slack 應用程式並啟用傳入 Webhook。完成後,請在此提供 Webhook URL 即可。", - "webhookLabel": "Webhook URL", - "webhookPlaceholder": "https://hooks.slack.com/services/...", - "webhookRequired": "Slack webhook URL is required" - }, - "discord": { - "label": "Discord", - "description": "", - "webhookLabel": "Discord Webhook URL", - "webhookPlaceholder": "https://discord.com/api/webhooks/...", - "webhookRequired": "" - }, - "telegram": { - "label": "Telegram", - "description": "要啟用 Telegram 通知,請使用 BotFather 用於創建和管理 Telegram 的官方機器來建立一個 Telegram 機器人。接著,取得 API 金鑰和聊天室 ID,並在此處填寫。", - "tokenLabel": "你的機器人金鑰", - "tokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", - "chatIdLabel": "你的聊天 ID", - "chatIdPlaceholder": "-1001234567890", - "fieldsRequired": "" - }, - "webhook": { - "label": "Webhooks", - "description": "你可以設定自訂 webhook,在事件發生時接收通知。", - "urlLabel": "Webhook URL", - "urlPlaceholder": "https://your-server.com/webhook", - "urlRequired": "" - }, - "testNotificationDevelop": "測試通知 2", - "integrationButton": "", - "testSuccess": "", - "testFailed": "", - "unsupportedType": "", - "networkError": "", - "fallback": { - "title": "", - "checks": [ - "提醒團隊有關停機或性能問題", - "讓工程師知道事件發生時的情況", - "讓管理員了解系統變更情況" - ], - "actionButton": "讓我們建立您的第一個通知頻道!" - }, - "createButton": "", - "createTitle": "", - "create": { - "success": "", - "failed": "" - }, - "fetch": { - "success": "", - "failed": "" - }, - "delete": { - "success": "", - "failed": "" - }, - "edit": { - "success": "", - "failed": "" - }, - "test": { - "success": "", - "failed": "" - } - }, - "testLocale": "測試語言環境", - "add": "新增", - "monitors": "", - "distributedUptimeStatusCreateStatusPage": "", - "distributedUptimeStatusCreateStatusPageAccess": "", - "distributedUptimeStatusCreateStatusPageReady": "", - "distributedUptimeStatusBasicInfoHeader": "基本資料", - "distributedUptimeStatusBasicInfoDescription": "", - "distributedUptimeStatusLogoHeader": "標誌", - "distributedUptimeStatusLogoDescription": "", - "distributedUptimeStatusLogoUploadButton": "上傳標誌", - "distributedUptimeStatusStandardMonitorsHeader": "", - "distributedUptimeStatusStandardMonitorsDescription": "", - "distributedUptimeStatusCreateYour": "", - "distributedUptimeStatusEditYour": "", - "distributedUptimeStatusPublishedLabel": "", - "distributedUptimeStatusCompanyNameLabel": "公司名稱", - "distributedUptimeStatusPageAddressLabel": "", - "distributedUptimeStatus30Days": "30日", - "distributedUptimeStatus60Days": "60日", - "distributedUptimeStatus90Days": "90日", - "distributedUptimeStatusPageNotSetUp": "", - "distributedUptimeStatusContactAdmin": "", - "distributedUptimeStatusPageNotPublic": "", - "distributedUptimeStatusPageDeleteDialog": "", - "distributedUptimeStatusPageDeleteConfirm": "", - "distributedUptimeStatusPageDeleteDescription": "", - "distributedUptimeStatusDevices": "設備", - "distributedUptimeStatusUpt": "UPT", - "distributedUptimeStatusUptBurned": "", - "distributedUptimeStatusUptLogo": "", - "incidentsTableNoIncidents": "", - "incidentsTablePaginationLabel": "", - "incidentsTableMonitorName": "", - "incidentsTableStatus": "", - "incidentsTableDateTime": "", - "incidentsTableStatusCode": "", - "incidentsTableMessage": "", - "incidentsOptionsHeader": "", - "incidentsOptionsHeaderFilterBy": "", - "incidentsOptionsHeaderFilterAll": "", - "incidentsOptionsHeaderFilterDown": "", - "incidentsOptionsHeaderFilterCannotResolve": "", - "incidentsOptionsHeaderShow": "", - "incidentsOptionsHeaderLastHour": "", - "incidentsOptionsHeaderLastDay": "", - "incidentsOptionsHeaderLastWeek": "", - "incidentsOptionsPlaceholderAllServers": "", - "infrastructureCreateYour": "", - "infrastructureCreateGeneralSettingsDescription": "", - "infrastructureServerRequirement": "", - "infrastructureCustomizeAlerts": "", - "infrastructureAlertNotificationDescription": "", - "infrastructureCreateMonitor": "", - "infrastructureProtocol": "", - "infrastructureServerUrlLabel": "", - "infrastructureDisplayNameLabel": "", - "infrastructureAuthorizationSecretLabel": "", - "gb": "GB", - "mb": "MB", - "mem": "Mem", - "memoryUsage": "", - "cpu": "CPU", - "cpuUsage": "CPU 使用量", - "cpuTemperature": "CPU 溫度", - "diskUsage": "", - "used": "", - "total": "", - "cores": "核", - "frequency": "", - "status": "", - "cpuPhysical": "CPU (實體核心)", - "cpuLogical": "CPU (邏輯核心)", - "cpuFrequency": "CPU 頻率", - "avgCpuTemperature": "平均 CPU 溫度", - "memory": "", - "disk": "", - "uptime": "", - "os": "OS", - "host": "", - "actions": "行動", - "integrations": "", - "integrationsPrism": "", - "integrationsSlack": "Slack", - "integrationsSlackInfo": "", - "integrationsDiscord": "", - "integrationsDiscordInfo": "", - "integrationsZapier": "", - "integrationsZapierInfo": "", - "commonSave": "儲存", - "createYour": "", - "createMonitor": "", - "pause": "", - "resume": "", - "editing": "", - "url": "UR:", - "access": "存取", - "timezone": "時區", - "features": "", - "administrator": "系統管理員?", - "loginHere": "", - "displayName": "", - "urlMonitor": "", - "portToMonitor": "", - "websiteMonitoring": "網站監控", - "websiteMonitoringDescription": "", - "pingMonitoring": "", - "pingMonitoringDescription": "", - "dockerContainerMonitoring": "", - "dockerContainerMonitoringDescription": "", - "portMonitoring": "", - "portMonitoringDescription": "", - "createMaintenanceWindow": "", - "createMaintenance": "", - "editMaintenance": "", - "maintenanceWindowName": "", - "friendlyNameInput": "", - "friendlyNamePlaceholder": "", - "maintenanceRepeat": "", - "maintenance": "", - "duration": "", - "addMonitors": "新增監視器", - "window": "", - "cancel": "取消", - "message": "", - "low": "低", - "high": "高", - "statusCode": "", - "date&Time": "日期與時間", - "type": "類型", - "statusPageName": "", - "publicURL": "", - "repeat": "", - "edit": "", - "createA": "", - "remove": "", - "maintenanceWindowDescription": "", - "startTime": "", - "timeZoneInfo": "", - "monitorsToApply": "", - "nextWindow": "", - "notFoundButton": "", - "pageSpeedConfigureSettingsDescription": "", - "monitorDisplayName": "", - "whenNewIncident": "", - "notifySMS": "", - "notifyEmails": "", - "seperateEmails": "", - "checkFrequency": "檢查頻率", - "matchMethod": "", - "expectedValue": "", - "deleteDialogTitle": "您確定要刪除此監視器嗎?", - "deleteDialogDescription": "一旦刪除,該監視器無法恢復。", - "pageSpeedMonitor": "", - "shown": "", - "ago": "", - "companyName": "公司名稱", - "pageSpeedDetailsPerformanceReport": "", - "pageSpeedDetailsPerformanceReportCalculator": "", - "checkingEvery": "", - "statusPageCreateSettings": "", - "basicInformation": "基本資料", - "statusPageCreateBasicInfoDescription": "", - "statusPageCreateSelectTimeZoneDescription": "", - "statusPageCreateAppearanceDescription": "", - "statusPageCreateSettingsCheckboxLabel": "", - "statusPageCreateBasicInfoStatusPageAddress": "", - "statusPageCreateTabsContent": "", - "statusPageCreateTabsContentDescription": "", - "statusPageCreateTabsContentFeaturesDescription": "", - "showCharts": "", - "showUptimePercentage": "", - "removeLogo": "", - "statusPageStatus": "", - "statusPageStatusContactAdmin": "", - "statusPageStatusNotPublic": "", - "statusPageStatusNoPage": "", - "statusPageStatusServiceStatus": "", - "deleteStatusPage": "", - "deleteStatusPageConfirm": "", - "deleteStatusPageDescription": "", - "uptimeCreate": "", - "uptimeCreateJsonPath": "", - "uptimeCreateJsonPathQuery": "", - "maintenanceTableActionMenuDialogTitle": "", - "infrastructureEditYour": "", - "infrastructureEditMonitor": "", - "infrastructureMonitorCreated": "", - "infrastructureMonitorUpdated": "", - "errorInvalidTypeId": "", - "errorInvalidFieldId": "", - "inviteNoTokenFound": "", - "pageSpeedWarning": "", - "pageSpeedLearnMoreLink": "", - "pageSpeedAddApiKey": "", - "update": "更新", - "invalidFileFormat": "", - "invalidFileSize": "", - "ClickUpload": "點擊上傳", - "DragandDrop": "", - "MaxSize": "", - "SupportedFormats": "", - "FirstName": "", - "LastName": "", - "EmailDescriptionText": "", - "YourPhoto": "頭像", - "PhotoDescriptionText": "", - "save": "", - "DeleteDescriptionText": "這將刪除帳號以及所有相關的伺服器資料,此操作不可逆轉。", - "DeleteAccountWarning": "刪除您的帳號表示您將無法再次登入,且所有資料都會被移除。此操作不可逆轉。", - "DeleteWarningTitle": "", - "bulkImport": { - "title": "批量匯入", - "selectFileTips": "", - "selectFileDescription": "", - "selectFile": "", - "parsingFailed": "", - "uploadSuccess": "", - "validationFailed": "", - "noFileSelected": "", - "fallbackPage": "", - "invalidFileType": "", - "uploadFailed": "" - }, - "DeleteAccountTitle": "刪除帳戶", - "DeleteAccountButton": "刪除帳戶", - "publicLink": "", - "maskedPageSpeedKeyPlaceholder": "", - "reset": "", - "ignoreTLSError": "", - "tlsErrorIgnored": "", - "ignoreTLSErrorDescription": "", - "createNew": "", - "greeting": { - "prepend": "", - "append": "", - "overview": "" - }, - "roles": { - "superAdmin": "", - "admin": "管理員", - "teamMember": "", - "demoUser": "示範用戶" - }, - "teamPanel": { - "teamMembers": "", - "filter": { - "all": "全部", - "member": "" - }, - "inviteTeamMember": "", - "inviteNewTeamMember": "", - "inviteDescription": "", - "email": "", - "selectRole": "", - "inviteLink": "", - "cancel": "取消", - "noMembers": "", - "getToken": "", - "emailToken": "", - "table": { - "name": "", - "email": "", - "role": "", - "created": "" - }, - "addTeamMember": { - "addMemberMenu": "", - "title": "", - "description": "", - "addButton": "" - }, - "register": "", - "registerToast": { - "success": "", - "dbUserExists": "", - "unknownError": "" - }, - "registerTeamMember": { - "title": "", - "auth": { - "common": { - "inputs": { - "firstName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "lastName": { - "errors": { - "empty": "", - "pattern": "" - } - }, - "email": { - "errors": { - "empty": "", - "invalid": "" - } - }, - "role": { - "errors": { - "empty": "" - } - } - } - } - } - }, - "role": "", - "changeTeamPassword": { - "changePasswordMenu": "", - "title": "", - "description": "", - "success": "" - } - }, - "monitorState": { - "paused": "", - "resumed": "", - "active": "啟用中" - }, - "menu": { - "uptime": "", - "pagespeed": "", - "infrastructure": "", - "incidents": "", - "statusPages": "", - "maintenance": "", - "integrations": "", - "settings": "", - "support": "", - "discussions": "", - "docs": "", - "changelog": "版本變更記錄", - "profile": "", - "password": "", - "team": "", - "logOut": "", - "notifications": "", - "logs": "" - }, - "settingsEmailUser": "", - "state": "", - "statusBreadCrumbsStatusPages": "", - "statusBreadCrumbsDetails": "", - "commonSaving": "儲存中...", - "navControls": "", - "incidentsPageTitle": "", - "passwordPanel": { - "passwordChangedSuccess": "", - "passwordInputIncorrect": "", - "currentPassword": "當前密碼", - "enterCurrentPassword": "", - "newPassword": "", - "enterNewPassword": "", - "confirmNewPassword": "確認新密碼", - "passwordRequirements": "", - "saving": "" - }, - "emailSent": "", - "failedToSendEmail": "", - "settingsTestEmailSuccess": "", - "settingsTestEmailFailed": "", - "settingsTestEmailFailedWithReason": "", - "settingsTestEmailUnknownError": "", - "statusMsg": { - "paused": "", - "up": "", - "down": "", - "pending": "" - }, - "uptimeGeneralInstructions": { - "http": "", - "ping": "", - "docker": "", - "port": "", - "game": "", - "https": "" - }, "common": { - "appName": "Checkmate", - "monitoringAgentName": "", - "buttons": { - "toggleTheme": "" + "auth": { + "roles": { + "admin": "管理員", + "demo": "示範", + "superadmin": "超級管理員", + "user": "使用者" + } }, - "toasts": { - "networkError": "", - "checkConnection": "請檢查您的連線", - "unknownError": "" - } - }, - "auth": { - "common": { - "navigation": { - "continue": "繼續", - "back": "返回" + "alerts": { + "pageSpeedApiKey": { + "content": "警告:您尚未新增 Google PageSpeed API 金鑰。請前往設定新增。若未設定,PageSpeed 監控將無法運作。" + } + }, + "appName": "Checkmate", + "breadcrumbs": { + "details": "詳細資料", + "home": "首頁" + }, + "buttons": { + "addMember": "新增成員", + "cancel": "取消", + "close": "關閉", + "configure": "設定", + "confirm": "確認", + "create": "建立", + "delete": "刪除", + "generateToken": "產生令牌", + "incidents": "事件", + "inviteMember": "邀請成員", + "pause": "暫停", + "resume": "恢復", + "save": "儲存", + "sendInvite": "發送邀請", + "test": "測試", + "testNotifications": "測試通知", + "toggleTheme": "切換淺色和深色模式", + "flushQueue": "清除佇列", + "notFound": "前往主儀表板", + "resetPassword": "重設密碼", + "reset": "重設", + "clear": "清除", + "addDemo": "新增示範監控", + "removeMonitors": "移除監控", + "sendTestEmail": "發送測試郵件", + "exportToJSON": "匯出為 JSON", + "importFromJSON": "從 JSON 匯入", + "clearFilters": "清除篩選條件", + "removeUser": "移除使用者" + }, + "charts": { + "labels": { + "averageResponseTime": "平均回應時間", + "downtime": "停機時間", + "high": "高", + "low": "低", + "uptime": "正常運行時間" }, - "inputs": { - "email": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "invalid": "" - } - }, - "password": { - "label": "", - "rules": { - "length": { - "beginning": "必須至少", - "highlighted": "" - }, - "special": { - "beginning": "必須至少包含", - "highlighted": "" - }, - "number": { - "beginning": "必須至少包含", - "highlighted": "" - }, - "uppercase": { - "beginning": "必須至少包含", - "highlighted": "" - }, - "lowercase": { - "beginning": "必須至少包含", - "highlighted": "" - }, - "match": { - "beginning": "", - "highlighted": "" - } - }, - "errors": { - "empty": "", - "length": "", - "uppercase": "", - "lowercase": "", - "number": "", - "special": "", - "incorrect": "" - } - }, - "passwordConfirm": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "different": "" - } - }, - "firstName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" - } - }, - "lastName": { - "label": "", - "placeholder": "", - "errors": { - "empty": "", - "length": "", - "pattern": "" + "histogram": { + "avg": "平均:{{value}} ms", + "max": "最大:{{value}} ms" + } + }, + "dialogs": { + "delete": { + "description": "此操作無法復原。", + "title": "您確定要刪除嗎?" + } + }, + "labels": { + "active": "啟用中", + "paused": "已暫停", + "na": "N/A", + "resolved": "已解決", + "responseTime": "回應時間" + }, + "form": { + "role": { + "option": { + "role": { + "label": "角色" } } }, - "errors": { - "validation": "" - }, - "fields": { - "password": { - "errors": { - "incorrect": "" + "name": { + "option": { + "firstName": { + "label": "名字", + "placeholder": "輸入您的名字" + }, + "lastName": { + "label": "姓氏", + "placeholder": "輸入您的姓氏" } - }, - "role": { - "errors": { - "min": "" + } + }, + "email": { + "option": { + "email": { + "label": "電子郵件", + "placeholder": "you@example.com" } } } }, - "login": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "" - }, - "links": { - "forgotPassword": "", - "register": "", - "forgotPasswordLink": "", - "registerLink": "" - }, - "toasts": { - "success": "歡迎回來!您已成功登入。", - "incorrectPassword": "" - }, + "table": { + "empty": "這裡沒有資料", + "headers": { + "actions": "行動", + "dateTime": "日期和時間", + "message": "訊息", + "monitor": "監控", + "monitorId": "監控 ID", + "name": "名稱", + "status": "狀態", + "type": "類型", + "url": "URL", + "interval": "間隔", + "active": "啟用中", + "responseTime": "回應時間" + } + } + }, + "components": { + "imageUpload": { + "clickToUpload": "點擊上傳", + "dragAndDrop": "或拖放檔案", + "supportedFormats": "支援的格式", + "maxSize": "最大大小", + "orDragAndDrop": "或拖放檔案", "errors": { - "password": { - "incorrect": "" - } - }, - "welcome": "" + "invalidFileSize": "檔案大小過大!", + "invalidFileFormat": "不支援的檔案格式!" + } }, - "registration": { - "heading": { - "superAdmin": "", - "user": "" - }, - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "" + "headerStatusPageControls": { + "publicLink": "公開連結" + }, + "headerTimeRange": { + "labels": { + "day": "日", + "month": "月", + "recent": "最近", + "week": "週" }, "description": { - "superAdmin": "", - "user": "" + "recent": "顯示過去 2 小時的統計資料。", + "day": "顯示過去 24 小時的統計資料。", + "week": "顯示過去 7 天的統計資料。", + "month": "顯示過去 30 天的統計資料。" + } + }, + "offlineBanner": { + "serverUnreachable": "無法連線至伺服器", + "retry": "重試", + "retrying": "重試中...", + "reconnected": "連線已恢復" + }, + "sidebar": { + "menu": { + "uptime": "正常運行時間", + "pagespeed": "PageSpeed", + "infrastructure": "基礎架構", + "notifications": "通知", + "checks": "檢查", + "incidents": "事件", + "statusPages": "狀態頁面", + "maintenance": "維護", + "logs": "日誌", + "settings": "設定" }, - "gettingStartedButton": { - "superAdmin": "", - "user": "" + "bottomMenu": { + "support": "支援", + "discussions": "討論", + "docs": "文件", + "changelog": "版本變更記錄" }, - "termsAndPolicies": "", + "accountMenu": { + "profile": "個人檔案", + "password": "密碼", + "team": "團隊" + }, + "starPrompt": { + "title": "為 Checkmate 加星", + "description": "查看最新版本並在 GitHub 上幫助社群成長" + }, + "authFooter": { + "navControls": "控制項", + "logOut": "登出", + "roles": { + "superAdmin": "超級管理員", + "admin": "管理員", + "user": "使用者", + "demoUser": "示範用戶" + } + } + }, + "starPrompt": { + "title": "為 Checkmate 加星", + "description": "查看最新版本並在 GitHub 上幫助社群成長" + }, + "pageHeader": { "links": { - "login": "" - }, - "toasts": { - "success": "歡迎!您的帳號已成功建立。" - }, - "welcome": "" - }, - "forgotPassword": { - "heading": "", - "subheadings": { - "stepOne": "", - "stepTwo": "", - "stepThree": "", - "stepFour": "" - }, - "buttons": { - "openEmail": "", - "resetPassword": "" - }, - "imageAlts": { - "passwordKey": "", - "email": "", - "lock": "", - "passwordConfirm": "" - }, - "links": { - "login": "", - "resend": "" - }, - "toasts": { - "sent": "", - "emailNotFound": "", - "redirect": "", - "success": "您的密碼已成功重設。", - "error": "" + "support": "支援", + "discussions": "討論", + "docs": "文件", + "changelog": "更新日誌" } } }, - "errorPages": { - "serverUnreachable": { - "toasts": { - "reconnected": "", - "stillUnreachable": "" + "pages": { + "notFound": { + "title": "哎呀!您的壽司掉了!", + "subtitle": "該 URL 不存在,或您沒有存取權限。" + }, + "account": { + "tabs": { + "profile": "個人檔案", + "password": "密碼", + "team": "團隊" }, - "alertBox": "伺服器連線錯誤", - "description": "我們無法連接到伺服器。請檢查您的網路連線,若問題持續,請確認您的部署設定是否正確。", - "retryButton": { - "default": "重新嘗試連線", - "processing": "" + "form": { + "name": { + "title": "名稱", + "description": "更新您的個人資訊" + }, + "photo": { + "title": "個人頭像", + "description": "上傳個人頭像" + }, + "currentPassword": { + "title": "目前密碼", + "description": "輸入您目前的密碼以驗證身份", + "option": { + "label": "目前密碼", + "placeholder": "輸入目前密碼" + } + }, + "newPassword": { + "title": "新密碼", + "description": "請選擇至少 8 個字元的強密碼", + "option": { + "newPassword": { + "label": "新密碼", + "placeholder": "輸入新密碼" + }, + "confirm": { + "label": "確認密碼", + "placeholder": "確認新密碼" + } + } + }, + "deleteAccount": { + "title": "刪除帳號", + "description": "此操作為永久性,無法復原" + } + }, + "team": { + "filter": { + "placeholder": "依角色篩選", + "all": "全部", + "admin": "管理員", + "member": "成員" + }, + "table": { + "headers": { + "email": "電子郵件", + "role": "角色", + "created": "建立時間" + } + }, + "addMember": { + "title": "註冊新團隊成員", + "description": "建立新的使用者帳號。建立後請安全地分享登入憑證。" + }, + "invite": { + "title": "邀請團隊成員", + "description": "發送加入團隊的邀請", + "email": { + "label": "電子郵件地址", + "placeholder": "輸入電子郵件地址" + }, + "role": { + "label": "角色", + "placeholder": "選擇角色" + }, + "linkLabel": "邀請連結" + }, + "fallback": { + "title": "尚無團隊成員", + "description": "透過電子郵件邀請隊友或直接新增他們。一旦加入,他們將與角色一起顯示在此。" + } + }, + "header": { + "title": "帳號", + "description": "管理個人資料、密碼、團隊和工作區設定。" + } + }, + "auth": { + "brandPanel": { + "eyebrow": "開源基礎設施監控", + "tagline": "關注您伺服器的每一步動作。", + "description": "即時追蹤運行狀態、回應時間和事件 - 自主託管,完全屬於您。" + }, + "common": { + "passwordRules": { + "length": { + "beginning": "必須至少", + "highlighted": "8 個字元" + }, + "lowercase": { + "beginning": "必須至少包含", + "highlighted": "一個小寫字母" + }, + "match": { + "beginning": "密碼", + "highlighted": "必須相符" + }, + "number": { + "beginning": "必須至少包含", + "highlighted": "一個數字" + }, + "special": { + "beginning": "必須至少包含", + "highlighted": "一個特殊字元 (!?@#$%^&*()-_=+[]{}|;:'\",./\\~`)" + }, + "uppercase": { + "beginning": "必須至少包含", + "highlighted": "一個大寫字母" + } + }, + "form": { + "option": { + "email": { + "label": "電子郵件", + "placeholder": "me@example.com" + }, + "password": { + "label": "密碼", + "placeholder": "********" + }, + "confirmPassword": { + "label": "確認密碼" + } + } + } + }, + "login": { + "title": "歡迎回到 Checkmate!", + "subtitle": "請登入以繼續", + "submit": "登入", + "links": { + "forgotPassword": { + "text": "忘記密碼?", + "linkText": "重設密碼" + }, + "register": { + "text": "還沒有帳號?", + "linkText": "在此註冊" + } + } + }, + "register": { + "title": "歡迎回到 Checkmate!", + "subtitle": "註冊以開始使用", + "submit": "註冊", + "setupNotice": "此頁面僅在初始設定期間可用。註冊超級管理員後,可以從團隊設定中邀請更多使用者。" + }, + "forgotPassword": { + "title": "忘記密碼了嗎?", + "subtitle": "別擔心,我們會發送重設密碼的說明給您。", + "submit": "請求復原", + "links": { + "login": { + "text": "返回", + "linkText": "登入" + } + } + }, + "setNewPassword": { + "title": "重設您的密碼", + "subtitle": "您的新密碼必須與先前使用過的密碼不同。" + } + }, + "checks": { + "selects": { + "monitor": { + "all": "所有監控" + }, + "status": { + "all": "全部", + "down": "離線", + "up": "上線" + } + }, + "table": { + "empty": "此時間範圍內沒有離線檢查", + "headers": { + "statusCode": "狀態碼", + "location": "位置" + } + }, + "fallback": { + "title": "尚無檢查", + "description": "建立監視器後,每次狀態探測與回應時間都會以一次檢查的形式顯示在此。" + }, + "header": { + "title": "檢查", + "description": "Checkmate 對您的監視器執行的每次探測都會在此顯示,包含狀態碼與回應時間。" + } + }, + "common": { + "monitors": { + "actions": { + "configure": "設定", + "delete": "刪除", + "generateToken": "產生令牌", + "details": "詳細資料", + "incidents": "事件", + "inviteMember": "邀請成員", + "openSite": "開啟網站", + "pause": "暫停", + "resume": "恢復", + "selectAll": "選擇所有監視器", + "selectMonitor": "選擇 {{name}}", + "bulkSelected_one": "已選擇 {{count}} 個監視器", + "bulkSelected_other": "已選擇 {{count}} 個監視器" + }, + "statBoxes": { + "activeFor": "已啟用", + "certificateExpiry": "憑證到期", + "lastCheck": "最後檢查", + "lastResponseTime": "最後回應時間", + "serviceIsDown": "服務已停止" + }, + "status": { + "down": "離線", + "breached": "超過閾值", + "initializing": "初始化中", + "maintenance": "維護中", + "paused": "已暫停", + "total": "總計", + "up": "上線" + }, + "monitorTypes": { + "optionDocker": "Docker", + "optionGame": "遊戲", + "optionHttp": "HTTP(S)", + "optionPing": "Ping", + "optionPort": "連接埠", + "optionPagespeed": "PageSpeed", + "optionHardware": "基礎架構", + "optionGrpc": "gRPC", + "optionWebSocket": "WebSocket" + }, + "bulkPause": { + "paused_one": "已暫停 {{count}} 個監視器", + "paused_other": "已暫停 {{count}} 個監視器", + "resumed_one": "已恢復 {{count}} 個監視器", + "resumed_other": "已恢復 {{count}} 個監視器", + "alreadyPaused_one": "所選監視器已暫停", + "alreadyPaused_other": "所有選定的監視器均已暫停", + "alreadyRunning_one": "所選監視器已經在運行", + "alreadyRunning_other": "所有選定的監視器均已在運行" + } + } + }, + "createMonitor": { + "form": { + "advanced": { + "description": "進階使用案例的選用設定", + "option": { + "advancedMatching": { + "label": "使用進階配對" + }, + "expectedValue": { + "label": "預期值" + }, + "jsonPath": { + "description": "此表達式將對回應的 JSON 資料進行評估,結果將用於與預期值進行配對。查詢語法文件請參閱 jmespath.org。", + "label": "JSONPath 表達式" + }, + "matchMethod": { + "label": "配對方式", + "equal": "等於", + "include": "包含", + "regex": "正規表達式" + } + }, + "title": "進階設定" + }, + "frequency": { + "description": "您希望多久檢查一次此監控的狀態?", + "option": { + "frequency": { + "label": "檢查頻率", + "value": { + "fifteenMinutes": "15 分鐘", + "fifteenSeconds": "15 秒", + "fiveMinutes": "5 分鐘", + "fourMinutes": "4 分鐘", + "oneMinute": "1 分鐘", + "tenMinutes": "10 分鐘", + "thirtyMinutes": "30 分鐘", + "thirtySeconds": "30 秒", + "threeMinutes": "3分鐘", + "twoMinutes": "2 分鐘" + } + } + }, + "title": "檢查頻率" + }, + "general": { + "option": { + "container": { + "label": "容器名稱/ID", + "placeholder": "my-app 或 abcd1234" + }, + "host": { + "label": "主機", + "placeholder": "192.168.1.100 或 example.com" + }, + "name": { + "label": "顯示名稱", + "placeholder": "例如:我的網站" + }, + "secret": { + "label": "授權密鑰", + "placeholder": "輸入您的密鑰" + }, + "url": { + "label": "URL", + "placeholder": "https://www.google.com" + }, + "game": { + "label": "選擇遊戲", + "placeholder": "選擇遊戲" + }, + "port": { + "label": "監控的連接埠", + "placeholder": "80" + }, + "grpcServiceName": { + "label": "服務名稱", + "placeholder": "例如:my.service.v1(留空表示整體健康狀態)" + }, + "wsUrl": { + "label": "WebSocket URL", + "placeholder": "wss://example.com/socket" + }, + "captureUrl": { + "label": "Capture 端點", + "placeholder": "http://192.168.1.10:5001/api/v1/metrics" + } + }, + "title": "一般設定", + "description": { + "http": "輸入要監控的 URL 或 IP(例如 https://example.com/ 或 192.168.1.100),並新增一個顯示在儀表板上的清晰顯示名稱。", + "ping": "輸入要 Ping 的 IP 位址或主機名稱(例如 192.168.1.100 或 example.com),並新增一個顯示在儀表板上的清晰顯示名稱。", + "port": "輸入伺服器的 URL 或 IP、連接埠號碼,以及顯示在儀表板上的清晰顯示名稱。", + "docker": "輸入 Docker 容器名稱或 ID。您可以使用容器名稱(例如 my-app)或容器 ID(完整的 64 字元 ID 或短 ID)。", + "game": "輸入要 Ping 的 IP 位址或主機名稱及連接埠號碼(例如 192.168.1.100 或 example.com),並選擇遊戲類型。部分遊戲需要在設定檔中啟用伺服器查詢 — 請參閱你的遊戲伺服器文件。詳見支援的遊戲清單了解協定細節。", + "pagespeed": "追蹤您網站的頁面載入效能、Core Web Vitals 和最佳化分數。", + "grpc": "輸入 gRPC 伺服器的主機名稱和連接埠,可選擇指定健康檢查的服務名稱,並新增一個顯示名稱。", + "websocket": "輸入要監控的 WebSocket URL(例如 wss://example.com/socket),並新增一個顯示名稱。", + "hardware": "監控基礎架構的 CPU、記憶體、磁碟使用情況和溫度。Capture 端點 URL 必須包含完整路徑 /api/v1/metrics(例如 http://192.168.1.10:5001/api/v1/metrics)。授權密鑰僅貼上權杖字串——請勿包含 'Bearer'。" + } + }, + "ignoreTls": { + "description": "設定 HTTPS 連線的 TLS/SSL 憑證驗證。", + "option": { + "tls": { + "label": "忽略 TLS/SSL 錯誤" + } + }, + "title": "TLS/SSL 設定" + }, + "incidents": { + "description": "使用滑動視窗來判斷監控何時離線。監控的狀態只有在滑動視窗內的檢查百分比達到指定值時才會變更。", + "option": { + "checks": { + "label": "滑動視窗中的檢查次數" + }, + "percentage": { + "label": "滑動視窗中有多少百分比的檢查失敗/成功後,監控狀態才會變更?" + } + }, + "title": "事件" + }, + "notifications": { + "description": "選擇您要使用的通知頻道", + "title": "通知" + }, + "type": { + "description": "選擇要執行的檢查類型", + "optionDockerDescription": "使用 Docker 監控容器是否正在執行。", + "optionGameDescription": "監控特定遊戲伺服器是否上線。", + "optionGrpcDescription": "使用標準健康檢查協定監控 gRPC 服務。", + "optionWebSocketDescription": "監控 WebSocket 端點的連線健康狀態和回應時間。", + "optionHttpDescription": "使用 HTTP(S) 監控您的網站或 API 端點。", + "optionPingDescription": "使用 ICMP Ping 監控伺服器是否上線。", + "optionPortDescription": "監控伺服器上的特定連接埠是否開啟。", + "title": "類型" + }, + "thresholds": { + "title": "警報閾值", + "description": "定義此硬體監控應觸發警報的閾值。", + "option": { + "cpuThreshold": { + "label": "CPU 警報閾值 (%)" + }, + "memoryThreshold": { + "label": "記憶體警報閾值 (%)" + }, + "diskThreshold": { + "label": "磁碟警報閾值 (%)" + }, + "tempThreshold": { + "label": "溫度警報閾值 (°C)" + } + } + }, + "geoChecks": { + "title": "地理分散式檢查", + "description": "從多個地理位置執行檢查,以監控全球可用性和效能。", + "option": { + "enabled": { + "label": "啟用地理分散式檢查" + }, + "locations": { + "label": "位置", + "placeholder": "選擇位置", + "options": { + "EU": "歐洲", + "NA": "北美洲", + "AS": "亞洲", + "SA": "南美洲", + "AF": "非洲", + "OC": "大洋洲" + } + }, + "interval": { + "label": "檢查間隔", + "value": { + "fiveMinutes": "5 分鐘", + "tenMinutes": "10 分鐘", + "fifteenMinutes": "15 分鐘", + "thirtyMinutes": "30 分鐘" + } + } + } + }, + "url": { + "title": "在狀態頁面上顯示監控 IP/URL", + "description": "在公開狀態頁面上顯示監控的 IP 位址或 URL。若停用此選項,將僅顯示監控名稱以保護敏感資訊。", + "option": { + "showURL": { + "label": "在狀態頁面上顯示 IP/URL", + "enabled": "啟用", + "disabled": "停用" + } + } + }, + "stats": { + "title": "監控歷史", + "description": "清除團隊的所有監控歷史和統計資料。此操作無法復原。", + "buttonText": "清除所有統計資料", + "dialog": { + "title": "清除所有監控歷史?", + "description": "這將永久刪除團隊的所有監控歷史、統計資料和檢查資料。此操作無法復原。", + "confirm": "是的,清除所有統計資料" + } + } + } + }, + "editUser": { + "form": { + "roles": { + "title": "角色", + "description": "為使用者指派角色。可以選擇多個角色。" + } + }, + "dialog": { + "removeUser": { + "title": "移除使用者", + "content": "您確定要將 {{name}} 從團隊中移除嗎?此操作無法復原。" + } + } + }, + "incidents": { + "fallback": { + "title": "無事件 — 所有系統健康", + "description": "當監視器宕機或恢復時,事件將連同時間、監視器詳情與解決紀錄一起顯示在此。" + }, + "dialog": { + "resolveIncident": { + "title": "解決事件", + "option": { + "comment": { + "label": "備註(選填)", + "placeholder": "新增關於解決方案的備註..." + } + } + }, + "details": { + "analysis": "事件分析", + "comment": "備註:", + "detailsLabel": "詳細資料", + "downtime": "停機時間:", + "message": "訊息:", + "monitor": "監控:", + "overview": "概覽", + "resolutionDetails": "解決詳情", + "resolutionType": "類型:", + "resolutionTypes": { + "automatic": "自動", + "manual": "手動" + }, + "resolve": "解決事件", + "resolvedAt": "解決於:", + "resolvedBy": "解決者:", + "startedAt": "開始於:", + "status": "狀態:", + "statusCode": "狀態碼:", + "timeline": "時間軸", + "title": "事件詳情", + "url": "URL:" + } + }, + "filters": { + "allMonitors": "所有監控", + "monitor": "監控", + "resolutionType": "解決類型", + "resolutionTypes": { + "manual": "手動", + "automatic": "自動", + "all": "全部" + } + }, + "summaryCard": { + "activeIncidents": { + "title": "進行中的事件", + "active_zero": "沒有進行中的事件", + "active_one": "{{count}} 個進行中的事件", + "active_other": "{{count}} 個進行中的事件" + }, + "incidentStats": { + "avgResolutionTime": "平均解決時間", + "mostAffectedMonitor": "受影響最多的監控", + "title": "事件統計", + "totalIncidents": "事件總數" + }, + "latestIncidents": { + "title": "最新事件" + } + }, + "table": { + "actions": { + "details": "詳細資料", + "goToMonitor": "前往監控", + "resolveManually": "手動解決" + }, + "activeIncidents": "進行中的事件", + "headers": { + "endTime": "結束時間", + "resolutionType": "解決類型", + "startTime": "開始時間", + "statusCode": "狀態碼" + }, + "resolvedIncidents": "已解決的事件", + "status": { + "active": "啟用中", + "resolved": "已解決" + } + }, + "header": { + "title": "事件", + "description": "當監視器宕機或恢復時,事件將連同時間、監視器詳情與解決紀錄一起顯示在此。" + } + }, + "infrastructure": { + "charts": { + "labels": { + "cpu": "CPU 使用率", + "disk": "磁碟使用率", + "memory": "記憶體使用率", + "netBytesRecv": "{{name}} - 接收位元組", + "netBytesSent": "{{name}} - 發送位元組", + "netNoTraffic": "Capture 回報所有介面的流量皆為零。當 Capture 在只能看到回環介面的容器中執行時,這是正常情況——請直接在主機上執行 Capture 才能看到實際的網路活動。", + "temp": "溫度" + } + }, + "gauges": { + "cpu": { + "lowerLabel": "最大頻率", + "title": "CPU 使用率", + "upperLabel": "目前頻率" + }, + "disk": { + "lowerLabel": "可用", + "title": "磁碟 {{idx}} 使用率", + "upperLabel": "已使用" + }, + "memory": { + "lowerLabel": "可用", + "title": "記憶體使用率", + "upperLabel": "已使用" + } + }, + "statBoxes": { + "avgCpuTemperature": "平均 CPU 溫度", + "cpuFrequency": "CPU 頻率", + "cpuLogical": "CPU (邏輯核心)", + "cpuPhysical": "CPU (實體核心)", + "disk": "磁碟", + "memory": "記憶體", + "os": "作業系統" + }, + "table": { + "headers": { + "cpu": "CPU", + "disk": "磁碟", + "memory": "記憶體" + } + }, + "tabs": { + "labels": { + "network": "網路", + "overview": "概覽" + } + }, + "fallback": { + "actionButton": "建立監控!", + "title": "尚無基礎設施監視器", + "description": "追蹤伺服器效能、找出瓶頸,透過即時基礎設施監控確保可靠性。" + }, + "header": { + "title": "基礎設施監視器", + "description": "追蹤伺服器效能、找出瓶頸,透過即時基礎設施監控確保可靠性。" + } + }, + "logs": { + "fallback": { + "title": "尚無日誌", + "description": "Checkmate 運行時的伺服器活動、警告與錯誤將顯示在此。" + }, + "tabs": { + "diagnostics": "診斷", + "logs": "日誌", + "queue": "工作佇列" + }, + "logLevelSelect": { + "label": "日誌等級" + }, + "jobQueue": "工作佇列", + "jobQueueExplainer": "反白的列是目前正由工作程序處理的工作。", + "failedJobs": "失敗的工作", + "failedJobsExplainer": "依監控的最近失敗 — 最近失敗時間、失敗次數及原因。", + "noLogs": "找不到日誌", + "metrics": { + "jobs": "工作", + "activeJobs": "進行中的工作", + "failingJobs": "失敗中的工作", + "totalRuns": "總執行次數", + "totalFailures": "總失敗次數" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "事件迴圈延遲", + "uptime": "正常運行時間", + "usedHeapSize": "已使用堆積大小", + "totalHeapSize": "堆積總大小", + "osMemoryLimit": "作業系統記憶體限制" + }, + "gauges": { + "heapAllocation": "堆積配置", + "heapUsage": "堆積使用率", + "heapUtilization": "堆積利用率", + "instantCpuUsage": "即時 CPU 使用率", + "availableMemoryPercentage": "可用記憶體百分比", + "allocatedPercentage": "已配置百分比", + "usedSPercentage": "CPU 使用 1 秒百分比", + "total": "總計", + "used": "已使用" + }, + "sections": { + "runtimeStats": { + "title": "執行階段統計", + "description": "Node.js 程序和主機的快照。每 5 秒更新一次。" + }, + "memoryCpu": { + "title": "記憶體與 CPU", + "description": "程序如何使用其分配的記憶體和主機 CPU。" + } + } + }, + "table": { + "headers": { + "timestamp": "時間戳", + "level": "等級", + "service": "服務", + "method": "方法", + "monitorId": "監控 ID", + "runCount": "執行次數", + "failCount": "失敗次數", + "lastRunAt": "最後執行於", + "lockedAt": "鎖定於", + "lastFinishedAt": "最後完成於", + "lastRunTook": "最後執行耗時", + "lastFailedAt": "最後失敗於", + "failReason": "失敗原因" + } + }, + "queueFallback": { + "title": "佇列中沒有任務", + "description": "已排定或正在執行的監視器檢查將顯示在此。空佇列代表一切都是最新狀態。" + }, + "header": { + "title": "日誌與佇列", + "description": "在一處查看伺服器日誌、監視器作業佇列和執行時診斷。" + } + }, + "maintenanceWindow": { + "fallback": { + "actionButton": "建立維護時段!", + "title": "尚無排定的維護時段", + "description": "安排計畫性停機,抑制警報,並讓團隊和使用者知曉預期狀況。" + }, + "table": { + "headers": { + "nextWindow": "下一個時段", + "repeat": "重複" + } + }, + "form": { + "general": { + "title": "一般設定", + "description": "為維護時段設定名稱和重複選項。", + "option": { + "name": { + "label": "名稱", + "placeholder": "例如:每週維護" + }, + "repeat": { + "label": "重複" + } + } + }, + "startDate": { + "title": "開始日期", + "description": "選擇維護時段的開始日期。", + "option": { + "startDate": { + "label": "開始日期" + } + } + }, + "startTime": { + "title": "開始時間", + "description": "設定維護時段的開始時間和持續時間。所有數值為 UTC 時間", + "option": { + "duration": { + "label": "持續時間" + }, + "startTime": { + "label": "開始時間" + } + }, + "monitors": { + "title": "監控", + "description": "維護時段應套用到的監控", + "option": { + "addMonitors": { + "label": "新增監控" + } + } + } + } + }, + "header": { + "title": "維護時段", + "description": "安排計畫性停機,抑制警報,並讓團隊和使用者知曉預期狀況。" + } + }, + "notifications": { + "fallback": { + "actionButton": "建立頻道", + "title": "尚無通知頻道", + "description": "連接 Slack、Discord、電子郵件、Webhook 等,讓事件發生時正確的人立即收到通知。" + }, + "form": { + "accessToken": { + "optionAccessToken": "存取令牌", + "placeholder": "syt_YWxleF9ob2xsaWRheQ_VmtScmV0U2VjcmV0S2V5_abc123" + }, + "address": { + "description": "通知將發送到的地址。", + "optionAddress": "地址", + "placeholderEmail": "example@example.com", + "placeholderWebhook": "https://your-server.com/webhook", + "title": "地址" + }, + "homeServer": { + "optionHomeServer": "主伺服器", + "placeholder": "example.com" + }, + "matrix": { + "description": "設定您的 Matrix 主伺服器連線以接收通知。", + "title": "Matrix 設定" + }, + "notificationName": { + "description": "通知頻道的描述性名稱", + "optionName": "頻道名稱", + "placeholder": "例如:正式環境警報", + "title": "頻道名稱" + }, + "pagerDuty": { + "description": "用於接收警報的 PagerDuty 整合金鑰。", + "optionIntegrationKey": "整合金鑰", + "placeholder": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "title": "整合金鑰" + }, + "roomId": { + "optionRoomId": "房間 ID", + "placeholder": "!abcdefg:matrix.org" + }, + "type": { + "description": "選擇要建立的通知頻道類型。", + "optionType": "類型", + "title": "頻道類型" + }, + "telegram": { + "title": "Telegram 設定", + "description": "要啟用 Telegram 通知,請使用 BotFather 用於創建和管理 Telegram 的官方機器來建立一個 Telegram 機器人。接著,取得 API 金鑰和聊天室 ID,並在此處填寫。", + "optionBotToken": "機器人令牌", + "placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ", + "optionChatId": "聊天室 ID", + "placeholderChatId": "-1001234567890" + }, + "pushover": { + "title": "Pushover 設定", + "description": "輸入您的 Pushover 應用程式權杖和使用者金鑰。", + "optionAppToken": "應用程式權杖", + "placeholderAppToken": "輸入應用程式權杖", + "optionUserKey": "使用者金鑰", + "placeholderUserKey": "輸入使用者金鑰" + }, + "twilio": { + "title": "Twilio 設定", + "description": "輸入 Twilio 帳戶憑證以傳送簡訊。", + "optionAccountSid": "帳戶 SID", + "placeholderAccountSid": "輸入帳戶 SID", + "optionAuthToken": "驗證權杖", + "placeholderAuthToken": "輸入驗證權杖", + "optionFromNumber": "發送號碼", + "placeholderFromNumber": "+1234567890", + "optionToNumber": "接收號碼", + "placeholderToNumber": "+1234567890" + } + }, + "table": { + "headers": { + "destination": "目的地" + } + }, + "header": { + "title": "通知", + "description": "連接 Slack、Discord、電子郵件、Webhook 等,讓事件發生時正確的人立即收到通知。" + } + }, + "pageSpeed": { + "charts": { + "common": { + "cls": "累計版面配置位移 (CLS)", + "fcp": "首次內容繪製 (FCP)", + "lcp": "最大內容繪製 (LCP)", + "si": "速度指數 (SI)", + "tbt": "總阻塞時間 (TBT)" + }, + "legend": { + "title": "PageSpeed 報告", + "weight": "權重" + }, + "pie": { + "title": "效能報告" + } + }, + "table": { + "headers": { + "pageSpeedScore": "PageSpeed 分數" + } + }, + "fallback": { + "actionButton": "建立監控!", + "title": "尚無 PageSpeed 監視器", + "description": "持續追蹤 Core Web Vitals 與 Lighthouse 評分,在使用者察覺前發現效能退化。" + }, + "header": { + "title": "PageSpeed 監視器", + "description": "持續追蹤 Core Web Vitals 與 Lighthouse 評分,在使用者察覺前發現效能退化。" + } + }, + "settings": { + "form": { + "timezone": { + "title": "顯示時區", + "description": "選擇用於在整個應用程式中顯示日期和時間的時區。", + "option": { + "timezone": { + "label": "顯示時區" + } + } + }, + "ui": { + "title": "外觀", + "description": "切換淺色和深色模式、變更語言或自訂圖表顯示類型。", + "option": { + "theme": { + "label": "佈景主題模式", + "light": "淺色", + "dark": "深色" + }, + "language": { + "label": "語言" + }, + "chartType": { + "label": "圖表類型", + "histogram": "直方圖", + "heatmap": "熱力圖" + } + } + }, + "pagespeed": { + "title": "Google PageSpeed API 金鑰", + "description": "輸入您的 Google PageSpeed API 金鑰以啟用 Google PageSpeed 監控。點擊重設以更新金鑰。", + "option": { + "apiKey": { + "label": "PageSpeed API 金鑰", + "labelSet": "API 金鑰已設定。點擊重設以變更。", + "placeholder": "輸入您的 Google PageSpeed API 金鑰" + } + } + }, + "url": { + "title": "在狀態頁面上顯示監控 IP/URL", + "description": "在公開狀態頁面上顯示監控的 IP 位址或 URL。若停用此選項,將僅顯示監控名稱以保護敏感資訊。", + "option": { + "showURL": { + "label": "在狀態頁面上顯示 IP/URL", + "enabled": "啟用", + "disabled": "停用" + } + } + }, + "stats": { + "title": "監控歷史", + "description": "清除團隊的所有監控歷史和統計資料。此操作無法復原。", + "option": { + "clear": { + "label": "清除所有統計資料。此操作無法復原。" + } + }, + "dialog": { + "title": "清除所有監控歷史?", + "description": "此操作無法復原" + } + }, + "retention": { + "title": "檢查資料保留", + "description": "設定檢查資料在自動清除之前的保留時間。", + "option": { + "days": { + "label": "保留期間(天)", + "unlimited": "無限制" + } + } + }, + "thresholds": { + "title": "全域閾值", + "description": "設定基礎架構監控的全域 CPU、記憶體、磁碟和溫度閾值。這些設定將套用到所有硬體監控,除非被個別覆寫。", + "option": { + "cpu": { + "label": "CPU 閾值 (%)", + "placeholder": "1 - 100" + }, + "memory": { + "label": "記憶體閾值 (%)", + "placeholder": "1 - 100" + }, + "disk": { + "label": "磁碟閾值 (%)", + "placeholder": "1 - 100" + }, + "temperature": { + "label": "溫度閾值 (°C)", + "placeholder": "1 - 150" + } + } + }, + "email": { + "title": "電子郵件設定", + "description": "設定系統的電子郵件設定。此設定用於發送通知和警報。", + "descriptionTransport": "這會為 NodeMailer 建立 SMTP 傳輸", + "descriptionTransportLink": "在此查看規格", + "option": { + "host": { + "label": "電子郵件主機 - 要連線的主機名稱或 IP 位址", + "placeholder": "smtp.gmail.com" + }, + "port": { + "label": "電子郵件連接埠 - 要連線的連接埠", + "placeholder": "587" + }, + "address": { + "label": "電子郵件地址 - 用於驗證", + "placeholder": "you@example.com" + }, + "user": { + "label": "電子郵件使用者 - 用於驗證的使用者名稱,若指定則覆寫電子郵件地址", + "placeholder": "若非必要請留空" + }, + "password": { + "label": "電子郵件密碼 - 用於驗證的密碼", + "labelSet": "密碼已設定。點擊重設以變更。", + "placeholder": "輸入您的密碼" + }, + "tlsServername": { + "label": "TLS 伺服器名稱 - 當主機為 IP 時用於 TLS 驗證的選用主機名稱", + "placeholder": "example.com" + }, + "connectionHost": { + "label": "電子郵件連線主機 - 用於 HELO/EHLO 問候的主機名稱", + "placeholder": "localhost" + }, + "secure": { + "label": "使用 SSL(建議):使用 SSL/TLS 加密連線" + }, + "pool": { + "label": "啟用連線池:重複使用現有連線以提升效能" + }, + "ignoreTLS": { + "label": "停用 STARTTLS:即使伺服器支援也不使用 TLS" + }, + "requireTLS": { + "label": "強制 STARTTLS:要求 TLS 升級,若不支援則失敗" + }, + "rejectUnauthorized": { + "label": "拒絕無效憑證:拒絕使用自簽或不受信任憑證的連線" + } + } + }, + "demoMonitors": { + "title": "示範監控", + "description": "新增範例監控以供示範用途。" + }, + "removeMonitors": { + "title": "系統重置", + "description": "從系統中移除所有監視器。", + "dialog": { + "title": "移除所有監控?", + "description": "此操作無法復原。", + "paragraph": "所有檢查歷史、事件、排定的維護時段以及使用這些監控的狀態頁面也將一併被移除。如需保留資料備份,請先匯出監控。" + } + }, + "exportMonitors": { + "title": "匯出監控" + }, + "importExportMonitors": { + "title": "匯入/匯出監控", + "description": "將監控資料匯入或匯出為 JSON 檔案以進行備份或轉移。" + }, + "about": { + "title": "關於", + "developedBy": "由 Bluewave Labs 開發" + }, + "validation": { + "errorMessage": "請修正以下驗證錯誤:" + } + }, + "header": { + "title": "設定", + "description": "設定 SMTP、整合、保留與全域應用程式行為。" + } + }, + "statusPages": { + "deleteSuccess": "狀態頁面已成功刪除", + "fallback": { + "title": "尚無狀態頁面", + "actionButton": "建立狀態頁面!", + "description": "發佈一個公開頁面,向客戶和利害關係人顯示即時的運行狀態與事件歷史。" + }, + "monitorsList": { + "chartTypeHeatmap": "熱力圖", + "chartTypeHistogram": "直方圖", + "noData": "沒有可用資料", + "infrastructure": { + "title": "基礎架構", + "cpu": "CPU", + "memory": "RAM", + "memoryText": "記憶體", + "disk": "磁碟", + "usage": "使用量", + "used": "已使用", + "total": "總計" + }, + "uptime": { + "title": "正常運行時間", + "responseTime": "回應時間" + } + }, + "statusBar": { + "allDown": "所有系統已停機", + "allUp": "所有系統運作正常", + "degraded": "部分系統正在發生問題", + "unknown": "無法判斷系統狀態", + "allBreached": "所有系統已超過閾值", + "allMaintenance": "所有系統正在維護中", + "allPaused": "所有系統已暫停", + "allInitializing": "所有系統正在初始化", + "breachedAndDown": "部分系統超過閾值且已停止", + "breached": "部分系統超過閾值", + "maintenanceAndDown": "部分系統在維護期間停止", + "maintenance": "部分系統正在維護", + "partiallyPaused": "部分系統已暫停", + "initializing": "部分系統正在初始化", + "noMonitors": "尚未設定任何監視器" + }, + "table": { + "headers": { + "name": "狀態頁面名稱", + "url": "公開 URL" + }, + "published": "已發佈", + "unpublished": "未發佈" + }, + "title": "狀態頁面", + "details": { + "empty": { + "title": "這裡還沒有任何內容", + "addMonitor": "新增一個監控以開始使用" + } + }, + "form": { + "access": { + "title": "存取", + "description": "如果您的狀態頁面已準備就緒,可以將其標記為已發佈。", + "option": { + "published": { + "name": "已發佈且公開可見" + } + } + }, + "basicInfo": { + "title": "基本資訊", + "description": "定義公司名稱和狀態頁面指向的子網域。", + "option": { + "name": { + "label": "公司名稱", + "placeholder": "Acme Inc." + }, + "url": { + "label": "您的狀態頁面地址", + "placeholder": "my-status-page" + } + } + }, + "monitors": { + "title": "監控", + "description": "選擇要在狀態頁面上顯示的監控。", + "noMonitors": "未選擇監控", + "option": { + "monitors": { + "label": "選擇監控", + "placeholder": "搜尋並選擇監控..." + } + } + }, + "timezone": { + "title": "時區", + "description": "選擇狀態頁面顯示的時區。", + "option": { + "timezone": { + "label": "時區", + "placeholder": "選擇時區..." + } + } + }, + "appearance": { + "title": "外觀", + "description": "定義公開狀態頁面的預設外觀。", + "option": { + "logo": { + "label": "Logo" + }, + "color": { + "label": "品牌顏色" + } + } + }, + "features": { + "title": "功能", + "description": "設定狀態頁面上顯示的資訊。", + "option": { + "showCharts": { + "label": "顯示回應時間圖表" + }, + "showUptimePercentage": { + "label": "顯示正常運行時間百分比" + }, + "showAdminLoginLink": { + "label": "顯示管理員登入連結" + }, + "showInfrastructure": { + "label": "顯示基礎架構指標" + } + } + } + }, + "header": { + "title": "狀態頁面", + "description": "發佈公開頁面,向客戶和利害關係人顯示即時的運行狀態與事件歷史。" + } + }, + "uptime": { + "filters": { + "search": { + "placeholder": "搜尋監控..." + } + }, + "table": { + "headers": { + "responseTime": "回應時間" + } + }, + "fallback": { + "actionButton": "建立監控!", + "title": "尚無運行時間監視器", + "description": "監控 HTTP 端點、Ping、容器和連接埠 — 一旦發生故障即刻收到提醒。" + }, + "header": { + "title": "運行時間監視器", + "description": "監控 HTTP 端點、Ping、容器和連接埠 — 一旦發生故障即刻收到提醒。" } } - }, - "createNotifications": { - "title": "", - "nameSettings": { - "title": "姓名", - "description": "為您的整合設定描述名稱。", - "nameLabel": "", - "namePlaceholder": "" - }, - "typeSettings": { - "title": "類型", - "description": "請選擇您想要建立的通知頻道類型。", - "typeLabel": "類型" - }, - "emailSettings": { - "title": "電郵", - "description": "目的地電子郵件位址。", - "emailLabel": "", - "emailPlaceholder": "" - }, - "slackSettings": { - "title": "Slack", - "description": "在此配置您的 Slack webhook", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "pagerdutySettings": { - "title": "PagerDuty", - "description": "在此配置您的 PagerDuty 整合", - "integrationKeyLabel": "", - "integrationKeyPlaceholder": "" - }, - "discordSettings": { - "title": "Discord", - "description": "在此配置您的 Discord webhook", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "webhookSettings": { - "title": "Webhook", - "description": "", - "webhookLabel": "", - "webhookPlaceholder": "" - }, - "testNotification": "", - "dialogDeleteTitle": "", - "dialogDeleteConfirm": "" - }, - "notificationConfig": { - "title": "通知", - "description": "請選擇您想要使用的通知頻道" - }, - "monitorStatus": { - "checkingEvery": "", - "withCaptureAgent": "", - "up": "", - "down": "", - "paused": "" - }, - "advancedMatching": "進階匹配", - "sendTestNotifications": "", - "selectAll": "", - "showAdminLoginLink": "", - "logsPage": { - "title": "", - "description": "", - "tabs": { - "queue": "", - "logs": "", - "diagnostics": "" - }, - "toast": { - "fetchLogsSuccess": "" - }, - "logLevelSelect": { - "title": "", - "values": { - "all": "全部", - "info": "", - "warn": "", - "error": "", - "debug": "除錯" - } - } - }, - "queuePage": { - "title": "", - "refreshButton": "", - "flushButton": "", - "jobTable": { - "title": "", - "idHeader": "", - "urlHeader": "", - "typeHeader": "類型", - "activeHeader": "啟用中", - "lockedAtHeader": "", - "runCountHeader": "", - "failCountHeader": "", - "lastRunHeader": "", - "lastFinishedAtHeader": "", - "lastRunTookHeader": "", - "intervalHeader": "" - }, - "metricsTable": { - "title": "", - "metricHeader": "", - "valueHeader": "" - }, - "failedJobTable": { - "title": "", - "monitorIdHeader": "", - "monitorUrlHeader": "", - "failCountHeader": "", - "failedAtHeader": "", - "failReasonHeader": "" - } - }, - "export": { - "title": "", - "success": "", - "failed": "" - }, - "monitorActions": { - "title": "", - "import": "", - "export": "", - "deleteSuccess": "", - "deleteFailed": "", - "details": "" - }, - "settingsPage": { - "aboutSettings": { - "labelDevelopedBy": "", - "labelVersion": "", - "title": "關於" - }, - "demoMonitorsSettings": { - "buttonAddMonitors": "", - "description": "", - "title": "" - }, - "emailSettings": { - "buttonSendTestEmail": "發送測試電子郵件", - "description": "", - "descriptionTransport": "", - "labelAddress": "", - "labelConnectionHost": "", - "labelHost": "", - "labelIgnoreTLS": "", - "labelPassword": "", - "labelPasswordSet": "", - "labelPool": "", - "labelPort": "", - "labelRejectUnauthorized": "", - "labelRequireTLS": "", - "labelSecure": "", - "labelTLSServername": "", - "labelUser": "", - "linkTransport": "", - "placeholderUser": "", - "title": "電郵", - "toastEmailRequiredFieldsError": "" - }, - "pageSpeedSettings": { - "description": "", - "labelApiKeySet": "", - "labelApiKey": "", - "title": "Google PageSpeed API 金鑰" - }, - "saveButtonLabel": "", - "statsSettings": { - "clearAllStatsButton": "清除所有統計數據", - "clearAllStatsDescription": "清除所有統計數據,此操作不可逆轉。", - "clearAllStatsDialogConfirm": "是,請清除所有統計數據", - "clearAllStatsDialogDescription": "一旦刪除,監控歷史和統計數據將無法復原。", - "clearAllStatsDialogTitle": "您是否要清除所有統計數據?", - "description": "", - "labelTTL": "", - "labelTTLOptional": "0 表示無限", - "title": "" - }, - "systemResetSettings": { - "buttonRemoveAllMonitors": "", - "description": "", - "dialogConfirm": "", - "dialogDescription": "", - "dialogTitle": "", - "title": "系統重設" - }, - "timezoneSettings": { - "description": "", - "label": "", - "title": "顯示時區" - }, - "title": "設定", - "uiSettings": { - "description": "", - "labelLanguage": "", - "labelTheme": "", - "title": "外觀" - }, - "urlSettings": { - "description": "", - "label": "", - "selectDisabled": "", - "selectEnabled": "", - "title": "" - }, - "globalThresholds": { - "title": "", - "description": "" - } - }, - "statusPageCreate": { - "buttonSave": "儲存" - }, - "incidentsOptionsHeaderFilterResolved": "", - "settingsSave": "", - "statusPageCreateAppearanceTitle": "", - "confirmPassword": "確認密碼", - "monitorHooks": { - "failureAddDemoMonitors": "", - "successAddDemoMonitors": "" - }, - "settingsAppearance": "", - "settingsDisplayTimezone": "", - "settingsGeneralSettings": "", - "incidentsOptionsHeaderTotalIncidents": "", - "statusPage": { - "deleteSuccess": "", - "deleteFailed": "刪除狀態頁面失敗", - "createSuccess": "", - "updateSuccess": "", - "generalSettings": "", - "contents": "內容", - "fallback": { - "checks": [""], - "title": "", - "actionButton": "讓我們建立您的第一個狀態頁面!" - } - }, - "testNotificationsDisabled": "", - "incidentsTableResolvedAt": "", - "incidentsTableActionResolve": "", - "checkHooks": { - "failureResolveOne": "", - "failureResolveAll": "", - "failureResolveMonitor": "" - }, - "checkFormError": "", - "diagnosticsPage": { - "diagnosticDescription": "", - "statsDescription": "", - "gauges": { - "heapAllocationTitle": "", - "heapAllocationSubtitle": "", - "heapUsageTitle": "", - "heapUsageSubtitle": "", - "heapUtilizationTitle": "", - "heapUtilizationSubtitle": "", - "instantCpuUsageTitle": "", - "instantCpuUsageSubtitle": "" - }, - "stats": { - "eventLoopDelayTitle": "", - "uptimeTitle": "", - "usedHeapSizeTitle": "", - "totalHeapSizeTitle": "", - "osMemoryLimitTitle": "" - } - }, - "pageSpeedLighthouseAPI": "", - "time": { - "threeMinutes": "3分鐘", - "fiveMinutes": "5分鐘", - "tenMinutes": "10分鐘", - "twentyMinutes": "20分鐘", - "oneHour": "1小時", - "oneDay": "1日", - "oneWeek": "1星期", - "fourMinutes": "", - "oneMinute": "", - "twoMinutes": "", - "fifteenSeconds": "", - "thirtySeconds": "" - }, - "general": { - "noOptionsFound": "" - }, - "infrastructureMonitor": { - "fallback": { - "checks": ["追蹤伺服器的效能", "識別瓶頸並優化資源使用", "透過即時監控確保可靠性"], - "title": "", - "actionButton": "讓我們建立您的第一個基礎設施監視器!" - } - }, - "maintenanceWindow": { - "fallback": { - "checks": ["標記您的維護期間", "消除任何誤解", "在維護時段停止發送警報"], - "title": "", - "actionButton": "讓我們建立您的第一個維護時段!" - } - }, - "pageSpeed": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "讓我們建立您的第一個 PageSpeed 監視器!" - } - }, - "uptimeMonitor": { - "fallback": { - "checks": [""], - "title": "", - "actionButton": "讓我們建立您的第一個正常運行時間監視器!" - } - }, - "editUserPage": { - "form": { - "email": "電郵", - "firstName": "名", - "lastName": "姓", - "role": "角色", - "save": "儲存" - }, - "table": { - "actionHeader": "行動", - "roleHeader": "角色" - }, - "title": "編輯用戶", - "toast": { - "successUserUpdate": "", - "validationErrors": "" - } - }, - "incidentsPageActionResolveMonitor": "解決監視器事故", - "incidentsPageActionResolveAll": "解決所有事故", - "matchMethodOptions": { - "equal": "", - "equalPlaceholder": "", - "include": "", - "includePlaceholder": "", - "regex": "", - "regexPlaceholder": "", - "text": "" - }, - "monitorType": { - "docker": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "http": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "ping": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "port": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - }, - "game": { - "label": "", - "namePlaceholder": "", - "placeholder": "" - } - }, - "uptimeAdvancedMatching": { - "jsonPath": "" - }, - "bytesPerSecond": "", - "bytesReceived": "", - "bytesSent": "", - "chooseGame": "", - "createMonitorPage": { - "incidentConfigDescription": "", - "incidentConfigStatusWindowLabel": "", - "incidentConfigStatusWindowThresholdLabel": "", - "incidentConfigTitle": "", - "incidentConfigDescriptionV2": "", - "incidentConfigStatusCheckNumber": "", - "intervalTitle": "", - "intervalDescription": "" - }, - "dataRate": "", - "dataReceived": "", - "dataSent": "", - "details": "", - "drops": "", - "errors": "", - "errorsIn": "", - "errorsOut": "", - "gameServerMonitoring": "", - "gameServerMonitoringDescription": "", - "network": "", - "networkDrops": "", - "networkErrors": "", - "networkInterface": "", - "noNetworkStatsAvailable": "", - "packetsPerSecond": "", - "packetsReceived": "", - "packetsReceivedRate": "", - "packetsSent": "", - "rate": "", - "selectInterface": "" + } } diff --git a/client/vite.config.ts b/client/vite.config.ts index 373a50763..f04ef8c45 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -3,17 +3,28 @@ import react from "@vitejs/plugin-react"; import svgr from "vite-plugin-svgr"; import path from "path"; import { fileURLToPath } from "url"; +import { readFileSync } from "fs"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export default defineConfig(({}) => { - let version = "3.5.1"; + const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "package.json"), "utf-8")); + const version = pkg.version; + + const allowedHosts = process.env.VITE_ALLOWED_HOSTS + ? process.env.VITE_ALLOWED_HOSTS.split(",").map((h) => h.trim()) + : true; return { base: "/", plugins: [svgr(), react()], server: { host: true, + allowedHosts, + }, + preview: { + host: true, + allowedHosts, }, resolve: { alias: { diff --git a/docker/coolify/build_images.bat b/docker/coolify/build_images.bat new file mode 100644 index 000000000..60fc9aa4a --- /dev/null +++ b/docker/coolify/build_images.bat @@ -0,0 +1,36 @@ +@echo off +setlocal enabledelayedexpansion + +REM Change directory to root Server directory for correct Docker Context +cd /d "%~dp0" +cd ../.. + +REM Define service names and their corresponding Dockerfiles +set services[0]=bluewaveuptime/uptime_client +set dockerfiles[0]=.\docker\coolify\client.Dockerfile + +set services[1]=bluewaveuptime/uptime_database_mongo +set dockerfiles[1]=.\docker\coolify\mongoDB.Dockerfile + +set services[2]=bluewaveuptime/uptime_redis +set dockerfiles[2]=.\docker\coolify\redis.Dockerfile + +set services[3]=bluewaveuptime/uptime_server +set dockerfiles[3]=.\docker\coolify\server.Dockerfile + +REM Loop through each service and build the corresponding image +for /L %%i in (0,1,2,3) do ( + set service=!services[%%i]! + set dockerfile=!dockerfiles[%%i]! + + docker build -f "!dockerfile!" -t "!service!" . + + REM Check if the build succeeded + if errorlevel 1 ( + echo Error building !service! image. Exiting... + exit /b 1 + ) +) + +echo All images built successfully +exit /b 0 diff --git a/docker/coolify/build_images.sh b/docker/coolify/build_images.sh index 179b50b5b..04dfe324c 100755 --- a/docker/coolify/build_images.sh +++ b/docker/coolify/build_images.sh @@ -1,14 +1,14 @@ #!/bin/bash # Change directory to root Server directory for correct Docker Context cd "$(dirname "$0")" -cd ../../../ +cd ../../ # Define an array of services and their Dockerfiles declare -A services=( - ["bluewaveuptime/uptime_client"]="./server/docker/dist/client.Dockerfile" - ["bluewaveuptime/uptime_database_mongo"]="./server/docker/dist/mongoDB.Dockerfile" - ["bluewaveuptime/uptime_redis"]="./server/docker/dist/redis.Dockerfile" - ["bluewaveuptime/uptime_server"]="./server/docker/dist/server.Dockerfile" + ["bluewaveuptime/uptime_client"]="./docker/coolify/client.Dockerfile" + ["bluewaveuptime/uptime_database_mongo"]="./docker/coolify/mongoDB.Dockerfile" + ["bluewaveuptime/uptime_redis"]="./docker/coolify/redis.Dockerfile" + ["bluewaveuptime/uptime_server"]="./docker/coolify/server.Dockerfile" ) # Loop through each service and build the corresponding image diff --git a/docker/dev/build_images.bat b/docker/dev/build_images.bat new file mode 100644 index 000000000..ec3889418 --- /dev/null +++ b/docker/dev/build_images.bat @@ -0,0 +1,33 @@ +@echo off +setlocal enabledelayedexpansion + +REM Change directory to root Server directory for correct Docker Context +cd /d "%~dp0" +cd ../.. + +REM Define service names and their corresponding Dockerfiles +set services[0]=uptime_client +set dockerfiles[0]=.\docker\dev\client.Dockerfile + +set services[1]=uptime_mongo +set dockerfiles[1]=.\docker\dev\mongoDB.Dockerfile + +set services[2]=uptime_server +set dockerfiles[2]=.\docker\dev\server.Dockerfile + +REM Loop through each service and build the corresponding image +for /L %%i in (0,1,2) do ( + set service=!services[%%i]! + set dockerfile=!dockerfiles[%%i]! + + docker build -f "!dockerfile!" -t "!service!" . + + REM Check if the build succeeded + if errorlevel 1 ( + echo Error building !service! image. Exiting... + exit /b 1 + ) +) + +echo All images built successfully +exit /b 0 \ No newline at end of file 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 c9835067f..a522dabfa 100644 --- a/docker/dist-arm/server.Dockerfile +++ b/docker/dist-arm/server.Dockerfile @@ -1,17 +1,16 @@ # --------------------- # Frontend build stage # --------------------- -FROM node:20-slim AS frontend-build - - +# Build on the host arch (BUILDPLATFORM) rather than under QEMU — the output is +# pure JS so it's arch-independent, and this avoids the esbuild host/native +# binary version mismatch under emulation. +FROM --platform=$BUILDPLATFORM node:20-slim AS frontend-build WORKDIR /app/client -COPY client/package.json ./ +COPY client/package*.json ./ -RUN npm install - -RUN npm install esbuild@0.25.5 --build-from-source +RUN npm ci COPY client ./ @@ -41,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/build_images.bat b/docker/dist-mono/build_images.bat new file mode 100644 index 000000000..3e1d39fe1 --- /dev/null +++ b/docker/dist-mono/build_images.bat @@ -0,0 +1,30 @@ +@echo off +setlocal enabledelayedexpansion + +REM Change directory to root Server directory for correct Docker Context +cd /d "%~dp0" +cd ../.. + +REM Define service names and their corresponding Dockerfiles +set services[0]=uptime_database_mongo +set dockerfiles[0]=.\docker\dist-mono\mongoDB.Dockerfile + +set services[1]=uptime_server +set dockerfiles[1]=.\docker\dist-mono\server.Dockerfile + +REM Loop through each service and build the corresponding image +for /L %%i in (0,1) do ( + set service=!services[%%i]! + set dockerfile=!dockerfiles[%%i]! + + docker build -f "!dockerfile!" -t "!service!" . + + REM Check if the build succeeded + if errorlevel 1 ( + echo Error building !service! image. Exiting... + exit /b 1 + ) +) + +echo All images built successfully +exit /b 0 diff --git a/docker/dist-mono/server.Dockerfile b/docker/dist-mono/server.Dockerfile index 84b7127ed..3f14d8fc1 100644 --- a/docker/dist-mono/server.Dockerfile +++ b/docker/dist-mono/server.Dockerfile @@ -19,15 +19,19 @@ WORKDIR /app/server COPY server ./ - RUN npm install RUN npm run build +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 +CMD ["sh", "-c", "./scripts/inject-vars.sh && node ./dist/index.js"] diff --git a/docker/dist/build_images.bat b/docker/dist/build_images.bat new file mode 100644 index 000000000..92b5c4a9d --- /dev/null +++ b/docker/dist/build_images.bat @@ -0,0 +1,33 @@ +@echo off +setlocal enabledelayedexpansion + +REM Change directory to root Server directory for correct Docker Context +cd /d "%~dp0" +cd ../.. + +REM Define service names and their corresponding Dockerfiles +set services[0]=bluewaveuptime/uptime_client +set dockerfiles[0]=.\docker\dist\client.Dockerfile + +set services[1]=bluewaveuptime/uptime_database_mongo +set dockerfiles[1]=.\docker\dist\mongoDB.Dockerfile + +set services[2]=bluewaveuptime/uptime_server +set dockerfiles[2]=.\docker\dist\server.Dockerfile + +REM Loop through each service and build the corresponding image +for /L %%i in (0,1,2) do ( + set service=!services[%%i]! + set dockerfile=!dockerfiles[%%i]! + + docker build -f "!dockerfile!" -t "!service!" . + + REM Check if the build succeeded + if errorlevel 1 ( + echo Error building !service! image. Exiting... + exit /b 1 + ) +) + +echo All images built successfully +exit /b 0 diff --git a/docker/prod/build_images.bat b/docker/prod/build_images.bat new file mode 100644 index 000000000..79403db3f --- /dev/null +++ b/docker/prod/build_images.bat @@ -0,0 +1,33 @@ +@echo off +setlocal enabledelayedexpansion + +REM Change directory to root Server directory for correct Docker Context +cd /d "%~dp0" +cd ../.. + +REM Define service names and their corresponding Dockerfiles +set services[0]=uptime_client +set dockerfiles[0]=.\docker\prod\client.Dockerfile + +set services[1]=uptime_database_mongo +set dockerfiles[1]=.\docker\prod\mongoDB.Dockerfile + +set services[2]=uptime_server +set dockerfiles[2]=.\docker\prod\server.Dockerfile + +REM Loop through each service and build the corresponding image +for /L %%i in (0,1,2) do ( + set service=!services[%%i]! + set dockerfile=!dockerfiles[%%i]! + + docker build -f "!dockerfile!" -t "!service!" . + + REM Check if the build succeeded + if errorlevel 1 ( + echo Error building !service! image. Exiting... + exit /b 1 + ) +) + +echo All images built successfully +exit /b 0 diff --git a/docker/prod/docker-compose.yaml b/docker/prod/docker-compose.yaml index 6e29fd90a..02a9fd919 100755 --- a/docker/prod/docker-compose.yaml +++ b/docker/prod/docker-compose.yaml @@ -3,8 +3,8 @@ services: image: ghcr.io/bluewave-labs/checkmate:frontend-demo restart: always environment: - UPTIME_APP_API_BASE_URL: "https://checkmate-demo.bluewavelabs.ca/api/v1" - UPTIME_APP_CLIENT_HOST: "https://checkmate-demo.bluewavelabs.ca" + UPTIME_APP_API_BASE_URL: "https://demo.checkmate.so/api/v1" + UPTIME_APP_CLIENT_HOST: "https://demo.checkmate.so" ports: - "80:80" - "443:443" @@ -12,16 +12,7 @@ services: - server volumes: - ./nginx/conf.d:/etc/nginx/conf.d/:ro - - ./certbot/www:/var/www/certbot/:ro - - ./certbot/conf/:/etc/nginx/ssl/:ro - - certbot: - image: certbot/certbot:latest - restart: always - volumes: - - ./certbot/www/:/var/www/certbot/:rw - - ./certbot/conf/:/etc/letsencrypt/:rw - entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot; sleep 12h & wait $${!}; done;'" + - /etc/letsencrypt:/etc/nginx/ssl:ro server: image: ghcr.io/bluewave-labs/checkmate:backend-demo diff --git a/docker/prod/nginx/conf.d/default.conf b/docker/prod/nginx/conf.d/default.conf index 8afeb93b7..0e0e55a2b 100755 --- a/docker/prod/nginx/conf.d/default.conf +++ b/docker/prod/nginx/conf.d/default.conf @@ -40,8 +40,8 @@ server { server_name checkmate-demo.bluewavelabs.ca; - ssl_certificate /etc/nginx/ssl/live/checkmate-demo.bluewavelabs.ca/fullchain.pem; - ssl_certificate_key /etc/nginx/ssl/live/checkmate-demo.bluewavelabs.ca/privkey.pem; + ssl_certificate /etc/nginx/ssl/live/demo.checkmate.so/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/live/demo.checkmate.so/privkey.pem; location / { root /usr/share/nginx/html; diff --git a/docker/prod/server.Dockerfile b/docker/prod/server.Dockerfile index 7341b1e9b..8be8200ee 100755 --- a/docker/prod/server.Dockerfile +++ b/docker/prod/server.Dockerfile @@ -17,6 +17,8 @@ COPY ./server ./ RUN npm run build +RUN cp -r src/templates dist/templates + EXPOSE 52345 CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/docker/staging/build_images.bat b/docker/staging/build_images.bat new file mode 100644 index 000000000..c8be0378a --- /dev/null +++ b/docker/staging/build_images.bat @@ -0,0 +1,33 @@ +@echo off +setlocal enabledelayedexpansion + +REM Change directory to root Server directory for correct Docker Context +cd /d "%~dp0" +cd ../.. + +REM Define service names and their corresponding Dockerfiles +set services[0]=uptime_client +set dockerfiles[0]=.\docker\staging\client.Dockerfile + +set services[1]=uptime_database_mongo +set dockerfiles[1]=.\docker\staging\mongoDB.Dockerfile + +set services[2]=uptime_server +set dockerfiles[2]=.\docker\staging\server.Dockerfile + +REM Loop through each service and build the corresponding image +for /L %%i in (0,1,2) do ( + set service=!services[%%i]! + set dockerfile=!dockerfiles[%%i]! + + docker build -f "!dockerfile!" -t "!service!" . + + REM Check if the build succeeded + if errorlevel 1 ( + echo Error building !service! image. Exiting... + exit /b 1 + ) +) + +echo All images built successfully +exit /b 0 diff --git a/docker/staging/server.Dockerfile b/docker/staging/server.Dockerfile index 7341b1e9b..8be8200ee 100755 --- a/docker/staging/server.Dockerfile +++ b/docker/staging/server.Dockerfile @@ -17,6 +17,8 @@ COPY ./server ./ RUN npm run build +RUN cp -r src/templates dist/templates + EXPOSE 52345 CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/docker/unified/build_images.bat b/docker/unified/build_images.bat new file mode 100644 index 000000000..a3d54e60d --- /dev/null +++ b/docker/unified/build_images.bat @@ -0,0 +1,33 @@ +@echo off +setlocal enabledelayedexpansion + +REM Change directory to root Server directory for correct Docker Context +cd /d "%~dp0" +cd ../.. + +REM Define service names and their corresponding Dockerfiles +set services[0]=uptime_client +set dockerfiles[0]=.\docker\unified\client.Dockerfile + +set services[1]=uptime_database_mongo +set dockerfiles[1]=.\docker\unified\mongoDB.Dockerfile + +set services[2]=uptime_server +set dockerfiles[2]=.\docker\unified\server.Dockerfile + +REM Loop through each service and build the corresponding image +for /L %%i in (0,1,2) do ( + set service=!services[%%i]! + set dockerfile=!dockerfiles[%%i]! + + docker build -f "!dockerfile!" -t "!service!" . + + REM Check if the build succeeded + if errorlevel 1 ( + echo Error building !service! image. Exiting... + exit /b 1 + ) +) + +echo All images built successfully +exit /b 0 diff --git a/docker/unified/build_images.sh b/docker/unified/build_images.sh new file mode 100755 index 000000000..21b866bbe --- /dev/null +++ b/docker/unified/build_images.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Change directory to root directory for correct Docker Context +cd "$(dirname "$0")" +cd ../../ + +# Define an array of services and their Dockerfiles +declare -A services=( + ["uptime_client"]="./docker/unified/client.Dockerfile" + ["uptime_database_mongo"]="./docker/unified/mongoDB.Dockerfile" + ["uptime_server"]="./docker/unified/server.Dockerfile" +) + +# Loop through each service and build the corresponding image +for service in "${!services[@]}"; do + docker build -f "${services[$service]}" -t "$service" . + + # Check if the build succeeded + if [ $? -ne 0 ]; then + echo "Error building $service image. Exiting..." + exit 1 + fi +done + +echo "All images built successfully" diff --git a/docker/unified/certbot-compose.yaml b/docker/unified/certbot-compose.yaml new file mode 100755 index 000000000..9220d07a1 --- /dev/null +++ b/docker/unified/certbot-compose.yaml @@ -0,0 +1,19 @@ +version: "3" + +services: + webserver: + image: nginx:latest + ports: + - 80:80 + - 443:443 + restart: always + volumes: + - ./nginx/conf.d/:/etc/nginx/conf.d/:ro + - ./certbot/www/:/var/www/certbot/:ro + certbot: + image: certbot/certbot:latest + volumes: + - ./certbot/www/:/var/www/certbot/:rw + - ./certbot/conf/:/etc/letsencrypt/:rw + depends_on: + - webserver diff --git a/docker/unified/client.Dockerfile b/docker/unified/client.Dockerfile new file mode 100755 index 000000000..b4c685e3e --- /dev/null +++ b/docker/unified/client.Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-slim AS build + +ENV NODE_OPTIONS="--max-old-space-size=4096" + +WORKDIR /app + +COPY ./client/package*.json ./ + +RUN npm install + +COPY ./client . + +RUN npm run build + +FROM nginx:1.27.1-alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY --from=build /app/env.sh /docker-entrypoint.d/env.sh +RUN chmod +x /docker-entrypoint.d/env.sh +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/docker/unified/docker-compose.yaml b/docker/unified/docker-compose.yaml new file mode 100755 index 000000000..199d0da21 --- /dev/null +++ b/docker/unified/docker-compose.yaml @@ -0,0 +1,114 @@ +# sudo certbot certonly --standalone --http-01-port 8080 +# cerbot needs to run on 8080 since port 80 is used by the gateway + +services: + # Gateway (Single Entry Point) + gateway: + image: nginx:1.27.1-alpine + container_name: checkmate-gateway + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - /etc/letsencrypt:/etc/nginx/ssl:ro + - ./gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro + + # Production Stack ----- + client-prod: + image: ghcr.io/bluewave-labs/checkmate:frontend-demo + container_name: client-prod + restart: always + environment: + UPTIME_APP_API_BASE_URL: "https://demo.checkmate.so/api/v1" + UPTIME_APP_CLIENT_HOST: "https://demo.checkmate.so" + expose: + - "80" + depends_on: + - server-prod + volumes: + - ./nginx/conf.d/prod.conf:/etc/nginx/conf.d/default.conf:ro + + server-prod: + image: ghcr.io/bluewave-labs/checkmate:backend-demo + container_name: server-prod + restart: always + env_file: + - server-prod.env + depends_on: + - mongodb-prod + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + + mongodb-prod: + image: ghcr.io/bluewave-labs/checkmate:mongo-demo + container_name: mongodb-prod + restart: always + command: [ "mongod", "--quiet", "--bind_ip_all" ] + volumes: + - ./mongo/data-prod:/data/db + env_file: + - mongo-prod.env + healthcheck: + test: [ "CMD", "mongosh", "--eval", "db.adminCommand('ping')", "--quiet" ] + interval: 5s + timeout: 30s + start_period: 0s + start_interval: 1s + retries: 30 + + # Staging Stack ----- + client-staging: + image: ghcr.io/bluewave-labs/checkmate:frontend-staging + container_name: client-staging + restart: always + environment: + UPTIME_APP_API_BASE_URL: "https://staging.checkmate.so/api/v1" + UPTIME_APP_CLIENT_HOST: "https://staging.checkmate.so" + expose: + - "80" + depends_on: + - server-staging + volumes: + - ./nginx/conf.d/staging.conf:/etc/nginx/conf.d/default.conf:ro + + server-staging: + image: ghcr.io/bluewave-labs/checkmate:backend-staging + container_name: server-staging + restart: always + env_file: + - server-staging.env + depends_on: + - mongodb-staging + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + + mongodb-staging: + image: ghcr.io/bluewave-labs/checkmate:mongo-staging + container_name: mongodb-staging + restart: always + command: [ "mongod", "--quiet", "--bind_ip_all" ] + volumes: + - ./mongo/data-staging:/data/db + env_file: + - mongo-staging.env + healthcheck: + test: [ "CMD", "mongosh", "--eval", "db.adminCommand('ping')", "--quiet" ] + interval: 5s + timeout: 30s + start_period: 0s + start_interval: 1s + retries: 30 + + # Shared Utils + capture: + image: ghcr.io/bluewave-labs/capture:latest + container_name: capture + restart: unless-stopped + ports: + - "59232:59232" + environment: + - API_SECRET=my_secret + - GIN_MODE=release + volumes: + - /etc/os-release:/etc/os-release:ro diff --git a/docker/unified/gateway/nginx.conf b/docker/unified/gateway/nginx.conf new file mode 100644 index 000000000..47ad4695e --- /dev/null +++ b/docker/unified/gateway/nginx.conf @@ -0,0 +1,55 @@ +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; + + +server { + listen 80; + server_name demo.checkmate.so staging.checkmate.so; + + location /.well-known/acme-challenge/ { + # Forward the challenge to Certbot listening on 8080 + proxy_pass http://172.17.0.1:8080; + } + + location / { + return 301 https://$host$request_uri; + } +} + + +# --- PRODUCTION: demo.checkmate.so --- +server { + listen 443 ssl; + server_name demo.checkmate.so; + + ssl_certificate /etc/nginx/ssl/live/demo.checkmate.so/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/live/demo.checkmate.so/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + # Passes traffic to the prod client on port 80 + proxy_pass http://client-prod:80; + } +} + +# --- STAGING: staging.checkmate.so --- + + server { + listen 443 ssl; + server_name staging.checkmate.so; + + ssl_certificate /etc/nginx/ssl/live/staging.checkmate.so/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/live/staging.checkmate.so/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + # Passes traffic to the staging client on port 80 + proxy_pass http://client-staging:80; + } + } diff --git a/docker/unified/mongoDB.Dockerfile b/docker/unified/mongoDB.Dockerfile new file mode 100755 index 000000000..969a320c2 --- /dev/null +++ b/docker/unified/mongoDB.Dockerfile @@ -0,0 +1,3 @@ +FROM mongo +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/unified/nginx/conf.d/prod.conf b/docker/unified/nginx/conf.d/prod.conf new file mode 100755 index 000000000..63a42a401 --- /dev/null +++ b/docker/unified/nginx/conf.d/prod.conf @@ -0,0 +1,31 @@ +server { + listen 80; + listen [::]:80; + + server_name _; + server_tokens off; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://server-prod:52345/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api-docs/ { + proxy_pass http://server-prod:52345/api-docs/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/docker/unified/nginx/conf.d/staging.conf b/docker/unified/nginx/conf.d/staging.conf new file mode 100755 index 000000000..6b640a06b --- /dev/null +++ b/docker/unified/nginx/conf.d/staging.conf @@ -0,0 +1,31 @@ +server { + listen 80; + listen [::]:80; + + server_name _; + server_tokens off; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://server-staging:52345/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api-docs/ { + proxy_pass http://server-staging:52345/api-docs/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/docker/unified/server.Dockerfile b/docker/unified/server.Dockerfile new file mode 100755 index 000000000..7341b1e9b --- /dev/null +++ b/docker/unified/server.Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-slim + +# Install ping +RUN apt-get update \ + && apt-get install -y iputils-ping \ + && rm -rf /var/lib/apt/lists/* + +ENV NODE_OPTIONS="--max-old-space-size=2048" + +WORKDIR /app + +COPY ./server/package*.json ./ + +RUN npm install + +COPY ./server ./ + +RUN npm run build + +EXPOSE 52345 + +CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index dc245e21e..b8904d1f9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,4 +35,4 @@ If you find issues or want to improve the documentation: - [GitHub Discussions](https://github.com/bluewave-labs/checkmate/discussions) - [Discord Channel](https://discord.gg/NAb6H3UTjK) -- [Documentation Portal](https://docs.checkmate.so/) +- [Documentation Portal](https://checkmate.so/docs) 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/i18n-contribution.md b/docs/i18n-contribution.md new file mode 100644 index 000000000..6dbec34b0 --- /dev/null +++ b/docs/i18n-contribution.md @@ -0,0 +1,31 @@ +# Contributing Translations + +Checkmate supports multiple languages. This guide explains how to contribute translations. + +## How to Add a New Language + +1. Create a new JSON file in `client/src/locales/` named with the language code (e.g., `it.json` for Italian) + +2. Copy the structure from `en.json` and translate all values + +3. Add the language to the language selector in the UI + +## Translation Guidelines + +- en.json is the reference localization file, all keys should be declared here and mirrored in other localization files +- Translate only the values +- Use formal/informal tone consistently +- Test your translations in the UI + +## File Structure +client/src/locales/ +├── en.json # Source language +├── es.json # Spanish +├── de.json # German +└── [lang].json # Your language +## Testing Translations + +1. Start the development server: `npm run dev` +2. Go to Settings → Language +3. Select your language +4. Verify all strings display correctly diff --git a/docs/infrastructure-disk-selection.md b/docs/infrastructure-disk-selection.md index 500e3be1e..bcfca165b 100644 --- a/docs/infrastructure-disk-selection.md +++ b/docs/infrastructure-disk-selection.md @@ -70,5 +70,5 @@ When you select specific disks, the following elements are filtered: ## Related Documentation -- [Infrastructure Monitoring Setup](https://docs.checkmate.so/checkmate-2.1) +- [Infrastructure Monitoring Setup](https://checkmate.so/docs) - [Capture Agent Installation](https://github.com/bluewave-labs/capture) \ No newline at end of file diff --git a/server/.env.example b/server/.env.example index 1a05a6ff7..20c579f5c 100644 --- a/server/.env.example +++ b/server/.env.example @@ -24,3 +24,9 @@ CLIENT_HOST=http://localhost:5173 # Optional Configuration # ---------------------- ORIGIN=localhost + +# Feature Flags +# ------------- +# Set to "false" to disable the selectable theme feature on status pages. +# When disabled, all status pages render with the default "refined" theme. +STATUS_PAGE_THEMES_ENABLED=true diff --git a/server/README.md b/server/README.md index db609cb02..09e055d02 100755 --- a/server/README.md +++ b/server/README.md @@ -8,7 +8,7 @@ Checkmate's backend is designed to be lightweight and scalable, ensuring reliabl ## Installation & documentation -For setup instructions, configuration details, and deployment guidelines, please visit our official documentation at [docs.checkmate.so](https://docs.checkmate.so). +For setup instructions, configuration details, and deployment guidelines, please visit our official documentation at [docs.checkmate.so](https://checkmate.so/docs). ## Related repositories diff --git a/server/jest.config.ts b/server/jest.config.ts index fd0c87ad7..1faf98181 100644 --- a/server/jest.config.ts +++ b/server/jest.config.ts @@ -9,11 +9,11 @@ const config: Config = { }, moduleNameMapper: { "^@/validation/(.*)\\.js$": "/src/validation/$1.js", - "^@/utils/(AppError)\\.js$": "/src/utils/$1.ts", - "^@/utils/(.*)\\.js$": "/src/utils/$1.js", + "^@/utils/(.*)\\.js$": "/src/utils/$1.ts", "^@/(.*)\\.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 6a44c19f2..f62062332 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -1,125 +1,1141 @@ { - "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", "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" + } + }, + "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" + }, + "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", + "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" + } + ], + "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" + } + } + }, + "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" + } + } + }, + "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 +1143,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 +1258,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 +1332,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,24 +1403,3810 @@ "/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": { + "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": { + "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/superadmin": { + "get": { + "tags": ["auth"], + "summary": "Check whether a superadmin user exists", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "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": { + "$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"] + } + } + } + } + } + }, + "post": { + "tags": ["auth"], + "summary": "Create a new user (superadmin)", + "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" + }, + "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"] + }, + "minItems": 1 + }, + "profileImage": { + "type": "string", + "format": "binary" + } + }, + "required": ["firstName", "lastName", "email", "password", "role", "profileImage"] + } + } + } + }, + "responses": { + "201": { + "description": "User created", + "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"] + } + } + } + } + } + } + }, + "/auth/users/{userId}": { + "get": { + "tags": ["auth"], + "summary": "Get a user by id", + "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" + }, + "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": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + } + }, + "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": ["auth"], + "summary": "Edit a user (superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "userId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "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" + }, + "role": { + "type": "array", + "items": { + "type": "string", + "enum": ["user", "admin", "superadmin", "demo"] + }, + "minItems": 1 + } + }, + "required": ["firstName", "lastName", "role"] + } + } + } + }, + "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"] + } + } + } + } + } + }, + "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": { + "patch": { + "tags": ["auth"], + "summary": "Change a user's password (superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "userId", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" + } + }, + "required": ["password"] + } + } + } + }, + "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/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" + }, + "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"] + } + } + } + } + } + } + }, + "/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"] + }, + "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"] + }, + "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": "Send an invite email (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "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": { + "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/verify": { + "post": { + "tags": ["invite"], + "summary": "Verify an invite token", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "minLength": 1 + } + }, + "required": ["token"] + } + } + } + }, + "responses": { + "200": { + "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": { + "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": { + "post": { + "tags": ["invite"], + "summary": "Create an invite token (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "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": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + }, + "required": ["token"], + "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"] + } + } + } + } + } + } + }, + "/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": { + "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": { + "post": { + "tags": ["maintenance-window"], + "summary": "Create one or more maintenance windows", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "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"] + } + } + } + }, + "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/team": { + "get": { + "tags": ["maintenance-window"], + "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": "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 a maintenance window 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"] + } + } + } + } + } + }, + "patch": { + "tags": ["maintenance-window"], + "summary": "Edit a maintenance window", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "repeat": { + "type": "number" + }, + "start": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "end": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "expiry": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "monitors": { + "type": "array", + "items": { + "type": "string" + } + }, + "duration": { + "type": "number" + }, + "durationUnit": { + "type": "string", + "enum": ["seconds", "minutes", "hours", "days"] } } } @@ -420,102 +5215,228 @@ }, "responses": { "200": { - "$ref": "#/components/responses/Success" - }, - "400": { - "description": "Invalid token or password requirements not met" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - } - } - }, - "/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", + "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/Monitor" - } - } + "msg": { + "type": "string" + }, + "data": { + "nullable": true } - ] + }, + "required": ["success", "msg"] } } } }, - "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" + "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"] + } + } + } } - }, + } + }, + "delete": { + "tags": ["maintenance-window"], + "summary": "Delete a maintenance window", "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"] + } + } + } + } + } } }, "/monitors/team": { "get": { "tags": ["monitors"], - "summary": "Get monitors by team", - "description": "Get monitors filtered by team with optional parameters", + "summary": "List monitors for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], "parameters": [ { - "name": "type", - "in": "query", - "description": "Filter by monitor type", "schema": { - "oneOf": [ + "anyOf": [ { "type": "string", "enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"] @@ -528,103 +5449,31 @@ } } ] - } + }, + "required": false, + "name": "type", + "in": "query" }, { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "required": false, "name": "filter", - "in": "query", - "description": "Search filter value", - "schema": { - "type": "string" - } + "in": "query" } ], "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": 50, - "default": 10 - } - } - ], - "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": { @@ -632,438 +5481,10 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Monitor groups retrieved successfully" - }, - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "monitors": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - } - } - } - }, - "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", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "msg": { - "type": "string", - "example": "PageSpeed details retrieved successfully" - }, - "data": { - "type": "object", - "properties": { - "monitor": { - "$ref": "#/components/schemas/Monitor" - }, - "checks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Check" - } - } - } - } - } - } - } - } - }, - "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" + "type": "string" }, "data": { "type": "array", @@ -1071,44 +5492,2701 @@ "$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" + "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": [] } - ] + ], + "parameters": [ + { + "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": ["asc", "desc"] + }, + "required": false, + "name": "order", + "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "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"] + } + } + ] + }, + "required": false, + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "boolean" + }, + "required": false, + "name": "explain", + "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/bulk": { + "/monitors/team/groups": { + "get": { + "tags": ["monitors"], + "summary": "List monitor groups 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": { + "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/uptime/details/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get uptime details 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": 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": "Bulk import monitors", - "description": "Import monitors from CSV file", + "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": { - "required": true, "content": { - "multipart/form-data": { + "application/json": { "schema": { "type": "object", "properties": { - "csvFile": { + "monitorIds": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 100 + }, + "notificationIds": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 100 + }, + "action": { "type": "string", - "format": "binary", - "description": "CSV file containing monitor data" + "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", "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" + } + }, + "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": "" + }, + "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 + } + }, + "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", "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": [] + }, + "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": "" + }, + "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 + }, + "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", "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" + } + }, + "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": [""] + } + ] + }, + "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 } } } @@ -1117,1037 +8195,1846 @@ }, "responses": { "200": { - "description": "Monitors imported successfully" + "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"] + } + } + } }, - "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"] + } + } + } } - }, + } + }, + "delete": { + "tags": ["monitors"], + "summary": "Delete a monitor", "security": [ { "bearerAuth": [] } - ] - } - }, - "/monitors/test-email": { - "post": { - "tags": ["monitors"], - "summary": "Send test email", - "description": "Send a test email notification", + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + } + ], "responses": { "200": { - "$ref": "#/components/responses/Success" + "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": { - "$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"] + } + } + } } - }, + } + } + }, + "/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"] + } + } + } + }, + "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/all": { + "post": { + "tags": ["notifications"], + "summary": "Send a test alert through every notification channel for the team", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "monitorId": { + "type": "string", + "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 + } + }, + "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", - "description": "Retrieve current application configuration", + "security": [ + { + "bearerAuth": [] + } + ], "responses": { "200": { - "description": "Settings 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/AppSettings" - } - } + "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": { - "$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": ["settings"], + "summary": "Update application settings (admin/superadmin)", "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" + "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 + } + } + } + } } } } }, "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"] + } + } + } + }, + "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"] + } + } + } } - }, - "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" - } - }, + "summary": "Send a test email using current SMTP settings (admin/superadmin)", "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" - } - } - } - }, - "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", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Notification" - } - } - } - } - ] - } - } - } - }, - "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", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/SystemDiagnostics" - } - } - } - ] - } - } - } - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "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" - } - } - } - }, - "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)", - "parameters": [ - { - "name": "userId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User retrieved successfully", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/User" - } - } - } - ] - } - } - } - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "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" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserUpdateRequest" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/auth/users/{userId}/password": { - "put": { - "tags": ["auth"], - "summary": "Update user password by ID", - "description": "Update a specific user's password by ID (superadmin only)", - "parameters": [ - { - "name": "userId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["password"], "properties": { - "password": { + "to": { "type": "string", - "format": "password", - "minLength": 8, - "description": "New password for the user" - } - } - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/invite": { - "post": { - "tags": ["invite"], - "summary": "Create invite token", - "description": "Create a new invitation token", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["email", "role"], - "properties": { - "email": { - "type": "string", - "format": "email" + "minLength": 1 }, - "role": { - "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": { - "type": "string", - "format": "email" - } - } - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/Success" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/invite/verify": { - "post": { - "tags": ["invite"], - "summary": "Verify invitation token", - "description": "Verify an invitation token", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["token"], - "properties": { - "token": { + "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": { - "$ref": "#/components/responses/Success" - }, - "400": { - "description": "Invalid or expired token" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - } - } - }, - "/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" - } - }, - "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": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ack": { - "type": "boolean", - "description": "Acknowledgement status" - } - } - } - } - } - }, - "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": { @@ -2155,418 +10042,46 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "All checks acknowledged successfully" - }, - "data": { - "type": "object", - "description": "Updated checks information" - } - } - } - } - } - }, - "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", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "msg": { - "type": "string", - "example": "All checks acknowledged successfully" - }, - "data": { - "type": "object", - "description": "Updated checks information" - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/maintenance-window": { - "post": { - "tags": ["maintenance-window"], - "summary": "Create maintenance window", - "description": "Create a new maintenance window", - "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" } - } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK" } } } - } - }, - "responses": { - "201": { - "description": "Maintenance window created successfully" }, - "422": { - "$ref": "#/components/responses/ValidationError" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "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" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/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" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/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" - } - }, - "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" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "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": { + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { "type": "string" - } - } - } - } - } - } - }, - "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": ["maintenance-window"], - "summary": "Delete maintenance window", - "description": "Delete a specific maintenance window", - "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": [] - } - ] - } - }, - "/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", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "msg": { - "type": "string", - "example": "Queue jobs fetched successfully" }, "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": { @@ -2574,462 +10089,567 @@ "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" - } - }, - "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": { - "get": { - "tags": ["monitors"], - "summary": "Get game server list", - "description": "Get available game servers for monitoring", - "responses": { - "200": { - "description": "Game servers retrieved successfully", + "description": "Internal server error", "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - } - } + "msg": { + "type": "string" + }, + "data": { + "nullable": true } - ] + }, + "required": ["success", "msg"] } } } - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "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": { - "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" - } - } - } - } - } - }, - "responses": { - "201": { - "$ref": "#/components/responses/Success" - }, - "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" - } - } - } - } - } - }, - "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" - } - }, + "summary": "List status pages for the caller's team", "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" - } ], "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"] + } + } + } + }, + "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"] + } + } + } + } + } + } + }, + "/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", + "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"] + } + } + } + } + } + } + }, + "/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", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "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 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" - } - }, + "summary": "Delete a status page", "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": { @@ -3037,1617 +10657,188 @@ "properties": { "success": { "type": "boolean", - "example": true + "enum": [true] }, "msg": { - "type": "string", - "example": "Incidents retrieved successfully" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Incident" - } - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "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": "string", - "enum": ["day", "week", "month"], - "default": "week" - } - } - ], - "responses": { - "200": { - "description": "Incident summary retrieved successfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "msg": { - "type": "string", - "example": "Incident summary retrieved successfully" - }, - "data": { - "$ref": "#/components/schemas/IncidentSummary" - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/incidents/{incidentId}": { - "get": { - "tags": ["incidents"], - "summary": "Get incident by ID", - "description": "Retrieve a specific incident by its ID", - "parameters": [ - { - "name": "incidentId", - "in": "path", - "required": true, - "description": "The incident ID", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Incident retrieved successfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "msg": { - "type": "string", - "example": "Incident retrieved successfully" - }, - "data": { - "$ref": "#/components/schemas/Incident" - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "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" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "comment": { - "type": "string", - "description": "Optional comment about the resolution", - "example": "Issue resolved by restarting the service" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Incident resolved successfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": true - }, - "msg": { - "type": "string", - "example": "Incident resolved successfully" - }, - "data": { - "$ref": "#/components/schemas/Incident" - } - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalServerError" - } - }, - "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": { - "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": { - "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"] - }, - "default": ["user"] - } - } - }, - "LoginRequest": { - "type": "object", - "required": ["email", "password"], - "properties": { - "email": { - "type": "string", - "format": "email", - "example": "john@example.com" - }, - "password": { - "type": "string", - "format": "password", - "example": "SecurePass123!" - } - } - }, - "AuthResponse": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "token": { - "type": "string", - "description": "JWT access token" - }, - "user": { - "$ref": "#/components/schemas/User" - } - } - } - } - } - ] - }, - "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": "" - }, - "strategy": { - "type": "string", - "enum": ["desktop", "mobile"], - "default": "desktop", - "description": "PageSpeed analysis strategy" - }, - "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" - }, - "strategy": { - "type": "string", - "enum": ["desktop", "mobile"], - "description": "PageSpeed analysis strategy" - }, - "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" - } - } - }, - "assertions": { - "type": "array", - "items": { - "type": "object" - } - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "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", - "properties": { - "to": { - "type": "array", - "items": { - "type": "string", - "format": "email" - } - } - } - }, - { - "title": "Webhook Configuration", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "headers": { - "type": "object", - "additionalProperties": { "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..b34c542e7 --- /dev/null +++ b/server/openapi/routes/notification.ts @@ -0,0 +1,155 @@ +import { z } from "zod"; +import { registry } from "../registry.js"; +import { bearer, json, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { + createNotificationBodyValidation, + deleteNotificationParamValidation, + getNotificationByIdParamValidation, + editNotificationParamValidation, + testAllNotificationsBodyValidation, +} from "@/validation/notificationValidation.js"; + +const tags = ["notifications"]; + +// OpenAPI metadata for each notification variant. Keyed by the discriminator +// value in createNotificationBodyValidation; build fails loudly if a variant +// in the validator has no entry here, which doubles as drift detection. +const notificationVariantMeta: Record }> = { + email: { + component: "EmailNotification", + example: { notificationName: "Ops on-call email", type: "email", address: "alerts@example.com" }, + }, + webhook: { + component: "WebhookNotification", + example: { notificationName: "Custom webhook", type: "webhook", address: "https://example.com/hooks/checkmate" }, + }, + slack: { + component: "SlackNotification", + example: { notificationName: "#alerts", type: "slack", address: "https://hooks.slack.com/services/T000/B000/XXXX" }, + }, + discord: { + component: "DiscordNotification", + example: { notificationName: "#status", type: "discord", address: "https://discord.com/api/webhooks/123/abc" }, + }, + pager_duty: { + component: "PagerDutyNotification", + example: { notificationName: "PagerDuty primary", type: "pager_duty", address: "R01XXXXXXXXXXXXXXXXXXXXXXX" }, + }, + matrix: { + component: "MatrixNotification", + example: { + notificationName: "Matrix room", + type: "matrix", + homeserverUrl: "https://matrix.example.com", + roomId: "!abc123:example.com", + accessToken: "syt_xxx", + }, + }, + teams: { + component: "TeamsNotification", + example: { notificationName: "Teams ops channel", type: "teams", address: "https://outlook.office.com/webhook/..." }, + }, + telegram: { + component: "TelegramNotification", + example: { notificationName: "Telegram bot", type: "telegram", address: "-1001234567890", accessToken: "123456:ABC-DEF" }, + }, + pushover: { + component: "PushoverNotification", + example: { notificationName: "Pushover personal", type: "pushover", address: "u1234567890abcdef", accessToken: "a1234567890abcdef" }, + }, + twilio: { + component: "TwilioNotification", + example: { + notificationName: "Twilio SMS", + type: "twilio", + accountSid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + accessToken: "your-auth-token", + phone: "+15551234567", + twilioPhoneNumber: "+15557654321", + }, + }, +}; + +const decoratedVariants = createNotificationBodyValidation.options.map((variant) => { + const typeField = variant.shape.type as z.ZodLiteral; + 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 9e2a2b8cb..52f37fde8 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -12,9 +12,8 @@ "@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", - "bullmq": "5.41.2", "cacheable-lookup": "7.0.0", "compression": "1.8.1", "cookie-parser": "^1.4.7", @@ -22,7 +21,7 @@ "dockerode": "4.0.6", "dotenv": "^16.4.5", "express": "^4.19.2", - "express-rate-limit": "8.0.1", + "express-rate-limit": "8.3.2", "gamedig": "5.2.0", "got": "14.4.7", "handlebars": "^4.7.8", @@ -35,19 +34,19 @@ "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", - "multer": "^1.4.5-lts.1", - "nodemailer": "8.0.1", - "pg": "8.20.0", + "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.4.5", + "super-simple-scheduler": "1.9.1", "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", @@ -63,13 +62,11 @@ "@types/multer": "^2.0.0", "@types/nodemailer": "7.0.1", "@types/papaparse": "^5.5.2", - "@types/pg": "8.20.0", "@types/ping": "0.4.4", "@types/swagger-ui-express": "4.1.8", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", - "c8": "10.1.3", "eslint": "^9.17.0", "eslint-plugin-mocha": "^10.5.0", "esm": "3.2.25", @@ -130,6 +127,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", @@ -801,40 +811,20 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz", - "integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", + "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "fast-xml-parser": "5.4.1", + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/@aws/lambda-invoke-store": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", @@ -1378,16 +1368,6 @@ "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", "license": "Apache-2.0" }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -3200,84 +3180,6 @@ "sparse-bitfield": "^3.0.3" } }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -3855,9 +3757,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", - "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4529,18 +4431,6 @@ "@types/node": "*" } }, - "node_modules/@types/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, "node_modules/@types/ping": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/@types/ping/-/ping-0.4.4.tgz", @@ -4962,9 +4852,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5565,14 +5455,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "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-jest": { @@ -5905,9 +5795,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -6038,21 +5928,6 @@ "node": ">=10.0.0" } }, - "node_modules/bullmq": { - "version": "5.41.2", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.41.2.tgz", - "integrity": "sha512-wqsUIHW2Td86mTKTepqQpKLLUtP4gmX89bUO1YL2fAorxwj3da1GYtroGZMCg/zgB/+zMRsbylL6DHyMUWX7fA==", - "license": "MIT", - "dependencies": { - "cron-parser": "^4.9.0", - "ioredis": "^5.4.1", - "msgpackr": "^1.11.2", - "node-abort-controller": "^3.1.1", - "semver": "^7.5.4", - "tslib": "^2.0.0", - "uuid": "^9.0.0" - } - }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -6073,40 +5948,6 @@ "node": ">= 0.8" } }, - "node_modules/c8": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", - "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.1", - "@istanbuljs/schema": "^0.1.3", - "find-up": "^5.0.0", - "foreground-child": "^3.1.1", - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.1.6", - "test-exclude": "^7.0.1", - "v8-to-istanbul": "^9.0.0", - "yargs": "^17.7.2", - "yargs-parser": "^21.1.1" - }, - "bin": { - "c8": "bin/c8.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "monocart-coverage-reports": "^2" - }, - "peerDependenciesMeta": { - "monocart-coverage-reports": { - "optional": true - } - } - }, "node_modules/cacheable-lookup": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", @@ -7335,13 +7176,10 @@ } }, "node_modules/dompurify": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", - "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", "license": "(MPL-2.0 OR Apache-2.0)", - "engines": { - "node": ">=20" - }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -8062,12 +7900,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.0.1.tgz", - "integrity": "sha512-aZVCnybn7TVmxO4BtlmnvX+nuz8qHW124KKJ8dumsBsmv5ZLxE0pYu7S2nwyRBGHHCAzdmnGyrc5U/rksSPO7Q==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -8152,9 +7990,9 @@ "license": "MIT" }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "dev": true, "funding": [ { @@ -8162,7 +8000,31 @@ "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } }, "node_modules/fastq": { "version": "1.20.1", @@ -8303,9 +8165,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -8316,9 +8178,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "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", @@ -8752,9 +8614,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -8854,9 +8716,9 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "license": "MIT", "dependencies": { "minimist": "^1.2.5", @@ -9235,9 +9097,9 @@ } }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -10156,9 +10018,9 @@ } }, "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -10693,9 +10555,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "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.camelcase": { @@ -11237,9 +11099,9 @@ } }, "node_modules/mjml-cli/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -11729,37 +11591,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/msgpackr": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", - "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, "node_modules/multer": { "version": "1.4.5-lts.2", "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", @@ -11869,12 +11700,6 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, - "node_modules/node-abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "license": "MIT" - }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -11937,21 +11762,6 @@ "webidl-conversions": "^3.0.0" } }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -11966,9 +11776,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", - "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -12160,6 +11970,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", @@ -12333,6 +12153,22 @@ "node": ">=8" } }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -12369,9 +12205,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/path-type": { @@ -12384,96 +12220,6 @@ "node": ">=8" } }, - "node_modules/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "pg-connection-string": "^2.12.0", - "pg-pool": "^3.13.0", - "pg-protocol": "^1.13.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.3.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", - "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", - "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", - "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -12481,9 +12227,9 @@ "license": "ISC" }, "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==", "license": "MIT", "engines": { "node": ">=8.6" @@ -12607,9 +12353,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", @@ -13099,45 +12845,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/posthtml": { "version": "0.16.7", "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz", @@ -13318,9 +13025,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -13355,10 +13062,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "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/pstree.remy": { "version": "1.1.8", @@ -14183,15 +13893,6 @@ "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", "license": "ISC" }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -14509,9 +14210,9 @@ } }, "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", "dev": true, "funding": [ { @@ -14538,25 +14239,12 @@ } }, "node_modules/super-simple-scheduler": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.4.5.tgz", - "integrity": "sha512-xHR3a+pLywY7HtwjWj/+OaANdEwAieQhckC/i0s6OoHtVJQ4+VLO1VfkKUbxp+oSROGXcaZet8PTGONVdKNUUw==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/super-simple-scheduler/-/super-simple-scheduler-1.9.1.tgz", + "integrity": "sha512-JSiEQLk7MsnlSkJ87uoyFKIseG2N27ikB0FSxlh25nnQHWC7R5Lrd0VcoLpwSBanyLzlAslhK4W2rQ0mCU8jCQ==", "license": "MIT", "dependencies": { - "uuid": "11.1.0", - "winston": "3.17.0" - } - }, - "node_modules/super-simple-scheduler/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "uuid": "11.1.0" } }, "node_modules/super-simple-scheduler/node_modules/uuid": { @@ -14572,28 +14260,6 @@ "uuid": "dist/esm/bin/uuid" } }, - "node_modules/super-simple-scheduler/node_modules/winston": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", - "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", - "license": "MIT", - "dependencies": { - "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.2", - "async": "^3.2.3", - "is-stream": "^2.0.0", - "logform": "^2.7.0", - "one-time": "^1.0.0", - "readable-stream": "^3.4.0", - "safe-stable-stringify": "^2.3.1", - "stack-trace": "0.0.x", - "triple-beam": "^1.3.0", - "winston-transport": "^4.9.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -14681,47 +14347,6 @@ "node": ">=6" } }, - "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -14764,9 +14389,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "peer": true, @@ -15013,6 +14638,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "devOptional": true, "license": "0BSD" }, "node_modules/tsx": { @@ -15155,9 +14781,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -15825,9 +15451,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==", "dev": true, "license": "ISC", "bin": { @@ -15936,6 +15562,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 639a1b9e2..ea581be1a 100755 --- a/server/package.json +++ b/server/package.json @@ -5,10 +5,15 @@ "main": "index.js", "type": "module", "scripts": { - "test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage", + "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", + "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 .", @@ -27,9 +32,8 @@ "@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", - "bullmq": "5.41.2", "cacheable-lookup": "7.0.0", "compression": "1.8.1", "cookie-parser": "^1.4.7", @@ -37,7 +41,7 @@ "dockerode": "4.0.6", "dotenv": "^16.4.5", "express": "^4.19.2", - "express-rate-limit": "8.0.1", + "express-rate-limit": "8.3.2", "gamedig": "5.2.0", "got": "14.4.7", "handlebars": "^4.7.8", @@ -50,19 +54,19 @@ "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", - "multer": "^1.4.5-lts.1", - "nodemailer": "8.0.1", - "pg": "8.20.0", + "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.4.5", + "super-simple-scheduler": "1.9.1", "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", @@ -78,13 +82,11 @@ "@types/multer": "^2.0.0", "@types/nodemailer": "7.0.1", "@types/papaparse": "^5.5.2", - "@types/pg": "8.20.0", "@types/ping": "0.4.4", "@types/swagger-ui-express": "4.1.8", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", - "c8": "10.1.3", "eslint": "^9.17.0", "eslint-plugin-mocha": "^10.5.0", "esm": "3.2.25", 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/services.ts b/server/src/config/services.ts index 8ed72862a..309f55242 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -29,6 +29,8 @@ import { MatrixProvider, TeamsProvider, TelegramProvider, + PushoverProvider, + TwilioProvider, // Interfaces INetworkService, IEmailService, @@ -62,8 +64,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"; @@ -98,19 +102,6 @@ import { MongoTeamsRepository, MongoMaintenanceWindowsRepository, MongoSettingsRepository, - TimescaleMonitorsRepository, - TimescaleChecksRepository, - TimescaleGeoChecksRepository, - TimescaleMonitorStatsRepository, - TimescaleStatusPagesRepository, - TimescaleUsersRepository, - TimescaleInvitesRepository, - TimescaleRecoveryTokensRepository, - TimescaleNotificationsRepository, - TimescaleIncidentsRepository, - TimescaleTeamsRepository, - TimescaleMaintenanceWindowsRepository, - TimescaleSettingsRepository, IMonitorsRepository, IChecksRepository, IGeoChecksRepository, @@ -126,7 +117,6 @@ import { IMaintenanceWindowsRepository, } from "@/repositories/index.js"; import { ILogger } from "@/utils/logger.js"; -import TimescaleDB from "@/db/TimescaleDB.js"; import { AppError } from "@/utils/AppError.js"; export type InitializedServices = { @@ -183,8 +173,6 @@ export const initializeServices = async ({ if (dbType === "mongodb") { db = new MongoDB(logger, envSettings); - } else if (dbType === "timescaledb") { - db = new TimescaleDB(logger, envSettings); } if (!db) { @@ -224,23 +212,7 @@ export const initializeServices = async ({ teamsRepository = new MongoTeamsRepository(); maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); } else { - const pool = db.getPool(); - if (!pool) { - throw new Error("Failed to get database pool"); - } - monitorsRepository = new TimescaleMonitorsRepository(pool); - checksRepository = new TimescaleChecksRepository(pool); - geoChecksRepository = new TimescaleGeoChecksRepository(pool); - monitorStatsRepository = new TimescaleMonitorStatsRepository(pool); - statusPagesRepository = new TimescaleStatusPagesRepository(pool); - usersRepository = new TimescaleUsersRepository(pool); - invitesRepository = new TimescaleInvitesRepository(pool); - recoveryTokensRepository = new TimescaleRecoveryTokensRepository(pool); - settingsRepository = new TimescaleSettingsRepository(pool); - notificationsRepository = new TimescaleNotificationsRepository(pool); - incidentsRepository = new TimescaleIncidentsRepository(pool); - teamsRepository = new TimescaleTeamsRepository(pool); - maintenanceWindowsRepository = new TimescaleMaintenanceWindowsRepository(pool); + throw new AppError({ message: "Unsupported database type", status: 500 }); } // Inject settings repository into settings service (now that DB is connected) @@ -256,6 +228,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, @@ -267,6 +240,7 @@ export const initializeServices = async ({ gameProvider, grpcProvider, webSocketProvider, + dnsProvider, ]); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); @@ -298,6 +272,8 @@ export const initializeServices = async ({ const matrixProvider = new MatrixProvider(logger); const teamsProvider = new TeamsProvider(logger); const telegramProvider = new TelegramProvider(logger); + const pushoverProvider = new PushoverProvider(logger); + const twilioProvider = new TwilioProvider(logger); const notificationsService = new NotificationsService( notificationsRepository, @@ -310,6 +286,8 @@ export const initializeServices = async ({ matrixProvider, teamsProvider, telegramProvider, + pushoverProvider, + twilioProvider, settingsService, logger, notificationMessageBuilder @@ -364,7 +342,6 @@ export const initializeServices = async ({ }); const monitorService = new MonitorService({ jobQueue: superSimpleQueue, - emailService, logger, games, monitorsRepository, @@ -375,7 +352,7 @@ export const initializeServices = async ({ incidentsRepository, }); - const statusPageService = new StatusPageService(statusPagesRepository); + const statusPageService = new StatusPageService(statusPagesRepository, settingsService); const services = { settingsService, diff --git a/server/src/controllers/controllerUtils.ts b/server/src/controllers/controllerUtils.ts index 17159a03c..67dc71a70 100755 --- a/server/src/controllers/controllerUtils.ts +++ b/server/src/controllers/controllerUtils.ts @@ -29,6 +29,14 @@ export const optionalString = (value: unknown, fieldName: string): string | unde throw new AppError({ message: `${fieldName} must be a string`, status: 400 }); }; +export const extractString = (value: unknown): string | undefined => { + const candidate = Array.isArray(value) ? value[0] : value; + if (typeof candidate !== "string" || candidate.length === 0) { + return undefined; + } + return candidate; +}; + export const optionalNumber = (value: unknown, fieldName: string): number | undefined => { if (value === undefined) { return undefined; diff --git a/server/src/controllers/incidentController.ts b/server/src/controllers/incidentController.ts index f12327541..03f1c7fe4 100644 --- a/server/src/controllers/incidentController.ts +++ b/server/src/controllers/incidentController.ts @@ -1,6 +1,6 @@ import { AppError } from "@/utils/AppError.js"; import { Request, Response, NextFunction } from "express"; -import { requireTeamId, requireUserId, requireUserEmail } from "./controllerUtils.js"; +import { requireTeamId, requireUserId, requireUserEmail, extractString } from "./controllerUtils.js"; import { IIncidentService } from "@/service/index.js"; import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js"; @@ -85,12 +85,13 @@ class IncidentController implements IIncidentController { const teamId = requireTeamId(req.user?.teamId); const userId = requireUserId(req.user?.id); const userEmail = requireUserEmail(req.user?.email); - const incidentId = req.params?.incidentId; + const incidentId = extractString(req.params?.incidentId); if (!incidentId) { throw new AppError({ message: "Incident ID is required", service: SERVICE_NAME, status: 400 }); } - const resolvedIncident = await this.incidentService.resolveIncident(incidentId, userId, teamId, req?.body?.comment, userEmail); + const comment = extractString(req.body?.comment); + const resolvedIncident = await this.incidentService.resolveIncident(incidentId, userId, teamId, comment, userEmail); return res.status(200).json({ success: true, diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 7f1136b7c..5cb6f32be 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, + }); + } catch (error) { + next(error); + } + }; + addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => { try { const id = requireUserId(req.user?.id); diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index 0a49cbdb1..301c15446 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -37,13 +37,9 @@ class NotificationController implements INotificationController { const notification = testNotificationBodyValidation.parse(req.body); const success = await this.notificationsService.sendTestNotification(notification); - if (!success) { - throw new AppError({ message: "Sending notification failed", status: 500 }); - } - return res.status(200).json({ - success: true, - msg: "Notification sent successfully", + success, + msg: success ? "Notification sent successfully" : "Notification could not be sent — check the destination details.", details: { service: SERVICE_NAME }, }); } catch (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/statusPageController.ts b/server/src/controllers/statusPageController.ts index 0c73b6e3b..190c3a9fd 100644 --- a/server/src/controllers/statusPageController.ts +++ b/server/src/controllers/statusPageController.ts @@ -66,6 +66,7 @@ class StatusPageController implements IStatusPageController { if (req.file) { imageValidation.parse(req.file); } + const teamId = requireTeamId(req?.user?.teamId); const statusPageId = req.params.id as string; if (!statusPageId) { @@ -127,7 +128,10 @@ class StatusPageController implements IStatusPageController { return res.status(200).json({ success: true, msg: "Status page retrieved successfully", - data: { statusPage, monitors: normalizedMonitors }, + data: { + statusPage, + monitors: normalizedMonitors, + }, }); } catch (error) { next(error); diff --git a/server/src/controllers/statusPageThemeFlag.ts b/server/src/controllers/statusPageThemeFlag.ts new file mode 100644 index 000000000..bd65d5cb2 --- /dev/null +++ b/server/src/controllers/statusPageThemeFlag.ts @@ -0,0 +1,33 @@ +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/types/statusPage.js"; + +/** + * When STATUS_PAGE_THEMES_ENABLED is false, the controller drops incoming + * theme fields before persisting so an admin client can't accidentally store + * a value the public page won't honour. Whatever a caller sends here for + * `theme` / `themeMode` is silently ignored — the rest of the body is + * preserved verbatim. + * + * The strip is silent (not a 400) because the kill switch is an ops-side + * concern: API clients should not have to know whether the operator has + * disabled themes today, only that their saved value won't take effect. + * If we later decide we'd rather fail loudly, change this to throw a + * 400 AppError; downstream consumers already handle it. + */ +export const withoutThemeFields = (body: Record): Record => { + const rest = { ...body }; + delete rest.theme; + delete rest.themeMode; + return rest; +}; + +/** + * When STATUS_PAGE_THEMES_ENABLED is false, callers reading a status page + * should see the canonical default theme / mode regardless of what's stored + * in Mongo. Whatever the user previously chose stays in the DB so flipping + * the flag back on resurfaces their selection. + */ +export const applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({ + ...statusPage, + theme: DEFAULT_STATUS_PAGE_THEME, + themeMode: DEFAULT_STATUS_PAGE_THEME_MODE, +}); diff --git a/server/src/db/IDb.ts b/server/src/db/IDb.ts index e71e36cca..4c719999f 100644 --- a/server/src/db/IDb.ts +++ b/server/src/db/IDb.ts @@ -1,7 +1,4 @@ -import { Pool } from "pg"; - export interface IDb { connect(): Promise; disconnect(): Promise; - getPool(): Pool | null; } diff --git a/server/src/db/MongoDB.ts b/server/src/db/MongoDB.ts index 8aea56279..1284aef4d 100755 --- a/server/src/db/MongoDB.ts +++ b/server/src/db/MongoDB.ts @@ -5,7 +5,6 @@ import { ILogger } from "@/utils/logger.js"; import { EnvConfig } from "@/service/system/settingsService.js"; const SERVICE_NAME = "MongoDB"; import { IDb } from "@/db/IDb.js"; -import { Pool } from "pg"; class MongoDB implements IDb { static SERVICE_NAME = SERVICE_NAME; @@ -35,6 +34,12 @@ class MongoDB implements IDb { setDefaultsOnInsert: true, } ); + // Run migrations BEFORE syncIndexes. The 0006 dedupe migration must run before + // the unique index on MonitorStats.monitorId is enforced, otherwise databases + // with stray duplicates (caused by the pre-fix race in updateRunningStats) + // will crash at startup when the unique index fails to build. + await runMigrations(); + // Sync indexes const models = mongoose.modelNames(); for (const modelName of models) { @@ -47,8 +52,6 @@ class MongoDB implements IDb { service: SERVICE_NAME, method: "connect", }); - - await runMigrations(); } catch (error: unknown) { this.logger.error({ message: error instanceof Error ? error.message : "Unknown error", @@ -75,10 +78,6 @@ class MongoDB implements IDb { }); } }; - - getPool = () => { - return null; - }; } export default MongoDB; diff --git a/server/src/db/TimescaleDB.ts b/server/src/db/TimescaleDB.ts deleted file mode 100644 index b36c54292..000000000 --- a/server/src/db/TimescaleDB.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { IDb } from "@/db/IDb.js"; -import { ILogger } from "@/utils/logger.js"; -import { EnvConfig } from "@/service/system/settingsService.js"; -import { Client, Pool } from "pg"; -import { runTimescaleDBMigrations } from "@/db/migration/timescaledb/index.js"; - -const SERVICE_NAME = "TimescaleDB"; - -class TimescaleDB implements IDb { - private pool: Pool | null = null; - - constructor( - private logger: ILogger, - private envSettings: EnvConfig - ) {} - - private ensureDatabaseExists = async (connectionString: string) => { - const url = new URL(connectionString); - const dbName = url.pathname.slice(1); // Remove leading "/" - - // Connect to the default "postgres" database - url.pathname = "/postgres"; - const client = new Client({ connectionString: url.toString() }); - await client.connect(); - - const result = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]); - if (result.rowCount === 0) { - await client.query(`CREATE DATABASE "${dbName}"`); - this.logger.info({ - message: `Created database "${dbName}"`, - service: SERVICE_NAME, - method: "ensureDatabaseExists", - }); - } - await client.end(); - }; - - connect = async () => { - try { - const connectionString = this.envSettings.dbConnectionString || "postgresql://postgres:password@localhost:5432/checkmate"; - - await this.ensureDatabaseExists(connectionString); - - this.pool = new Pool({ connectionString }); - - const client = await this.pool.connect(); - client.release(); - - // Ensure TimescaleDB extension is enabled - await this.pool.query("CREATE EXTENSION IF NOT EXISTS timescaledb;"); - - this.logger.info({ - message: "Connected to TimescaleDB", - service: SERVICE_NAME, - method: "connect", - }); - - await runTimescaleDBMigrations(this.pool, this.logger); - } catch (error: unknown) { - this.logger.error({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "connect", - stack: error instanceof Error ? error.stack : undefined, - }); - throw error; - } - }; - - disconnect = async () => { - try { - if (this.pool === null) { - return; - } - this.logger.info({ - message: "Disconnecting from TimescaleDB", - service: SERVICE_NAME, - method: "disconnect", - }); - await this.pool.end(); - this.pool = null; - this.logger.info({ - message: "Disconnected from TimescaleDB", - service: SERVICE_NAME, - method: "disconnect", - }); - } catch (error: unknown) { - this.logger.error({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "disconnect", - stack: error instanceof Error ? error.stack : undefined, - }); - } - }; - - getPool = (): Pool => { - if (this.pool === null) { - throw new Error("TimescaleDB is not connected. Call connect() first."); - } - return this.pool; - }; -} - -export default TimescaleDB; diff --git a/server/src/db/migration/0006_cleanupDuplicateMonitorStatsForUniqueIndex.ts b/server/src/db/migration/0006_cleanupDuplicateMonitorStatsForUniqueIndex.ts new file mode 100644 index 000000000..814b19776 --- /dev/null +++ b/server/src/db/migration/0006_cleanupDuplicateMonitorStatsForUniqueIndex.ts @@ -0,0 +1,78 @@ +import { MonitorStatsModel } from "../models/MonitorStats.js"; +import { logger } from "@/utils/logger.js"; + +/** + * Second-pass dedupe of MonitorStats before the unique index on monitorId is enforced. + * + * 0003 ran the same dedupe, but the pre-fix race in StatusService.updateRunningStats + * could create new duplicates after 0003 completed. This migration re-runs the dedupe so + * the unique index added in the same PR can be built without failing. + * + * Keeps the document with the highest totalChecks for each monitorId (most data) and + * deletes the rest. Falls back to most recent updatedAt when totalChecks tie. + */ +export async function cleanupDuplicateMonitorStatsForUniqueIndex(): Promise { + const SERVICE_NAME = "Migration:CleanupDuplicateMonitorStatsForUniqueIndex"; + + try { + logger.info({ service: SERVICE_NAME, message: "Starting pre-unique-index dedupe of MonitorStats" }); + + const duplicates = await MonitorStatsModel.aggregate([ + { + $group: { + _id: "$monitorId", + docs: { $push: { id: "$_id", totalChecks: "$totalChecks", updatedAt: "$updatedAt" } }, + count: { $sum: 1 }, + }, + }, + { $match: { count: { $gt: 1 } } }, + ]); + + if (duplicates.length === 0) { + logger.info({ service: SERVICE_NAME, message: "No duplicate MonitorStats found" }); + return; + } + + logger.info({ + service: SERVICE_NAME, + message: `Found ${duplicates.length} monitors with duplicate stats`, + }); + + let totalDeleted = 0; + + type DocInfo = { id: string; totalChecks: number; updatedAt: Date }; + + for (const duplicate of duplicates) { + const monitorId = duplicate._id; + const docs: DocInfo[] = duplicate.docs; + + // Sort by totalChecks desc, then updatedAt desc — keep the winner, delete the rest. + docs.sort((a, b) => { + const checksDiff = (b.totalChecks ?? 0) - (a.totalChecks ?? 0); + if (checksDiff !== 0) return checksDiff; + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); + + const toDelete = docs.slice(1).map((doc) => doc.id); + + if (toDelete.length > 0) { + const result = await MonitorStatsModel.deleteMany({ _id: { $in: toDelete } }); + totalDeleted += result.deletedCount ?? 0; + + logger.debug({ + service: SERVICE_NAME, + message: `Deleted ${result.deletedCount} duplicate stats for monitor ${monitorId}`, + }); + } + } + + logger.info({ + service: SERVICE_NAME, + message: `Cleanup complete. Deleted ${totalDeleted} duplicate MonitorStats documents`, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error during MonitorStats dedupe: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index f1a672af9..5b74a96fc 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -4,6 +4,7 @@ import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStat import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.js"; import MigrationModel from "../models/Migration.js"; import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js"; +import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -17,6 +18,7 @@ const migrations: MigrationEntry[] = [ { name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats }, { name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds }, { name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray }, + { name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex }, ]; const runMigrations = async (logger?: ILogger) => { diff --git a/server/src/db/migration/timescaledb/0001_create_enums.ts b/server/src/db/migration/timescaledb/0001_create_enums.ts deleted file mode 100644 index a16ec955b..000000000 --- a/server/src/db/migration/timescaledb/0001_create_enums.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Pool } from "pg"; - -export const createEnums = async (pool: Pool) => { - await pool.query(` - DO $$ BEGIN - CREATE TYPE monitor_type AS ENUM ('http', 'ping', 'pagespeed', 'hardware', 'docker', 'port', 'game', 'grpc', 'websocket', 'unknown'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE monitor_status AS ENUM ('up', 'down', 'paused', 'initializing', 'maintenance', 'breached'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE notification_type AS ENUM ('email', 'slack', 'discord', 'webhook', 'pager_duty', 'matrix', 'teams'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE user_role AS ENUM ('user', 'admin', 'superadmin', 'demo'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE incident_resolution_type AS ENUM ('automatic', 'manual'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE duration_unit AS ENUM ('seconds', 'minutes', 'hours', 'days'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE status_page_type AS ENUM ('uptime', 'infrastructure'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - - DO $$ BEGIN - CREATE TYPE match_method AS ENUM ('equal', 'include', 'regex'); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; - `); -}; - -export const dropEnums = async (pool: Pool) => { - await pool.query(` - DROP TYPE IF EXISTS match_method; - DROP TYPE IF EXISTS status_page_type; - DROP TYPE IF EXISTS duration_unit; - DROP TYPE IF EXISTS incident_resolution_type; - DROP TYPE IF EXISTS user_role; - DROP TYPE IF EXISTS notification_type; - DROP TYPE IF EXISTS monitor_status; - DROP TYPE IF EXISTS monitor_type; - `); -}; diff --git a/server/src/db/migration/timescaledb/0002_create_teams.ts b/server/src/db/migration/timescaledb/0002_create_teams.ts deleted file mode 100644 index 905883eb2..000000000 --- a/server/src/db/migration/timescaledb/0002_create_teams.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Pool } from "pg"; - -export const createTeams = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS teams ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT NOT NULL UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_teams_email ON teams (email); - `); -}; - -export const dropTeams = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS teams;`); -}; diff --git a/server/src/db/migration/timescaledb/0003_create_users.ts b/server/src/db/migration/timescaledb/0003_create_users.ts deleted file mode 100644 index 6c8f57027..000000000 --- a/server/src/db/migration/timescaledb/0003_create_users.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Pool } from "pg"; - -export const createUsers = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - team_id UUID REFERENCES teams(id) ON DELETE SET NULL, - first_name TEXT NOT NULL, - last_name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - avatar_image TEXT, - profile_image BYTEA, - profile_image_content_type TEXT, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - is_verified BOOLEAN NOT NULL DEFAULT FALSE, - roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[], - check_ttl INTEGER, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_users_team_id ON users (team_id); - CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); - `); -}; - -export const dropUsers = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS users;`); -}; diff --git a/server/src/db/migration/timescaledb/0004_create_monitors.ts b/server/src/db/migration/timescaledb/0004_create_monitors.ts deleted file mode 100644 index a4829c7f5..000000000 --- a/server/src/db/migration/timescaledb/0004_create_monitors.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Pool } from "pg"; - -export const createMonitors = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS monitors ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - team_id UUID NOT NULL REFERENCES teams(id), - name TEXT NOT NULL, - description TEXT, - type monitor_type NOT NULL, - status monitor_status NOT NULL DEFAULT 'initializing', - - -- HTTP / Website monitoring - url TEXT, - port INTEGER, - ignore_tls_errors BOOLEAN DEFAULT FALSE, - use_advanced_matching BOOLEAN DEFAULT FALSE, - json_path TEXT, - expected_value TEXT, - match_method match_method, - secret TEXT, - - -- Scheduling - interval_ms INTEGER NOT NULL DEFAULT 60000, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Uptime tracking - status_window BOOLEAN[], - status_window_size INTEGER DEFAULT 5, - status_window_threshold INTEGER DEFAULT 60, - uptime_percentage DOUBLE PRECISION, - - -- Infrastructure alert thresholds - cpu_alert_threshold INTEGER, - cpu_alert_counter INTEGER, - memory_alert_threshold INTEGER, - memory_alert_counter INTEGER, - disk_alert_threshold INTEGER, - disk_alert_counter INTEGER, - temp_alert_threshold INTEGER, - temp_alert_counter INTEGER, - selected_disks TEXT[], - - -- Game monitoring - game_id TEXT, - grpc_service_name TEXT, - - -- Grouping - monitor_group TEXT, - - -- Geo checks - geo_check_enabled BOOLEAN DEFAULT FALSE, - geo_check_locations TEXT[], - geo_check_interval_ms INTEGER DEFAULT 300000, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_monitors_team_type ON monitors (team_id, type); - CREATE INDEX IF NOT EXISTS idx_monitors_user_id ON monitors (user_id); - CREATE INDEX IF NOT EXISTS idx_monitors_status ON monitors (status); - `); -}; - -export const dropMonitors = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS monitors;`); -}; diff --git a/server/src/db/migration/timescaledb/0005_create_notifications.ts b/server/src/db/migration/timescaledb/0005_create_notifications.ts deleted file mode 100644 index 2a4a065fa..000000000 --- a/server/src/db/migration/timescaledb/0005_create_notifications.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Pool } from "pg"; - -export const createNotifications = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS notifications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - team_id UUID NOT NULL REFERENCES teams(id), - type notification_type NOT NULL, - notification_name TEXT, - address TEXT, - phone TEXT, - - -- Matrix-specific fields - homeserver_url TEXT, - room_id TEXT, - access_token TEXT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_notifications_team ON notifications (team_id); - CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id); - `); -}; - -export const dropNotifications = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS notifications;`); -}; diff --git a/server/src/db/migration/timescaledb/0006_create_monitor_notifications.ts b/server/src/db/migration/timescaledb/0006_create_monitor_notifications.ts deleted file mode 100644 index d2a306322..000000000 --- a/server/src/db/migration/timescaledb/0006_create_monitor_notifications.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Pool } from "pg"; - -export const createMonitorNotifications = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS monitor_notifications ( - monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, - notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE, - PRIMARY KEY (monitor_id, notification_id) - ); - - CREATE INDEX IF NOT EXISTS idx_monitor_notifications_notification - ON monitor_notifications (notification_id); - `); -}; - -export const dropMonitorNotifications = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS monitor_notifications;`); -}; diff --git a/server/src/db/migration/timescaledb/0007_create_incidents.ts b/server/src/db/migration/timescaledb/0007_create_incidents.ts deleted file mode 100644 index e1c990f95..000000000 --- a/server/src/db/migration/timescaledb/0007_create_incidents.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Pool } from "pg"; - -export const createIncidents = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS incidents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, - team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, - start_time TIMESTAMPTZ NOT NULL, - end_time TIMESTAMPTZ, - status BOOLEAN, - message TEXT, - status_code INTEGER, - resolution_type incident_resolution_type, - resolved_by UUID REFERENCES users(id) ON DELETE SET NULL, - resolved_by_email TEXT, - comment TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents (monitor_id, status); - CREATE INDEX IF NOT EXISTS idx_incidents_team_status ON incidents (team_id, status); - CREATE INDEX IF NOT EXISTS idx_incidents_team_start ON incidents (team_id, start_time DESC); - CREATE INDEX IF NOT EXISTS idx_incidents_status_start ON incidents (status, start_time DESC); - CREATE INDEX IF NOT EXISTS idx_incidents_resolution ON incidents (resolution_type, status); - CREATE INDEX IF NOT EXISTS idx_incidents_resolved_by ON incidents (resolved_by, status); - CREATE INDEX IF NOT EXISTS idx_incidents_created ON incidents (created_at DESC); - `); -}; - -export const dropIncidents = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS incidents;`); -}; diff --git a/server/src/db/migration/timescaledb/0008_create_monitor_stats.ts b/server/src/db/migration/timescaledb/0008_create_monitor_stats.ts deleted file mode 100644 index f08004873..000000000 --- a/server/src/db/migration/timescaledb/0008_create_monitor_stats.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Pool } from "pg"; - -export const createMonitorStats = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS monitor_stats ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - monitor_id UUID NOT NULL UNIQUE REFERENCES monitors(id) ON DELETE CASCADE, - avg_response_time DOUBLE PRECISION DEFAULT 0, - max_response_time DOUBLE PRECISION DEFAULT 0, - total_checks BIGINT DEFAULT 0, - total_up_checks BIGINT DEFAULT 0, - total_down_checks BIGINT DEFAULT 0, - uptime_percentage DOUBLE PRECISION DEFAULT 0, - last_check_timestamp TIMESTAMPTZ, - last_response_time DOUBLE PRECISION DEFAULT 0, - time_of_last_failure TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - `); -}; - -export const dropMonitorStats = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS monitor_stats;`); -}; diff --git a/server/src/db/migration/timescaledb/0009_create_checks.ts b/server/src/db/migration/timescaledb/0009_create_checks.ts deleted file mode 100644 index 27753f524..000000000 --- a/server/src/db/migration/timescaledb/0009_create_checks.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { Pool } from "pg"; - -export const createChecks = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS checks ( - id UUID NOT NULL DEFAULT gen_random_uuid(), - monitor_id UUID NOT NULL, - team_id UUID NOT NULL, - monitor_type monitor_type NOT NULL, - status BOOLEAN, - response_time INTEGER, - status_code INTEGER, - message TEXT, - - -- HTTP request timings - timing_start DOUBLE PRECISION, - timing_socket DOUBLE PRECISION, - timing_lookup DOUBLE PRECISION, - timing_connect DOUBLE PRECISION, - timing_secure_connect DOUBLE PRECISION, - timing_upload DOUBLE PRECISION, - timing_response DOUBLE PRECISION, - timing_end DOUBLE PRECISION, - phase_wait DOUBLE PRECISION, - phase_dns DOUBLE PRECISION, - phase_tcp DOUBLE PRECISION, - phase_tls DOUBLE PRECISION, - phase_request DOUBLE PRECISION, - phase_first_byte DOUBLE PRECISION, - phase_download DOUBLE PRECISION, - phase_total DOUBLE PRECISION, - - -- CPU metrics - cpu_physical_core INTEGER, - cpu_logical_core INTEGER, - cpu_frequency DOUBLE PRECISION, - cpu_current_frequency DOUBLE PRECISION, - cpu_temperature DOUBLE PRECISION[], - cpu_free_percent DOUBLE PRECISION, - cpu_usage_percent DOUBLE PRECISION, - - -- Memory metrics - mem_total_bytes BIGINT, - mem_available_bytes BIGINT, - mem_used_bytes BIGINT, - mem_usage_percent DOUBLE PRECISION, - - -- Host info - host_os TEXT, - host_platform TEXT, - host_kernel_version TEXT, - host_pretty_name TEXT, - - -- Capture agent info - capture_version TEXT, - capture_mode TEXT, - - -- Lighthouse / PageSpeed scores - lighthouse_performance DOUBLE PRECISION, - lighthouse_accessibility DOUBLE PRECISION, - lighthouse_best_practices DOUBLE PRECISION, - lighthouse_seo DOUBLE PRECISION, - - -- Lighthouse audit details - audit_cls_score DOUBLE PRECISION, - audit_cls_value DOUBLE PRECISION, - audit_cls_display TEXT, - audit_si_score DOUBLE PRECISION, - audit_si_value DOUBLE PRECISION, - audit_si_display TEXT, - audit_fcp_score DOUBLE PRECISION, - audit_fcp_value DOUBLE PRECISION, - audit_fcp_display TEXT, - audit_lcp_score DOUBLE PRECISION, - audit_lcp_value DOUBLE PRECISION, - audit_lcp_display TEXT, - audit_tbt_score DOUBLE PRECISION, - audit_tbt_value DOUBLE PRECISION, - audit_tbt_display TEXT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - SELECT create_hypertable('checks', 'created_at', - chunk_time_interval => INTERVAL '7 days', - if_not_exists => TRUE - ); - - CREATE INDEX IF NOT EXISTS idx_checks_monitor_time - ON checks (monitor_id, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_checks_team_time - ON checks (team_id, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_checks_monitor_type_time - ON checks (monitor_id, monitor_type, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_checks_team_status_time - ON checks (team_id, status, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_checks_status_code - ON checks (status_code, created_at DESC); - `); -}; - -export const dropChecks = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS checks;`); -}; diff --git a/server/src/db/migration/timescaledb/0010_create_check_disks.ts b/server/src/db/migration/timescaledb/0010_create_check_disks.ts deleted file mode 100644 index e77784d39..000000000 --- a/server/src/db/migration/timescaledb/0010_create_check_disks.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Pool } from "pg"; - -export const createCheckDisks = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS check_disks ( - id UUID NOT NULL DEFAULT gen_random_uuid(), - check_id UUID NOT NULL, - check_created_at TIMESTAMPTZ NOT NULL, - device TEXT, - mountpoint TEXT, - total_bytes BIGINT, - free_bytes BIGINT, - used_bytes BIGINT, - usage_percent DOUBLE PRECISION, - total_inodes BIGINT, - free_inodes BIGINT, - used_inodes BIGINT, - inodes_usage_percent DOUBLE PRECISION, - read_bytes BIGINT, - write_bytes BIGINT, - read_time BIGINT, - write_time BIGINT - ); - - SELECT create_hypertable('check_disks', 'check_created_at', - chunk_time_interval => INTERVAL '7 days', - if_not_exists => TRUE - ); - - CREATE INDEX IF NOT EXISTS idx_check_disks_check - ON check_disks (check_id, check_created_at); - `); -}; - -export const dropCheckDisks = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS check_disks;`); -}; diff --git a/server/src/db/migration/timescaledb/0011_create_check_network_interfaces.ts b/server/src/db/migration/timescaledb/0011_create_check_network_interfaces.ts deleted file mode 100644 index 4988fe58e..000000000 --- a/server/src/db/migration/timescaledb/0011_create_check_network_interfaces.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Pool } from "pg"; - -export const createCheckNetworkInterfaces = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS check_network_interfaces ( - id UUID NOT NULL DEFAULT gen_random_uuid(), - check_id UUID NOT NULL, - check_created_at TIMESTAMPTZ NOT NULL, - name TEXT, - bytes_sent BIGINT, - bytes_recv BIGINT, - packets_sent BIGINT, - packets_recv BIGINT, - err_in BIGINT, - err_out BIGINT, - drop_in BIGINT, - drop_out BIGINT, - fifo_in BIGINT, - fifo_out BIGINT - ); - - SELECT create_hypertable('check_network_interfaces', 'check_created_at', - chunk_time_interval => INTERVAL '7 days', - if_not_exists => TRUE - ); - - CREATE INDEX IF NOT EXISTS idx_check_net_check - ON check_network_interfaces (check_id, check_created_at); - `); -}; - -export const dropCheckNetworkInterfaces = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS check_network_interfaces;`); -}; diff --git a/server/src/db/migration/timescaledb/0012_create_check_errors.ts b/server/src/db/migration/timescaledb/0012_create_check_errors.ts deleted file mode 100644 index c84bacac8..000000000 --- a/server/src/db/migration/timescaledb/0012_create_check_errors.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Pool } from "pg"; - -export const createCheckErrors = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS check_errors ( - id UUID NOT NULL DEFAULT gen_random_uuid(), - check_id UUID NOT NULL, - check_created_at TIMESTAMPTZ NOT NULL, - metrics TEXT[], - error TEXT - ); - - SELECT create_hypertable('check_errors', 'check_created_at', - chunk_time_interval => INTERVAL '7 days', - if_not_exists => TRUE - ); - - CREATE INDEX IF NOT EXISTS idx_check_errors_check - ON check_errors (check_id, check_created_at); - `); -}; - -export const dropCheckErrors = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS check_errors;`); -}; diff --git a/server/src/db/migration/timescaledb/0013_create_geo_checks.ts b/server/src/db/migration/timescaledb/0013_create_geo_checks.ts deleted file mode 100644 index a6ee03061..000000000 --- a/server/src/db/migration/timescaledb/0013_create_geo_checks.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Pool } from "pg"; - -export const createGeoChecks = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS geo_checks ( - id UUID NOT NULL DEFAULT gen_random_uuid(), - monitor_id UUID NOT NULL, - team_id UUID NOT NULL, - monitor_type monitor_type NOT NULL, - expiry TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - SELECT create_hypertable('geo_checks', 'created_at', - chunk_time_interval => INTERVAL '7 days', - if_not_exists => TRUE - ); - - CREATE INDEX IF NOT EXISTS idx_geo_checks_monitor_time - ON geo_checks (monitor_id, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_geo_checks_team_time - ON geo_checks (team_id, created_at DESC); - `); -}; - -export const dropGeoChecks = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS geo_checks;`); -}; diff --git a/server/src/db/migration/timescaledb/0014_create_geo_check_results.ts b/server/src/db/migration/timescaledb/0014_create_geo_check_results.ts deleted file mode 100644 index 4e0e45e1a..000000000 --- a/server/src/db/migration/timescaledb/0014_create_geo_check_results.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Pool } from "pg"; - -export const createGeoCheckResults = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS geo_check_results ( - id UUID NOT NULL DEFAULT gen_random_uuid(), - geo_check_id UUID NOT NULL, - geo_check_created_at TIMESTAMPTZ NOT NULL, - - -- Status - status BOOLEAN, - status_code INTEGER, - - -- Location - location_continent TEXT, - location_region TEXT, - location_country TEXT, - location_state TEXT, - location_city TEXT, - location_longitude DOUBLE PRECISION, - location_latitude DOUBLE PRECISION, - - -- Timings - timing_total DOUBLE PRECISION, - timing_dns DOUBLE PRECISION, - timing_tcp DOUBLE PRECISION, - timing_tls DOUBLE PRECISION, - timing_first_byte DOUBLE PRECISION, - timing_download DOUBLE PRECISION - ); - - SELECT create_hypertable('geo_check_results', 'geo_check_created_at', - chunk_time_interval => INTERVAL '7 days', - if_not_exists => TRUE - ); - - CREATE INDEX IF NOT EXISTS idx_geo_results_check - ON geo_check_results (geo_check_id, geo_check_created_at); - CREATE INDEX IF NOT EXISTS idx_geo_results_country - ON geo_check_results (location_country, geo_check_created_at DESC); - `); -}; - -export const dropGeoCheckResults = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS geo_check_results;`); -}; diff --git a/server/src/db/migration/timescaledb/0015_create_invites.ts b/server/src/db/migration/timescaledb/0015_create_invites.ts deleted file mode 100644 index 80aca5255..000000000 --- a/server/src/db/migration/timescaledb/0015_create_invites.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Pool } from "pg"; - -export const createInvites = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS invites ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT NOT NULL UNIQUE, - team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, - roles user_role[] NOT NULL DEFAULT ARRAY['user']::user_role[], - token TEXT NOT NULL, - expiry TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_invites_expiry ON invites (expiry); - `); -}; - -export const dropInvites = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS invites;`); -}; diff --git a/server/src/db/migration/timescaledb/0016_create_recovery_tokens.ts b/server/src/db/migration/timescaledb/0016_create_recovery_tokens.ts deleted file mode 100644 index 36a9d0de3..000000000 --- a/server/src/db/migration/timescaledb/0016_create_recovery_tokens.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Pool } from "pg"; - -export const createRecoveryTokens = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS recovery_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT NOT NULL UNIQUE, - token TEXT NOT NULL, - expiry TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_recovery_expiry ON recovery_tokens (expiry); - `); -}; - -export const dropRecoveryTokens = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS recovery_tokens;`); -}; diff --git a/server/src/db/migration/timescaledb/0017_create_maintenance_windows.ts b/server/src/db/migration/timescaledb/0017_create_maintenance_windows.ts deleted file mode 100644 index 190769261..000000000 --- a/server/src/db/migration/timescaledb/0017_create_maintenance_windows.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Pool } from "pg"; - -export const createMaintenanceWindows = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS maintenance_windows ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, - team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, - active BOOLEAN NOT NULL DEFAULT TRUE, - name TEXT, - duration INTEGER, - duration_unit duration_unit, - repeat INTEGER, - start_time TIMESTAMPTZ, - end_time TIMESTAMPTZ, - expiry TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_maint_monitor ON maintenance_windows (monitor_id); - CREATE INDEX IF NOT EXISTS idx_maint_team ON maintenance_windows (team_id); - CREATE INDEX IF NOT EXISTS idx_maint_expiry ON maintenance_windows (expiry); - `); -}; - -export const dropMaintenanceWindows = async (pool: Pool) => { - await pool.query(`DROP TABLE IF EXISTS maintenance_windows;`); -}; diff --git a/server/src/db/migration/timescaledb/0018_create_status_pages.ts b/server/src/db/migration/timescaledb/0018_create_status_pages.ts deleted file mode 100644 index 6037f6852..000000000 --- a/server/src/db/migration/timescaledb/0018_create_status_pages.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Pool } from "pg"; - -export const createStatusPages = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS status_pages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - team_id UUID NOT NULL REFERENCES teams(id), - types status_page_type[] NOT NULL DEFAULT ARRAY['uptime']::status_page_type[], - company_name TEXT, - url TEXT NOT NULL UNIQUE, - timezone TEXT, - color TEXT DEFAULT '#4169E1', - logo_data BYTEA, - logo_content_type TEXT, - is_published BOOLEAN DEFAULT FALSE, - show_charts BOOLEAN DEFAULT FALSE, - show_uptime_percentage BOOLEAN DEFAULT FALSE, - show_admin_login_link BOOLEAN DEFAULT FALSE, - show_infrastructure BOOLEAN DEFAULT FALSE, - custom_css TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - CREATE INDEX IF NOT EXISTS idx_status_pages_team ON status_pages (team_id); - CREATE INDEX IF NOT EXISTS idx_status_pages_url ON status_pages (url); - - CREATE TABLE IF NOT EXISTS status_page_monitors ( - status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, - monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, - sort_order INTEGER DEFAULT 0, - PRIMARY KEY (status_page_id, monitor_id) - ); - - CREATE TABLE IF NOT EXISTS status_page_sub_monitors ( - status_page_id UUID NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE, - monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, - sort_order INTEGER DEFAULT 0, - PRIMARY KEY (status_page_id, monitor_id) - ); - `); -}; - -export const dropStatusPages = async (pool: Pool) => { - await pool.query(` - DROP TABLE IF EXISTS status_page_sub_monitors; - DROP TABLE IF EXISTS status_page_monitors; - DROP TABLE IF EXISTS status_pages; - `); -}; diff --git a/server/src/db/migration/timescaledb/0019_create_app_settings.ts b/server/src/db/migration/timescaledb/0019_create_app_settings.ts deleted file mode 100644 index d2d624d07..000000000 --- a/server/src/db/migration/timescaledb/0019_create_app_settings.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Pool } from "pg"; - -export const createAppSettings = async (pool: Pool) => { - await pool.query(` - CREATE TABLE IF NOT EXISTS app_settings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - check_ttl INTEGER DEFAULT 30, - language TEXT, - jwt_secret TEXT, - pagespeed_api_key TEXT, - - -- SMTP / Email settings - system_email_host TEXT, - system_email_port INTEGER, - system_email_address TEXT, - system_email_password TEXT, - system_email_user TEXT, - system_email_connection_host TEXT, - system_email_tls_servername TEXT, - system_email_secure BOOLEAN, - system_email_pool BOOLEAN, - system_email_ignore_tls BOOLEAN, - system_email_require_tls BOOLEAN, - system_email_reject_unauthorized BOOLEAN, - - show_url BOOLEAN, - version TEXT, - - -- Global infrastructure thresholds - threshold_cpu_usage INTEGER, - threshold_memory_usage INTEGER, - threshold_disk_usage INTEGER, - threshold_temperature INTEGER, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - - -- Enforce singleton: only one row allowed - CREATE OR REPLACE FUNCTION enforce_singleton_app_settings() - RETURNS TRIGGER AS $$ - BEGIN - IF (SELECT COUNT(*) FROM app_settings) >= 1 THEN - RAISE EXCEPTION 'app_settings allows only one row'; - END IF; - RETURN NEW; - END; - $$ LANGUAGE plpgsql; - - DROP TRIGGER IF EXISTS trg_singleton_app_settings ON app_settings; - CREATE TRIGGER trg_singleton_app_settings - BEFORE INSERT ON app_settings - FOR EACH ROW EXECUTE FUNCTION enforce_singleton_app_settings(); - `); -}; - -export const dropAppSettings = async (pool: Pool) => { - await pool.query(` - DROP TABLE IF EXISTS app_settings; - DROP FUNCTION IF EXISTS enforce_singleton_app_settings(); - `); -}; diff --git a/server/src/db/migration/timescaledb/0020_create_continuous_aggregates.ts b/server/src/db/migration/timescaledb/0020_create_continuous_aggregates.ts deleted file mode 100644 index d435ffdf9..000000000 --- a/server/src/db/migration/timescaledb/0020_create_continuous_aggregates.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { Pool } from "pg"; - -export const createContinuousAggregates = async (pool: Pool) => { - // Uptime & Response Time (Hourly) - await pool.query(` - CREATE MATERIALIZED VIEW IF NOT EXISTS checks_hourly - WITH (timescaledb.continuous) AS - SELECT - monitor_id, - time_bucket('1 hour', created_at) AS bucket, - COUNT(*) AS total_checks, - COUNT(*) FILTER (WHERE status = TRUE) AS up_checks, - COUNT(*) FILTER (WHERE status = FALSE) AS down_checks, - AVG(response_time) AS avg_response_time, - AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time, - AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time, - MAX(response_time) AS max_response_time, - MIN(response_time) AS min_response_time - FROM checks - GROUP BY monitor_id, bucket - WITH NO DATA; - `); - await pool.query(`ALTER MATERIALIZED VIEW checks_hourly SET (timescaledb.materialized_only = false);`); - await pool.query(` - SELECT add_continuous_aggregate_policy('checks_hourly', - start_offset => INTERVAL '3 hours', - end_offset => INTERVAL '1 hour', - schedule_interval => INTERVAL '1 hour', - if_not_exists => TRUE - ); - `); - await pool.query(`CALL refresh_continuous_aggregate('checks_hourly', NULL, NULL);`); - - // Uptime & Response Time (Daily) - await pool.query(` - CREATE MATERIALIZED VIEW IF NOT EXISTS checks_daily - WITH (timescaledb.continuous) AS - SELECT - monitor_id, - time_bucket('1 day', created_at) AS bucket, - COUNT(*) AS total_checks, - COUNT(*) FILTER (WHERE status = TRUE) AS up_checks, - COUNT(*) FILTER (WHERE status = FALSE) AS down_checks, - AVG(response_time) AS avg_response_time, - AVG(response_time) FILTER (WHERE status = TRUE) AS avg_up_response_time, - AVG(response_time) FILTER (WHERE status = FALSE) AS avg_down_response_time, - MAX(response_time) AS max_response_time - FROM checks - GROUP BY monitor_id, bucket - WITH NO DATA; - `); - await pool.query(`ALTER MATERIALIZED VIEW checks_daily SET (timescaledb.materialized_only = false);`); - await pool.query(` - SELECT add_continuous_aggregate_policy('checks_daily', - start_offset => INTERVAL '3 days', - end_offset => INTERVAL '1 day', - schedule_interval => INTERVAL '1 day', - if_not_exists => TRUE - ); - `); - await pool.query(`CALL refresh_continuous_aggregate('checks_daily', NULL, NULL);`); - - // Infrastructure Metrics (Hourly) - await pool.query(` - CREATE MATERIALIZED VIEW IF NOT EXISTS hardware_hourly - WITH (timescaledb.continuous) AS - SELECT - monitor_id, - time_bucket('1 hour', created_at) AS bucket, - AVG(cpu_usage_percent) AS avg_cpu, - MAX(cpu_usage_percent) AS max_cpu, - AVG(mem_usage_percent) AS avg_memory, - MAX(mem_usage_percent) AS max_memory, - COUNT(*) AS sample_count - FROM checks - WHERE monitor_type = 'hardware' - GROUP BY monitor_id, bucket - WITH NO DATA; - `); - await pool.query(`ALTER MATERIALIZED VIEW hardware_hourly SET (timescaledb.materialized_only = false);`); - await pool.query(` - SELECT add_continuous_aggregate_policy('hardware_hourly', - start_offset => INTERVAL '3 hours', - end_offset => INTERVAL '1 hour', - schedule_interval => INTERVAL '1 hour', - if_not_exists => TRUE - ); - `); - await pool.query(`CALL refresh_continuous_aggregate('hardware_hourly', NULL, NULL);`); - - // PageSpeed Scores (Daily) - await pool.query(` - CREATE MATERIALIZED VIEW IF NOT EXISTS pagespeed_daily - WITH (timescaledb.continuous) AS - SELECT - monitor_id, - time_bucket('1 day', created_at) AS bucket, - AVG(lighthouse_performance) AS avg_performance, - AVG(lighthouse_accessibility) AS avg_accessibility, - AVG(lighthouse_best_practices) AS avg_best_practices, - AVG(lighthouse_seo) AS avg_seo, - COUNT(*) AS sample_count - FROM checks - WHERE monitor_type = 'pagespeed' - GROUP BY monitor_id, bucket - WITH NO DATA; - `); - await pool.query(`ALTER MATERIALIZED VIEW pagespeed_daily SET (timescaledb.materialized_only = false);`); - await pool.query(` - SELECT add_continuous_aggregate_policy('pagespeed_daily', - start_offset => INTERVAL '3 days', - end_offset => INTERVAL '1 day', - schedule_interval => INTERVAL '1 day', - if_not_exists => TRUE - ); - `); - await pool.query(`CALL refresh_continuous_aggregate('pagespeed_daily', NULL, NULL);`); -}; - -export const dropContinuousAggregates = async (pool: Pool) => { - await pool.query(` - DROP MATERIALIZED VIEW IF EXISTS pagespeed_daily CASCADE; - DROP MATERIALIZED VIEW IF EXISTS hardware_hourly CASCADE; - DROP MATERIALIZED VIEW IF EXISTS checks_daily CASCADE; - DROP MATERIALIZED VIEW IF EXISTS checks_hourly CASCADE; - `); -}; diff --git a/server/src/db/migration/timescaledb/0021_create_retention_compression.ts b/server/src/db/migration/timescaledb/0021_create_retention_compression.ts deleted file mode 100644 index 95230e248..000000000 --- a/server/src/db/migration/timescaledb/0021_create_retention_compression.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Pool } from "pg"; - -export const createRetentionCompression = async (pool: Pool) => { - // -- Compression policies -- - - // Checks - await pool.query(` - ALTER TABLE checks SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'monitor_id', - timescaledb.compress_orderby = 'created_at DESC' - ); - SELECT add_compression_policy('checks', INTERVAL '2 days', if_not_exists => TRUE); - `); - - // Check child tables - await pool.query(` - ALTER TABLE check_disks SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'check_id', - timescaledb.compress_orderby = 'check_created_at DESC' - ); - SELECT add_compression_policy('check_disks', INTERVAL '2 days', if_not_exists => TRUE); - `); - - await pool.query(` - ALTER TABLE check_network_interfaces SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'check_id', - timescaledb.compress_orderby = 'check_created_at DESC' - ); - SELECT add_compression_policy('check_network_interfaces', INTERVAL '2 days', if_not_exists => TRUE); - `); - - await pool.query(` - ALTER TABLE check_errors SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'check_id', - timescaledb.compress_orderby = 'check_created_at DESC' - ); - SELECT add_compression_policy('check_errors', INTERVAL '2 days', if_not_exists => TRUE); - `); - - // Geo checks - await pool.query(` - ALTER TABLE geo_checks SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'monitor_id', - timescaledb.compress_orderby = 'created_at DESC' - ); - SELECT add_compression_policy('geo_checks', INTERVAL '2 days', if_not_exists => TRUE); - `); - - await pool.query(` - ALTER TABLE geo_check_results SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'geo_check_id', - timescaledb.compress_orderby = 'geo_check_created_at DESC' - ); - SELECT add_compression_policy('geo_check_results', INTERVAL '2 days', if_not_exists => TRUE); - `); - - // -- Retention policies (default 30 days) -- - - await pool.query(` - SELECT add_retention_policy('checks', INTERVAL '30 days', if_not_exists => TRUE); - SELECT add_retention_policy('check_disks', INTERVAL '30 days', if_not_exists => TRUE); - SELECT add_retention_policy('check_network_interfaces', INTERVAL '30 days', if_not_exists => TRUE); - SELECT add_retention_policy('check_errors', INTERVAL '30 days', if_not_exists => TRUE); - SELECT add_retention_policy('geo_checks', INTERVAL '30 days', if_not_exists => TRUE); - SELECT add_retention_policy('geo_check_results', INTERVAL '30 days', if_not_exists => TRUE); - `); - - // -- Dynamic retention helper function -- - - await pool.query(` - CREATE OR REPLACE FUNCTION update_check_retention(new_ttl_days INTEGER) - RETURNS VOID AS $$ - DECLARE - ttl_interval INTERVAL; - BEGIN - ttl_interval := (new_ttl_days || ' days')::INTERVAL; - - PERFORM remove_retention_policy('checks', if_exists => true); - PERFORM remove_retention_policy('check_disks', if_exists => true); - PERFORM remove_retention_policy('check_network_interfaces', if_exists => true); - PERFORM remove_retention_policy('check_errors', if_exists => true); - PERFORM remove_retention_policy('geo_checks', if_exists => true); - PERFORM remove_retention_policy('geo_check_results', if_exists => true); - - PERFORM add_retention_policy('checks', ttl_interval); - PERFORM add_retention_policy('check_disks', ttl_interval); - PERFORM add_retention_policy('check_network_interfaces', ttl_interval); - PERFORM add_retention_policy('check_errors', ttl_interval); - PERFORM add_retention_policy('geo_checks', ttl_interval); - PERFORM add_retention_policy('geo_check_results', ttl_interval); - END; - $$ LANGUAGE plpgsql; - `); -}; - -export const dropRetentionCompression = async (pool: Pool) => { - // Remove retention policies - await pool.query(` - SELECT remove_retention_policy('checks', if_exists => true); - SELECT remove_retention_policy('check_disks', if_exists => true); - SELECT remove_retention_policy('check_network_interfaces', if_exists => true); - SELECT remove_retention_policy('check_errors', if_exists => true); - SELECT remove_retention_policy('geo_checks', if_exists => true); - SELECT remove_retention_policy('geo_check_results', if_exists => true); - `); - - // Remove compression policies - await pool.query(` - SELECT remove_compression_policy('checks', if_exists => true); - SELECT remove_compression_policy('check_disks', if_exists => true); - SELECT remove_compression_policy('check_network_interfaces', if_exists => true); - SELECT remove_compression_policy('check_errors', if_exists => true); - SELECT remove_compression_policy('geo_checks', if_exists => true); - SELECT remove_compression_policy('geo_check_results', if_exists => true); - `); - - // Disable compression on tables - await pool.query(` - ALTER TABLE checks SET (timescaledb.compress = false); - ALTER TABLE check_disks SET (timescaledb.compress = false); - ALTER TABLE check_network_interfaces SET (timescaledb.compress = false); - ALTER TABLE check_errors SET (timescaledb.compress = false); - ALTER TABLE geo_checks SET (timescaledb.compress = false); - ALTER TABLE geo_check_results SET (timescaledb.compress = false); - `); - - // Drop dynamic retention function - await pool.query(`DROP FUNCTION IF EXISTS update_check_retention(INTEGER);`); -}; diff --git a/server/src/db/migration/timescaledb/index.ts b/server/src/db/migration/timescaledb/index.ts deleted file mode 100644 index 9a76d093a..000000000 --- a/server/src/db/migration/timescaledb/index.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { Pool } from "pg"; -import type { ILogger } from "@/utils/logger.js"; -import { createEnums, dropEnums } from "./0001_create_enums.js"; -import { createTeams, dropTeams } from "./0002_create_teams.js"; -import { createUsers, dropUsers } from "./0003_create_users.js"; -import { createMonitors, dropMonitors } from "./0004_create_monitors.js"; -import { createNotifications, dropNotifications } from "./0005_create_notifications.js"; -import { createMonitorNotifications, dropMonitorNotifications } from "./0006_create_monitor_notifications.js"; -import { createIncidents, dropIncidents } from "./0007_create_incidents.js"; -import { createMonitorStats, dropMonitorStats } from "./0008_create_monitor_stats.js"; -import { createChecks, dropChecks } from "./0009_create_checks.js"; -import { createCheckDisks, dropCheckDisks } from "./0010_create_check_disks.js"; -import { createCheckNetworkInterfaces, dropCheckNetworkInterfaces } from "./0011_create_check_network_interfaces.js"; -import { createCheckErrors, dropCheckErrors } from "./0012_create_check_errors.js"; -import { createGeoChecks, dropGeoChecks } from "./0013_create_geo_checks.js"; -import { createGeoCheckResults, dropGeoCheckResults } from "./0014_create_geo_check_results.js"; -import { createInvites, dropInvites } from "./0015_create_invites.js"; -import { createRecoveryTokens, dropRecoveryTokens } from "./0016_create_recovery_tokens.js"; -import { createMaintenanceWindows, dropMaintenanceWindows } from "./0017_create_maintenance_windows.js"; -import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.js"; -import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js"; -import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js"; -import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js"; -import { addStrategyToMonitors, dropStrategyFromMonitors } from "./0022_add_strategy_to_monitors.js"; - -const SERVICE_NAME = "TimescaleDB Migrations"; - -type MigrationEntry = { - name: string; - up: (pool: Pool) => Promise; - down: (pool: Pool) => Promise; -}; - -const migrations: MigrationEntry[] = [ - { name: "0001_create_enums", up: createEnums, down: dropEnums }, - { name: "0002_create_teams", up: createTeams, down: dropTeams }, - { name: "0003_create_users", up: createUsers, down: dropUsers }, - { name: "0004_create_monitors", up: createMonitors, down: dropMonitors }, - { name: "0005_create_notifications", up: createNotifications, down: dropNotifications }, - { name: "0006_create_monitor_notifications", up: createMonitorNotifications, down: dropMonitorNotifications }, - { name: "0007_create_incidents", up: createIncidents, down: dropIncidents }, - { name: "0008_create_monitor_stats", up: createMonitorStats, down: dropMonitorStats }, - { name: "0009_create_checks", up: createChecks, down: dropChecks }, - { name: "0010_create_check_disks", up: createCheckDisks, down: dropCheckDisks }, - { name: "0011_create_check_network_interfaces", up: createCheckNetworkInterfaces, down: dropCheckNetworkInterfaces }, - { name: "0012_create_check_errors", up: createCheckErrors, down: dropCheckErrors }, - { name: "0013_create_geo_checks", up: createGeoChecks, down: dropGeoChecks }, - { name: "0014_create_geo_check_results", up: createGeoCheckResults, down: dropGeoCheckResults }, - { name: "0015_create_invites", up: createInvites, down: dropInvites }, - { name: "0016_create_recovery_tokens", up: createRecoveryTokens, down: dropRecoveryTokens }, - { name: "0017_create_maintenance_windows", up: createMaintenanceWindows, down: dropMaintenanceWindows }, - { name: "0018_create_status_pages", up: createStatusPages, down: dropStatusPages }, - { name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings }, - { name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates }, - { name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression }, - { name: "0022_add_strategy_to_monitors", up: addStrategyToMonitors, down: dropStrategyFromMonitors }, -]; - -const ensureMigrationsTable = async (pool: Pool) => { - await pool.query(` - DO $$ BEGIN - CREATE TYPE migration_status AS ENUM ('completed', 'failed'); - EXCEPTION - WHEN duplicate_object THEN NULL; - END $$; - `); - await pool.query(` - CREATE TABLE IF NOT EXISTS migrations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL UNIQUE, - status migration_status NOT NULL, - completed_at TIMESTAMPTZ, - error TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - `); -}; - -const runTimescaleDBMigrations = async (pool: Pool, logger?: ILogger) => { - try { - logger?.info({ message: "Running migrations", service: SERVICE_NAME }); - - await ensureMigrationsTable(pool); - - for (const migration of migrations) { - const result = await pool.query("SELECT 1 FROM migrations WHERE name = $1 AND status = 'completed'", [migration.name]); - if (result.rowCount !== null && result.rowCount > 0) { - logger?.info({ message: `Skipping ${migration.name}`, service: SERVICE_NAME }); - continue; - } - - try { - await migration.up(pool); - await pool.query( - `INSERT INTO migrations (name, status, completed_at) - VALUES ($1, 'completed', NOW()) - ON CONFLICT (name) DO UPDATE SET status = 'completed', completed_at = NOW(), error = NULL, updated_at = NOW()`, - [migration.name] - ); - logger?.info({ message: `Completed ${migration.name}`, service: SERVICE_NAME }); - } catch (error) { - const err = error as Error; - await pool.query( - `INSERT INTO migrations (name, status, error) - VALUES ($1, 'failed', $2) - ON CONFLICT (name) DO UPDATE SET status = 'failed', error = $2, updated_at = NOW()`, - [migration.name, err?.message] - ); - throw error; - } - } - - logger?.info({ message: "Migrations completed", service: SERVICE_NAME }); - } catch (error) { - const err = error as Error; - logger?.error({ message: "Migration failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack }); - throw error; - } -}; - -const rollbackTimescaleDBMigration = async (pool: Pool, logger?: ILogger) => { - try { - await ensureMigrationsTable(pool); - - // Find the last completed migration - const result = await pool.query("SELECT name FROM migrations WHERE status = 'completed' ORDER BY completed_at DESC LIMIT 1"); - if (result.rowCount === 0) { - logger?.info({ message: "No migrations to roll back", service: SERVICE_NAME }); - return; - } - - const lastMigrationName = result.rows[0].name; - const migration = migrations.find((m) => m.name === lastMigrationName); - if (!migration) { - throw new Error(`Migration "${lastMigrationName}" not found in migration list`); - } - - logger?.info({ message: `Rolling back ${migration.name}`, service: SERVICE_NAME }); - await migration.down(pool); - await pool.query("DELETE FROM migrations WHERE name = $1", [migration.name]); - logger?.info({ message: `Rolled back ${migration.name}`, service: SERVICE_NAME }); - } catch (error) { - const err = error as Error; - logger?.error({ message: "Rollback failed", service: SERVICE_NAME, details: { error: err?.message }, stack: err?.stack }); - throw error; - } -}; - -export { runTimescaleDBMigrations, rollbackTimescaleDBMigration }; 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/Check.ts b/server/src/db/models/Check.ts index 541abb4d9..964936af9 100644 --- a/server/src/db/models/Check.ts +++ b/server/src/db/models/Check.ts @@ -271,6 +271,7 @@ const CheckSchema = new Schema( { timestamps: true, strict: false, + suppressReservedKeysWarning: true, timeseries: { timeField: "createdAt", metaField: "metadata", diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/db/models/MaintenanceWindow.ts index 311cf97bf..703c144c8 100644 --- a/server/src/db/models/MaintenanceWindow.ts +++ b/server/src/db/models/MaintenanceWindow.ts @@ -1,5 +1,5 @@ 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; @@ -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 40866d191..a92e9331c 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, PageSpeedStrategies } from "@/types/monitor.js"; +import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/types/monitor.js"; import type { CheckAudits, CheckCaptureInfo, @@ -355,6 +355,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/MonitorStats.ts b/server/src/db/models/MonitorStats.ts index 5780de476..4ef2dab61 100755 --- a/server/src/db/models/MonitorStats.ts +++ b/server/src/db/models/MonitorStats.ts @@ -16,7 +16,7 @@ const MonitorStatsSchema = new Schema( monitorId: { type: Schema.Types.ObjectId, ref: "Monitor", - index: true, + unique: true, required: true, }, avgResponseTime: { diff --git a/server/src/db/models/Notification.ts b/server/src/db/models/Notification.ts index 5f6d234dd..2f7effa17 100755 --- a/server/src/db/models/Notification.ts +++ b/server/src/db/models/Notification.ts @@ -25,7 +25,7 @@ const NotificationSchema = new Schema( }, type: { type: String, - enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as NotificationChannel[], + enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[], required: true, }, notificationName: { @@ -37,6 +37,8 @@ const NotificationSchema = new Schema( homeserverUrl: { type: String }, roomId: { type: String }, accessToken: { type: String }, + accountSid: { type: String }, + twilioPhoneNumber: { type: String }, }, { timestamps: true, diff --git a/server/src/db/models/StatusPage.ts b/server/src/db/models/StatusPage.ts index e9b5b59aa..0b6451492 100644 --- a/server/src/db/models/StatusPage.ts +++ b/server/src/db/models/StatusPage.ts @@ -1,6 +1,12 @@ import { Schema, model, type Types } from "mongoose"; import type { StatusPage, StatusPageLogoDocument } from "@/types/statusPage.js"; -import { StatusPageTypes } from "@/types/statusPage.js"; +import { + DEFAULT_STATUS_PAGE_THEME, + DEFAULT_STATUS_PAGE_THEME_MODE, + StatusPageThemeModes, + StatusPageThemes, + StatusPageTypes, +} from "@/types/statusPage.js"; type StatusPageDocumentBase = Omit< StatusPage, @@ -108,6 +114,16 @@ const StatusPageSchema = new Schema( type: String, default: "", }, + theme: { + type: String, + enum: StatusPageThemes, + default: DEFAULT_STATUS_PAGE_THEME, + }, + themeMode: { + type: String, + enum: StatusPageThemeModes, + default: DEFAULT_STATUS_PAGE_THEME_MODE, + }, }, { timestamps: true } ); diff --git a/server/src/repositories/checks/TimescaleChecksRepository.ts b/server/src/repositories/checks/TimescaleChecksRepository.ts deleted file mode 100644 index 466be0f71..000000000 --- a/server/src/repositories/checks/TimescaleChecksRepository.ts +++ /dev/null @@ -1,1012 +0,0 @@ -import type { Pool } from "pg"; -import type { IChecksRepository } from "@/repositories/checks/IChecksRepository.js"; -import type { - Check, - CheckAudits, - CheckCaptureInfo, - CheckCpuInfo, - CheckDiskInfo, - CheckErrorInfo, - CheckHostInfo, - CheckMemoryInfo, - CheckNetworkInterfaceInfo, - ChecksQueryResult, - ChecksSummary, - MonitorType, - PageSpeedChecksResult, - HardwareChecksResult, - UptimeChecksResult, - GotTimings, -} from "@/types/index.js"; -import type { LatestChecksMap } from "@/repositories/checks/MongoChecksRepistory.js"; -import { getDateForRange } from "@/utils/dataUtils.js"; - -// Map MongoDB dateString formats to TimescaleDB time_bucket intervals -const dateStringToBucket = (dateString: string): string => { - if (dateString.includes("%M")) return "1 minute"; - if (dateString.includes("%H")) return "1 hour"; - return "1 day"; -}; - -export class TimescaleChecksRepository implements IChecksRepository { - constructor(private pool: Pool) {} - - // Returns correct CA table name for the given bucket interval and query type - // Or returns null if no CA is available (fall back to raw checks table). - private getCaTable(bucket: string, type: "uptime" | "hardware" | "pagespeed"): string | null { - if (type === "uptime" && bucket === "1 hour") return "checks_hourly"; - if (type === "uptime" && bucket === "1 day") return "checks_daily"; - if (type === "hardware" && bucket === "1 hour") return "hardware_hourly"; - if (type === "pagespeed" && bucket === "1 day") return "pagespeed_daily"; - return null; - } - - create = async (check: Check): Promise => { - const row = await this.insertCheck(check); - if (!row) { - throw new Error("Failed to create check"); - } - - if (check.disk?.length) { - await this.insertDisks(row.id, row.created_at, check.disk); - } - if (check.net?.length) { - await this.insertNetworkInterfaces(row.id, row.created_at, check.net); - } - if (check.errors?.length) { - await this.insertErrors(row.id, row.created_at, check.errors); - } - - return this.toEntity(row, check.disk, check.errors, check.net); - }; - - createChecks = async (checks: Check[]): Promise => { - const results: Check[] = []; - for (const check of checks) { - results.push(await this.create(check)); - } - return results; - }; - - findByMonitorId = async ( - monitorId: string, - sortOrder: string, - dateRange: string, - filter: string | undefined, - page: number, - rowsPerPage: number, - status: boolean | undefined - ): Promise => { - const { conditions, values, paramIndex } = this.buildCheckFilters({ monitorId, dateRange, filter, status }, "monitor_id"); - - const direction = sortOrder === "asc" ? "ASC" : "DESC"; - const offset = page && rowsPerPage ? page * rowsPerPage : 0; - - const [countResult, checksResult] = await Promise.all([ - this.pool.query(`SELECT COUNT(*)::int AS count FROM checks WHERE ${conditions.join(" AND ")}`, values), - this.pool.query( - `SELECT * FROM checks WHERE ${conditions.join(" AND ")} ORDER BY created_at ${direction} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, - [...values, rowsPerPage, offset] - ), - ]); - - return { - checksCount: countResult.rows[0].count, - checks: await this.populateChildData(checksResult.rows), - }; - }; - - findByTeamId = async ( - sortOrder: string, - dateRange: string, - filter: string | undefined, - page: number, - rowsPerPage: number, - teamId: string - ): Promise => { - const { conditions, values, paramIndex } = this.buildCheckFilters({ teamId, dateRange, filter }, "team_id"); - - const direction = sortOrder === "asc" ? "ASC" : "DESC"; - const offset = page && rowsPerPage ? page * rowsPerPage : 0; - - const [countResult, checksResult] = await Promise.all([ - this.pool.query(`SELECT COUNT(*)::int AS count FROM checks WHERE ${conditions.join(" AND ")}`, values), - this.pool.query( - `SELECT * FROM checks WHERE ${conditions.join(" AND ")} ORDER BY created_at ${direction} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, - [...values, rowsPerPage, offset] - ), - ]); - - return { - checksCount: countResult.rows[0].count, - checks: await this.populateChildData(checksResult.rows), - }; - }; - - findLatestByMonitorIds = async (monitorIds: string[], options?: { limitPerMonitor?: number }): Promise => { - if (!monitorIds.length) { - return {}; - } - const limitPerMonitor = options?.limitPerMonitor ?? 25; - const dateFilter = new Date(Date.now() - 24 * 60 * 60 * 1000); - - const allResult = await this.pool.query( - `SELECT * FROM ( - SELECT *, ROW_NUMBER() OVER (PARTITION BY monitor_id ORDER BY created_at DESC) AS rn - FROM checks - WHERE monitor_id = ANY($1) AND created_at >= $2 - ) sub - WHERE rn <= $3 - ORDER BY monitor_id, created_at DESC`, - [monitorIds, dateFilter, limitPerMonitor] - ); - - const checks = await this.populateChildData(allResult.rows); - - const mapped: LatestChecksMap = {}; - for (let i = 0; i < allResult.rows.length; i++) { - const mid = allResult.rows[i].monitor_id; - if (!mapped[mid]) { - mapped[mid] = []; - } - const check = checks[i]; - if (check !== undefined) { - mapped[mid].push(check); - } - } - // Ensure all requested IDs have an entry - for (const id of monitorIds) { - if (!mapped[id]) { - mapped[id] = []; - } - } - return mapped; - }; - - findByDateRangeAndMonitorId = async ( - monitorId: string, - startDate: Date, - endDate: Date, - dateString: string, - options?: { type?: MonitorType } - ): Promise => { - if (options?.type === "hardware") { - return this.findHardwareDateRangeChecks(monitorId, startDate, endDate, dateString); - } - if (options?.type === "pagespeed") { - return this.findPageSpeedDateRangeChecks(monitorId, startDate, endDate, dateString); - } - return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorId, startDate, endDate, dateString); - }; - - findSummaryByTeamId = async (teamId: string, dateRange: string): Promise => { - const rangeDate = getDateForRange(dateRange); - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - - if (rangeDate) { - conditions.push("created_at >= $2"); - values.push(rangeDate); - } - - const result = await this.pool.query( - `SELECT - COUNT(*)::int AS "totalChecks", - COUNT(*) FILTER (WHERE status = FALSE)::int AS "downChecks" - FROM checks WHERE ${conditions.join(" AND ")}`, - values - ); - - return result.rows[0] ?? { totalChecks: 0, downChecks: 0 }; - }; - - deleteByMonitorId = async (monitorId: string): Promise => { - const result = await this.pool.query(`DELETE FROM checks WHERE monitor_id = $1`, [monitorId]); - return result.rowCount ?? 0; - }; - - deleteByTeamId = async (teamId: string): Promise => { - const result = await this.pool.query(`DELETE FROM checks WHERE team_id = $1`, [teamId]); - return result.rowCount ?? 0; - }; - - deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { - if (!monitorIds.length) { - const result = await this.pool.query(`DELETE FROM checks`); - return result.rowCount ?? 0; - } - const result = await this.pool.query(`DELETE FROM checks WHERE monitor_id != ALL($1)`, [monitorIds]); - return result.rowCount ?? 0; - }; - - deleteOlderThan = async (date: Date): Promise => { - const result = await this.pool.query(`DELETE FROM checks WHERE created_at < $1`, [date]); - return result.rowCount ?? 0; - }; - - private populateChildData = async (checkRows: Record[]): Promise => { - if (!checkRows.length) return []; - - const checkIds = checkRows.map((r) => r.id as string); - - const [disksResult, netsResult, errsResult] = await Promise.all([ - this.pool.query( - `SELECT check_id, device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, - total_inodes, free_inodes, used_inodes, inodes_usage_percent, - read_bytes, write_bytes, read_time, write_time - FROM check_disks WHERE check_id = ANY($1)`, - [checkIds] - ), - this.pool.query( - `SELECT check_id, name, bytes_sent, bytes_recv, packets_sent, packets_recv, - err_in, err_out, drop_in, drop_out, fifo_in, fifo_out - FROM check_network_interfaces WHERE check_id = ANY($1)`, - [checkIds] - ), - this.pool.query( - `SELECT check_id, metrics, error - FROM check_errors WHERE check_id = ANY($1)`, - [checkIds] - ), - ]); - - const diskMap = new Map(); - for (const d of disksResult.rows) { - const key = d.check_id as string; - if (!diskMap.has(key)) diskMap.set(key, []); - diskMap.get(key)!.push({ - device: d.device, - mountpoint: d.mountpoint, - total_bytes: Number(d.total_bytes), - free_bytes: Number(d.free_bytes), - used_bytes: Number(d.used_bytes), - usage_percent: d.usage_percent, - total_inodes: Number(d.total_inodes), - free_inodes: Number(d.free_inodes), - used_inodes: Number(d.used_inodes), - inodes_usage_percent: d.inodes_usage_percent, - read_bytes: Number(d.read_bytes), - write_bytes: Number(d.write_bytes), - read_time: Number(d.read_time), - write_time: Number(d.write_time), - }); - } - - const netMap = new Map(); - for (const n of netsResult.rows) { - const key = n.check_id as string; - if (!netMap.has(key)) netMap.set(key, []); - netMap.get(key)!.push({ - name: n.name, - bytes_sent: Number(n.bytes_sent), - bytes_recv: Number(n.bytes_recv), - packets_sent: Number(n.packets_sent), - packets_recv: Number(n.packets_recv), - err_in: Number(n.err_in), - err_out: Number(n.err_out), - drop_in: Number(n.drop_in), - drop_out: Number(n.drop_out), - fifo_in: Number(n.fifo_in), - fifo_out: Number(n.fifo_out), - }); - } - - const errMap = new Map(); - for (const e of errsResult.rows) { - const key = e.check_id as string; - if (!errMap.has(key)) errMap.set(key, []); - errMap.get(key)!.push({ metric: e.metrics ?? [], err: e.error ?? "" }); - } - - return checkRows.map((row) => { - const id = row.id as string; - return this.toEntity(row, diskMap.get(id) ?? [], errMap.get(id) ?? [], netMap.get(id) ?? []); - }); - }; - - private insertCheck = async (check: Check) => { - const result = await this.pool.query( - `INSERT INTO checks ( - monitor_id, team_id, monitor_type, status, response_time, status_code, message, - timing_start, timing_socket, timing_lookup, timing_connect, timing_secure_connect, - timing_upload, timing_response, timing_end, - phase_wait, phase_dns, phase_tcp, phase_tls, phase_request, phase_first_byte, phase_download, phase_total, - cpu_physical_core, cpu_logical_core, cpu_frequency, cpu_current_frequency, cpu_temperature, - cpu_free_percent, cpu_usage_percent, - mem_total_bytes, mem_available_bytes, mem_used_bytes, mem_usage_percent, - host_os, host_platform, host_kernel_version, host_pretty_name, - capture_version, capture_mode, - lighthouse_performance, lighthouse_accessibility, lighthouse_best_practices, lighthouse_seo, - audit_cls_score, audit_cls_value, audit_cls_display, - audit_si_score, audit_si_value, audit_si_display, - audit_fcp_score, audit_fcp_value, audit_fcp_display, - audit_lcp_score, audit_lcp_value, audit_lcp_display, - audit_tbt_score, audit_tbt_value, audit_tbt_display - ) VALUES ( - $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23, - $24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41,$42,$43,$44, - $45,$46,$47,$48,$49,$50,$51,$52,$53,$54,$55,$56,$57,$58,$59 - ) RETURNING *`, - [ - check.metadata.monitorId, - check.metadata.teamId, - check.metadata.type, - check.status, - check.responseTime, - check.statusCode, - check.message, - check.timings?.start ?? null, - check.timings?.socket ?? null, - check.timings?.lookup ?? null, - check.timings?.connect ?? null, - check.timings?.secureConnect ?? null, - check.timings?.upload ?? null, - check.timings?.response ?? null, - check.timings?.end ?? null, - check.timings?.phases?.wait ?? null, - check.timings?.phases?.dns ?? null, - check.timings?.phases?.tcp ?? null, - check.timings?.phases?.tls ?? null, - check.timings?.phases?.request ?? null, - check.timings?.phases?.firstByte ?? null, - check.timings?.phases?.download ?? null, - check.timings?.phases?.total ?? null, - check.cpu?.physical_core ?? null, - check.cpu?.logical_core ?? null, - check.cpu?.frequency ?? null, - check.cpu?.current_frequency ?? null, - check.cpu?.temperature ?? null, - check.cpu?.free_percent ?? null, - check.cpu?.usage_percent ?? null, - check.memory?.total_bytes ?? null, - check.memory?.available_bytes ?? null, - check.memory?.used_bytes ?? null, - check.memory?.usage_percent ?? null, - check.host?.os ?? null, - check.host?.platform ?? null, - check.host?.kernel_version ?? null, - check.host?.pretty_name ?? null, - check.capture?.version ?? null, - check.capture?.mode ?? null, - check.performance ?? null, - check.accessibility ?? null, - check.bestPractices ?? null, - check.seo ?? null, - check.audits?.cls?.score ?? null, - check.audits?.cls?.numericValue ?? null, - check.audits?.cls?.displayValue ?? null, - check.audits?.si?.score ?? null, - check.audits?.si?.numericValue ?? null, - check.audits?.si?.displayValue ?? null, - check.audits?.fcp?.score ?? null, - check.audits?.fcp?.numericValue ?? null, - check.audits?.fcp?.displayValue ?? null, - check.audits?.lcp?.score ?? null, - check.audits?.lcp?.numericValue ?? null, - check.audits?.lcp?.displayValue ?? null, - check.audits?.tbt?.score ?? null, - check.audits?.tbt?.numericValue ?? null, - check.audits?.tbt?.displayValue ?? null, - ] - ); - return result.rows[0]; - }; - - private insertDisks = async (checkId: string, checkCreatedAt: Date, disks: CheckDiskInfo[]) => { - for (const disk of disks) { - await this.pool.query( - `INSERT INTO check_disks (check_id, check_created_at, device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, - total_inodes, free_inodes, used_inodes, inodes_usage_percent, read_bytes, write_bytes, read_time, write_time) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, - [ - checkId, - checkCreatedAt, - disk.device, - disk.mountpoint, - disk.total_bytes, - disk.free_bytes, - disk.used_bytes, - disk.usage_percent, - disk.total_inodes, - disk.free_inodes, - disk.used_inodes, - disk.inodes_usage_percent, - disk.read_bytes, - disk.write_bytes, - disk.read_time, - disk.write_time, - ] - ); - } - }; - - private insertNetworkInterfaces = async (checkId: string, checkCreatedAt: Date, nets: CheckNetworkInterfaceInfo[]) => { - for (const net of nets) { - await this.pool.query( - `INSERT INTO check_network_interfaces (check_id, check_created_at, name, bytes_sent, bytes_recv, packets_sent, packets_recv, - err_in, err_out, drop_in, drop_out, fifo_in, fifo_out) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, - [ - checkId, - checkCreatedAt, - net.name, - net.bytes_sent, - net.bytes_recv, - net.packets_sent, - net.packets_recv, - net.err_in, - net.err_out, - net.drop_in, - net.drop_out, - net.fifo_in, - net.fifo_out, - ] - ); - } - }; - - private insertErrors = async (checkId: string, checkCreatedAt: Date, errors: CheckErrorInfo[]) => { - for (const err of errors) { - await this.pool.query( - `INSERT INTO check_errors (check_id, check_created_at, metrics, error) - VALUES ($1,$2,$3,$4)`, - [checkId, checkCreatedAt, err.metric, err.err] - ); - } - }; - - private buildCheckFilters = ( - opts: { monitorId?: string; teamId?: string; dateRange?: string; filter?: string; status?: boolean }, - idColumn: string - ) => { - const conditions: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - if (opts.monitorId) { - conditions.push(`${idColumn} = $${paramIndex++}`); - values.push(opts.monitorId); - } else if (opts.teamId) { - conditions.push(`${idColumn} = $${paramIndex++}`); - values.push(opts.teamId); - } - - if (opts.dateRange) { - const rangeDate = getDateForRange(opts.dateRange); - if (rangeDate) { - conditions.push(`created_at >= $${paramIndex++}`); - values.push(rangeDate); - } - } - - // Filter overwrites status (matching Mongo behavior where filter sets matchStage.status) - let statusApplied = false; - if (opts.filter !== undefined) { - switch (opts.filter) { - case "all": - break; - case "up": - conditions.push(`status = TRUE`); - statusApplied = true; - break; - case "down": - conditions.push(`status = FALSE`); - statusApplied = true; - break; - case "resolve": - conditions.push(`status = FALSE`); - conditions.push(`status_code = 5000`); - statusApplied = true; - break; - default: - break; - } - } - - if (!statusApplied && opts.status !== undefined) { - conditions.push(`status = $${paramIndex++}`); - values.push(opts.status); - } - - return { conditions, values, paramIndex }; - }; - - private findUptimeDateRangeChecks = async ( - monitorType: Exclude, - monitorId: string, - startDate: Date, - endDate: Date, - dateString: string - ): Promise => { - const bucket = dateStringToBucket(dateString); - const caTable = this.getCaTable(bucket, "uptime"); - - if (caTable) { - return this.findUptimeFromCa(monitorType, monitorId, startDate, endDate, caTable); - } - return this.findUptimeFromRaw(monitorType, monitorId, startDate, endDate, bucket); - }; - - private findUptimeFromCa = async ( - monitorType: Exclude, - monitorId: string, - startDate: Date, - endDate: Date, - caTable: string - ): Promise => { - const result = await this.pool.query( - `SELECT bucket AS bucket_date, total_checks::int, up_checks::int, down_checks::int, - avg_response_time, avg_up_response_time, avg_down_response_time - FROM ${caTable} - WHERE monitor_id = $1 AND bucket >= $2 AND bucket <= $3 - ORDER BY bucket`, - [monitorId, startDate, endDate] - ); - - let totalChecks = 0; - let totalUp = 0; - let weightedResponseTime = 0; - - const groupedChecks = result.rows.map((row) => { - const count = Number(row.total_checks); - const up = Number(row.up_checks); - const avg = Number(row.avg_response_time ?? 0); - totalChecks += count; - totalUp += up; - weightedResponseTime += avg * count; - return { - bucketDate: (row.bucket_date as Date).toISOString(), - avgResponseTime: avg, - totalChecks: count, - }; - }); - - const groupedUpChecks = result.rows - .filter((row) => Number(row.up_checks) > 0) - .map((row) => ({ - bucketDate: (row.bucket_date as Date).toISOString(), - avgResponseTime: Number(row.avg_up_response_time ?? 0), - totalChecks: Number(row.up_checks), - })); - - const groupedDownChecks = result.rows - .filter((row) => Number(row.down_checks) > 0) - .map((row) => ({ - bucketDate: (row.bucket_date as Date).toISOString(), - avgResponseTime: Number(row.avg_down_response_time ?? 0), - totalChecks: Number(row.down_checks), - })); - - return { - monitorType, - groupedChecks, - groupedUpChecks, - groupedDownChecks, - uptimePercentage: totalChecks > 0 ? totalUp / totalChecks : 0, - avgResponseTime: totalChecks > 0 ? weightedResponseTime / totalChecks : 0, - }; - }; - - private findUptimeFromRaw = async ( - monitorType: Exclude, - monitorId: string, - startDate: Date, - endDate: Date, - bucket: string - ): Promise => { - const [uptimeResult, groupedResult, upResult, downResult] = await Promise.all([ - this.pool.query( - `SELECT - COUNT(*) FILTER (WHERE status = TRUE) AS up_checks, - COUNT(*) AS total_checks, - AVG(response_time) AS avg_response_time - FROM checks WHERE monitor_id = $1 AND created_at >= $2 AND created_at <= $3`, - [monitorId, startDate, endDate] - ), - this.pool.query( - `SELECT - time_bucket($1::interval, created_at) AS bucket_date, - AVG(response_time) AS "avgResponseTime", - COUNT(*)::int AS "totalChecks" - FROM checks WHERE monitor_id = $2 AND created_at >= $3 AND created_at <= $4 - GROUP BY bucket_date ORDER BY bucket_date`, - [bucket, monitorId, startDate, endDate] - ), - this.pool.query( - `SELECT - time_bucket($1::interval, created_at) AS bucket_date, - AVG(response_time) AS "avgResponseTime", - COUNT(*)::int AS "totalChecks" - FROM checks WHERE monitor_id = $2 AND created_at >= $3 AND created_at <= $4 AND status = TRUE - GROUP BY bucket_date ORDER BY bucket_date`, - [bucket, monitorId, startDate, endDate] - ), - this.pool.query( - `SELECT - time_bucket($1::interval, created_at) AS bucket_date, - AVG(response_time) AS "avgResponseTime", - COUNT(*)::int AS "totalChecks" - FROM checks WHERE monitor_id = $2 AND created_at >= $3 AND created_at <= $4 AND status = FALSE - GROUP BY bucket_date ORDER BY bucket_date`, - [bucket, monitorId, startDate, endDate] - ), - ]); - - const stats = uptimeResult.rows[0]; - const totalChecks = Number(stats?.total_checks ?? 0); - const upChecks = Number(stats?.up_checks ?? 0); - - const formatGrouped = (rows: Array>) => - rows.map((row) => ({ - bucketDate: (row.bucket_date as Date).toISOString(), - avgResponseTime: Number(row.avgResponseTime ?? 0), - totalChecks: Number(row.totalChecks ?? 0), - })); - - return { - monitorType, - groupedChecks: formatGrouped(groupedResult.rows), - groupedUpChecks: formatGrouped(upResult.rows), - groupedDownChecks: formatGrouped(downResult.rows), - uptimePercentage: totalChecks > 0 ? upChecks / totalChecks : 0, - avgResponseTime: Number(stats?.avg_response_time ?? 0), - }; - }; - - private findHardwareDateRangeChecks = async ( - monitorId: string, - startDate: Date, - endDate: Date, - dateString: string - ): Promise => { - const bucket = dateStringToBucket(dateString); - const caTable = this.getCaTable(bucket, "hardware"); - - // Build the metrics query: use hardware_hourly CA for cpu/mem when available, - // but temperature always requires the raw table - const metricsQuery = caTable - ? this.pool.query( - `SELECT bucket AS bucket_date, avg_cpu, avg_memory - FROM ${caTable} - WHERE monitor_id = $1 AND bucket >= $2 AND bucket <= $3 - ORDER BY bucket`, - [monitorId, startDate, endDate] - ) - : this.pool.query( - `SELECT - time_bucket($1::interval, created_at) AS bucket_date, - AVG(cpu_usage_percent) AS avg_cpu, - AVG(mem_usage_percent) AS avg_memory - FROM checks - WHERE monitor_id = $2 AND monitor_type = 'hardware' AND created_at >= $3 AND created_at <= $4 - GROUP BY bucket_date ORDER BY bucket_date`, - [bucket, monitorId, startDate, endDate] - ); - - // Temperature query always hits raw table (not in any CA) - const tempQuery = this.pool.query( - `WITH temp_avg AS ( - SELECT time_bucket($1::interval, c.created_at) AS bucket_date, idx, AVG(val) AS avg_val - FROM checks c, unnest(c.cpu_temperature) WITH ORDINALITY AS t(val, idx) - WHERE c.monitor_id = $2 AND c.monitor_type = 'hardware' AND c.created_at >= $3 AND c.created_at <= $4 - GROUP BY bucket_date, idx - ) - SELECT bucket_date, array_agg(avg_val ORDER BY idx) AS avg_temperature - FROM temp_avg - GROUP BY bucket_date - ORDER BY bucket_date`, - [bucket, monitorId, startDate, endDate] - ); - - const diskQuery = this.pool.query( - `SELECT - time_bucket($1::interval, d.check_created_at) AS bucket_date, - d.device AS name, - AVG(d.read_bytes) AS "readSpeed", - AVG(d.write_bytes) AS "writeSpeed", - AVG(d.total_bytes) AS "totalBytes", - AVG(d.free_bytes) AS "freeBytes", - AVG(d.usage_percent) AS "usagePercent" - FROM check_disks d - WHERE d.check_id IN ( - SELECT id FROM checks - WHERE monitor_id = $2 AND monitor_type = 'hardware' AND created_at >= $3 AND created_at <= $4 - ) - GROUP BY bucket_date, d.device - ORDER BY bucket_date, d.device`, - [bucket, monitorId, startDate, endDate] - ); - - // Batched network query across all buckets (eliminates N+1) - const netQuery = this.pool.query( - `WITH bounded AS ( - SELECT - time_bucket($1::interval, c.created_at) AS bucket_date, - n.name, - FIRST_VALUE(n.bytes_sent) OVER w AS first_bytes_sent, - LAST_VALUE(n.bytes_sent) OVER w AS last_bytes_sent, - FIRST_VALUE(n.bytes_recv) OVER w AS first_bytes_recv, - LAST_VALUE(n.bytes_recv) OVER w AS last_bytes_recv, - FIRST_VALUE(n.packets_sent) OVER w AS first_packets_sent, - LAST_VALUE(n.packets_sent) OVER w AS last_packets_sent, - FIRST_VALUE(n.packets_recv) OVER w AS first_packets_recv, - LAST_VALUE(n.packets_recv) OVER w AS last_packets_recv, - FIRST_VALUE(n.err_in) OVER w AS first_err_in, - LAST_VALUE(n.err_in) OVER w AS last_err_in, - FIRST_VALUE(n.err_out) OVER w AS first_err_out, - LAST_VALUE(n.err_out) OVER w AS last_err_out, - FIRST_VALUE(n.drop_in) OVER w AS first_drop_in, - LAST_VALUE(n.drop_in) OVER w AS last_drop_in, - FIRST_VALUE(n.drop_out) OVER w AS first_drop_out, - LAST_VALUE(n.drop_out) OVER w AS last_drop_out, - FIRST_VALUE(n.fifo_in) OVER w AS first_fifo_in, - LAST_VALUE(n.fifo_in) OVER w AS last_fifo_in, - FIRST_VALUE(n.fifo_out) OVER w AS first_fifo_out, - LAST_VALUE(n.fifo_out) OVER w AS last_fifo_out, - FIRST_VALUE(c.created_at) OVER w AS first_time, - LAST_VALUE(c.created_at) OVER w AS last_time - FROM check_network_interfaces n - JOIN checks c ON c.id = n.check_id - WHERE c.monitor_id = $2 AND c.monitor_type = 'hardware' - AND c.created_at >= $3 AND c.created_at <= $4 - WINDOW w AS (PARTITION BY time_bucket($1::interval, c.created_at), n.name - ORDER BY c.created_at ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) - ) - SELECT DISTINCT ON (bucket_date, name) bucket_date, name, - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_bytes_sent - first_bytes_sent) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "bytesSentPerSecond", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_bytes_recv - first_bytes_recv) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaBytesRecv", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_packets_sent - first_packets_sent) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaPacketsSent", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_packets_recv - first_packets_recv) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaPacketsRecv", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_err_in - first_err_in) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaErrIn", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_err_out - first_err_out) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaErrOut", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_drop_in - first_drop_in) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaDropIn", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_drop_out - first_drop_out) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaDropOut", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_fifo_in - first_fifo_in) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaFifoIn", - CASE WHEN EXTRACT(EPOCH FROM (last_time - first_time)) > 0 - THEN (last_fifo_out - first_fifo_out) / EXTRACT(EPOCH FROM (last_time - first_time)) - ELSE 0 END AS "deltaFifoOut" - FROM bounded - ORDER BY bucket_date, name`, - [bucket, monitorId, startDate, endDate] - ); - - const [totalResult, upResult, metricsResult, tempResult, diskResult, netResult] = await Promise.all([ - this.pool.query( - `SELECT COUNT(*)::int AS count FROM checks - WHERE monitor_id = $1 AND monitor_type = 'hardware' AND created_at >= $2 AND created_at <= $3`, - [monitorId, startDate, endDate] - ), - this.pool.query( - `SELECT COUNT(*)::int AS count FROM checks - WHERE monitor_id = $1 AND monitor_type = 'hardware' AND created_at >= $2 AND created_at <= $3 AND status = TRUE`, - [monitorId, startDate, endDate] - ), - metricsQuery, - tempQuery, - diskQuery, - netQuery, - ]); - - // Group disk and net results by bucket date - const disksByBucket = new Map(); - for (const row of diskResult.rows) { - const key = (row.bucket_date as Date).toISOString(); - if (!disksByBucket.has(key)) disksByBucket.set(key, []); - disksByBucket.get(key)!.push(row); - } - - const netsByBucket = new Map(); - for (const row of netResult.rows) { - const key = (row.bucket_date as Date).toISOString(); - if (!netsByBucket.has(key)) netsByBucket.set(key, []); - netsByBucket.get(key)!.push(row); - } - - const tempByBucket = new Map(); - for (const row of tempResult.rows) { - const key = (row.bucket_date as Date).toISOString(); - tempByBucket.set(key, Array.isArray(row.avg_temperature) ? row.avg_temperature.map(Number) : []); - } - - // Assemble checks array from metrics + disk/net maps - const checks = metricsResult.rows.map((row) => { - const bucketDate = (row.bucket_date as Date).toISOString(); - const disks = disksByBucket.get(bucketDate) ?? []; - const nets = netsByBucket.get(bucketDate) ?? []; - - return { - bucketDate, - avgCpuUsage: Number(row.avg_cpu ?? 0), - avgMemoryUsage: Number(row.avg_memory ?? 0), - avgTemperature: tempByBucket.get(bucketDate) ?? [], - disks: disks.map((d) => ({ - name: d.name ?? "", - readSpeed: Number(d.readSpeed ?? 0), - writeSpeed: Number(d.writeSpeed ?? 0), - totalBytes: Number(d.totalBytes ?? 0), - freeBytes: Number(d.freeBytes ?? 0), - usagePercent: Number(d.usagePercent ?? 0), - })), - net: nets.map((n) => ({ - name: n.name ?? "", - bytesSentPerSecond: Number(n.bytesSentPerSecond ?? 0), - deltaBytesRecv: Number(n.deltaBytesRecv ?? 0), - deltaPacketsSent: Number(n.deltaPacketsSent ?? 0), - deltaPacketsRecv: Number(n.deltaPacketsRecv ?? 0), - deltaErrIn: Number(n.deltaErrIn ?? 0), - deltaErrOut: Number(n.deltaErrOut ?? 0), - deltaDropIn: Number(n.deltaDropIn ?? 0), - deltaDropOut: Number(n.deltaDropOut ?? 0), - deltaFifoIn: Number(n.deltaFifoIn ?? 0), - deltaFifoOut: Number(n.deltaFifoOut ?? 0), - })), - }; - }); - - return { - monitorType: "hardware" as const, - aggregateData: { totalChecks: totalResult.rows[0].count }, - upChecks: { totalChecks: upResult.rows[0].count }, - checks, - }; - }; - - private findPageSpeedDateRangeChecks = async ( - monitorId: string, - startDate: Date, - endDate: Date, - dateString: string - ): Promise => { - const bucket = dateStringToBucket(dateString); - const caTable = this.getCaTable(bucket, "pagespeed"); - - const result = caTable - ? await this.pool.query( - `SELECT bucket AS bucket_date, - avg_performance AS performance, - avg_accessibility AS accessibility, - avg_best_practices AS "bestPractices", - avg_seo AS seo, - sample_count::int AS "totalChecks" - FROM ${caTable} - WHERE monitor_id = $1 AND bucket >= $2 AND bucket <= $3 - ORDER BY bucket`, - [monitorId, startDate, endDate] - ) - : await this.pool.query( - `SELECT - time_bucket($1::interval, created_at) AS bucket_date, - AVG(lighthouse_performance) AS performance, - AVG(lighthouse_accessibility) AS accessibility, - AVG(lighthouse_best_practices) AS "bestPractices", - AVG(lighthouse_seo) AS seo, - COUNT(*)::int AS "totalChecks" - FROM checks - WHERE monitor_id = $2 AND monitor_type = 'pagespeed' AND created_at >= $3 AND created_at <= $4 - GROUP BY bucket_date ORDER BY bucket_date`, - [bucket, monitorId, startDate, endDate] - ); - - return { - monitorType: "pagespeed" as const, - groupedChecks: result.rows.map((row) => ({ - bucketDate: (row.bucket_date as Date).toISOString(), - performance: Number(row.performance ?? 0), - accessibility: Number(row.accessibility ?? 0), - bestPractices: Number(row.bestPractices ?? 0), - seo: Number(row.seo ?? 0), - totalChecks: Number(row.totalChecks ?? 0), - })), - }; - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private toEntity = (row: any, disk?: CheckDiskInfo[], errors?: CheckErrorInfo[], net?: CheckNetworkInterfaceInfo[]): Check => { - const timings: GotTimings = { - start: row.timing_start ?? 0, - socket: row.timing_socket ?? 0, - lookup: row.timing_lookup ?? 0, - connect: row.timing_connect ?? 0, - secureConnect: row.timing_secure_connect ?? 0, - upload: row.timing_upload ?? 0, - response: row.timing_response ?? 0, - end: row.timing_end ?? 0, - phases: { - wait: row.phase_wait ?? 0, - dns: row.phase_dns ?? 0, - tcp: row.phase_tcp ?? 0, - tls: row.phase_tls ?? 0, - request: row.phase_request ?? 0, - firstByte: row.phase_first_byte ?? 0, - download: row.phase_download ?? 0, - total: row.phase_total ?? 0, - }, - }; - - const cpu: CheckCpuInfo = { - physical_core: row.cpu_physical_core ?? 0, - logical_core: row.cpu_logical_core ?? 0, - frequency: row.cpu_frequency ?? 0, - current_frequency: row.cpu_current_frequency ?? 0, - temperature: row.cpu_temperature ?? [], - free_percent: row.cpu_free_percent ?? 0, - usage_percent: row.cpu_usage_percent ?? 0, - }; - - const memory: CheckMemoryInfo = { - total_bytes: Number(row.mem_total_bytes ?? 0), - available_bytes: Number(row.mem_available_bytes ?? 0), - used_bytes: Number(row.mem_used_bytes ?? 0), - usage_percent: row.mem_usage_percent ?? 0, - }; - - const host: CheckHostInfo = { - os: row.host_os ?? "", - platform: row.host_platform ?? "", - kernel_version: row.host_kernel_version ?? "", - pretty_name: row.host_pretty_name ?? "", - }; - - const capture: CheckCaptureInfo = { - version: row.capture_version ?? "", - mode: row.capture_mode ?? "", - }; - - const audits: CheckAudits | undefined = - row.audit_cls_score !== null - ? { - cls: { score: row.audit_cls_score, numericValue: row.audit_cls_value, displayValue: row.audit_cls_display }, - si: { score: row.audit_si_score, numericValue: row.audit_si_value, displayValue: row.audit_si_display }, - fcp: { score: row.audit_fcp_score, numericValue: row.audit_fcp_value, displayValue: row.audit_fcp_display }, - lcp: { score: row.audit_lcp_score, numericValue: row.audit_lcp_value, displayValue: row.audit_lcp_display }, - tbt: { score: row.audit_tbt_score, numericValue: row.audit_tbt_value, displayValue: row.audit_tbt_display }, - } - : undefined; - - return { - id: row.id, - metadata: { - monitorId: row.monitor_id, - teamId: row.team_id, - type: row.monitor_type, - }, - status: row.status ?? false, - responseTime: row.response_time ?? 0, - timings, - statusCode: row.status_code ?? 0, - message: row.message ?? "", - cpu, - memory, - disk: disk ?? [], - host, - errors: errors ?? [], - capture, - net: net ?? [], - accessibility: row.lighthouse_accessibility ?? undefined, - bestPractices: row.lighthouse_best_practices ?? undefined, - seo: row.lighthouse_seo ?? undefined, - performance: row.lighthouse_performance ?? undefined, - audits, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }; - }; -} diff --git a/server/src/repositories/geo-checks/TimescaleGeoChecksRepository.ts b/server/src/repositories/geo-checks/TimescaleGeoChecksRepository.ts deleted file mode 100644 index e4528f259..000000000 --- a/server/src/repositories/geo-checks/TimescaleGeoChecksRepository.ts +++ /dev/null @@ -1,274 +0,0 @@ -import type { Pool } from "pg"; -import type { IGeoChecksRepository, FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js"; -import type { GeoCheck, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck, MonitorType } from "@/types/index.js"; -import { getDateForRange } from "@/utils/dataUtils.js"; - -const dateStringToBucket = (dateString: string): string => { - if (dateString.includes("%M")) return "1 minute"; - if (dateString.includes("%H")) return "1 hour"; - return "1 day"; -}; - -export class TimescaleGeoChecksRepository implements IGeoChecksRepository { - constructor(private pool: Pool) {} - - createGeoChecks = async (geoChecks: Omit[]): Promise => { - const created: GeoCheck[] = []; - - for (const geoCheck of geoChecks) { - const geoResult = await this.pool.query( - `INSERT INTO geo_checks (monitor_id, team_id, monitor_type, expiry) - VALUES ($1, $2, $3, $4) - RETURNING id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at`, - [geoCheck.metadata.monitorId, geoCheck.metadata.teamId, geoCheck.metadata.type, geoCheck.expiry ? new Date(geoCheck.expiry) : null] - ); - const row = geoResult.rows[0]; - if (!row) continue; - - for (const result of geoCheck.results) { - await this.pool.query( - `INSERT INTO geo_check_results ( - geo_check_id, geo_check_created_at, - status, status_code, - location_continent, location_region, location_country, location_state, location_city, - location_longitude, location_latitude, - timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, - [ - row.id, - row.created_at, - result.status, - result.statusCode, - result.location.continent, - result.location.region, - result.location.country, - result.location.state, - result.location.city, - result.location.longitude, - result.location.latitude, - result.timings.total, - result.timings.dns, - result.timings.tcp, - result.timings.tls, - result.timings.firstByte, - result.timings.download, - ] - ); - } - - created.push(this.toGeoCheck(row, geoCheck.results)); - } - - return created; - }; - - findByMonitorId = async ( - monitorId: string, - sortOrder: string, - dateRange: string, - page: number, - rowsPerPage: number, - continents?: GeoContinent[] - ): Promise => { - const conditions: string[] = ["gc.monitor_id = $1"]; - const values: unknown[] = [monitorId]; - let paramIndex = 2; - - const rangeDate = getDateForRange(dateRange); - if (rangeDate) { - conditions.push(`gc.created_at >= $${paramIndex++}`); - values.push(rangeDate); - } - - if (continents && continents.length > 0) { - conditions.push(`r.location_continent = ANY($${paramIndex++})`); - values.push(continents); - } - - const where = conditions.join(" AND "); - const direction = sortOrder === "asc" ? "ASC" : "DESC"; - const offset = page && rowsPerPage ? page * rowsPerPage : 0; - - const countValues = [...values]; - const dataValues = [...values, rowsPerPage, offset]; - - const [countResult, dataResult] = await Promise.all([ - this.pool.query( - `SELECT COUNT(*)::int AS count - FROM geo_checks gc - JOIN geo_check_results r ON r.geo_check_id = gc.id - WHERE ${where}`, - countValues - ), - this.pool.query( - `SELECT gc.id, gc.monitor_id, gc.team_id, gc.monitor_type, - r.location_continent, r.location_region, r.location_country, r.location_state, r.location_city, - r.location_longitude, r.location_latitude, - r.status, r.status_code, - r.timing_total, r.timing_dns, r.timing_tcp, r.timing_tls, r.timing_first_byte, r.timing_download, - gc.created_at, gc.updated_at - FROM geo_checks gc - JOIN geo_check_results r ON r.geo_check_id = gc.id - WHERE ${where} - ORDER BY gc.created_at ${direction} - LIMIT $${paramIndex++} OFFSET $${paramIndex}`, - dataValues - ), - ]); - - const geoChecks: FlatGeoCheck[] = dataResult.rows.map((row) => ({ - id: `${row.monitor_id}-${new Date(row.created_at).getTime()}-${row.location_continent}-${row.location_city}-${row.id.substring(0, 8)}`, - monitorId: row.monitor_id, - teamId: row.team_id, - type: row.monitor_type, - location: { - continent: row.location_continent, - region: row.location_region, - country: row.location_country, - state: row.location_state, - city: row.location_city, - longitude: row.location_longitude, - latitude: row.location_latitude, - }, - status: row.status, - statusCode: row.status_code, - timings: { - total: row.timing_total ?? 0, - dns: row.timing_dns ?? 0, - tcp: row.timing_tcp ?? 0, - tls: row.timing_tls ?? 0, - firstByte: row.timing_first_byte ?? 0, - download: row.timing_download ?? 0, - }, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - })); - - return { geoChecksCount: countResult.rows[0].count, geoChecks }; - }; - - findByMonitorIdAndDateRange = async (monitorId: string, startDate: Date, endDate: Date): Promise => { - const geoRows = await this.pool.query( - `SELECT id, monitor_id, team_id, monitor_type, expiry, created_at, updated_at - FROM geo_checks - WHERE monitor_id = $1 AND created_at >= $2 AND created_at <= $3 - ORDER BY created_at DESC`, - [monitorId, startDate, endDate] - ); - - const geoChecks: GeoCheck[] = []; - for (const row of geoRows.rows) { - const resultsRows = await this.pool.query( - `SELECT status, status_code, - location_continent, location_region, location_country, location_state, location_city, - location_longitude, location_latitude, - timing_total, timing_dns, timing_tcp, timing_tls, timing_first_byte, timing_download - FROM geo_check_results - WHERE geo_check_id = $1`, - [row.id] - ); - - const results = resultsRows.rows.map(this.toGeoCheckResult); - geoChecks.push(this.toGeoCheck(row, results)); - } - - return geoChecks; - }; - - findGroupedByMonitorIdAndDateRange = async ( - monitorId: string, - startDate: Date, - endDate: Date, - dateFormat: string, - continents?: GeoContinent[] - ): Promise => { - const bucket = dateStringToBucket(dateFormat); - - const conditions: string[] = ["gc.monitor_id = $2", "gc.created_at >= $3", "gc.created_at <= $4"]; - const values: unknown[] = [bucket, monitorId, startDate, endDate]; - let paramIndex = 5; - - if (continents && continents.length > 0) { - conditions.push(`r.location_continent = ANY($${paramIndex++})`); - values.push(continents); - } - - const result = await this.pool.query( - `SELECT - time_bucket($1::interval, gc.created_at) AS bucket_date, - r.location_continent AS continent, - ROUND(AVG(r.timing_total)::numeric, 2) AS "avgResponseTime", - COUNT(*)::int AS "totalChecks", - ROUND((100.0 * COUNT(*) FILTER (WHERE r.status = TRUE) / COUNT(*))::numeric, 2) AS "uptimePercentage" - FROM geo_checks gc - JOIN geo_check_results r ON r.geo_check_id = gc.id - WHERE ${conditions.join(" AND ")} - GROUP BY bucket_date, r.location_continent - ORDER BY bucket_date, r.location_continent`, - values - ); - - return result.rows.map((row) => ({ - bucketDate: (row.bucket_date as Date).toISOString(), - continent: row.continent as GeoContinent, - avgResponseTime: Number(row.avgResponseTime ?? 0), - totalChecks: Number(row.totalChecks ?? 0), - uptimePercentage: Number(row.uptimePercentage ?? 0), - })); - }; - - deleteByMonitorId = async (monitorId: string): Promise => { - const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id = $1`, [monitorId]); - return result.rowCount ?? 0; - }; - - deleteByTeamId = async (teamId: string): Promise => { - const result = await this.pool.query(`DELETE FROM geo_checks WHERE team_id = $1`, [teamId]); - return result.rowCount ?? 0; - }; - - deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { - if (!monitorIds.length) { - const result = await this.pool.query(`DELETE FROM geo_checks`); - return result.rowCount ?? 0; - } - const result = await this.pool.query(`DELETE FROM geo_checks WHERE monitor_id != ALL($1)`, [monitorIds]); - return result.rowCount ?? 0; - }; - - private toGeoCheckResult = (row: Record): GeoCheckResult => ({ - location: { - continent: (row.location_continent as GeoContinent) ?? "", - region: (row.location_region as string) ?? "", - country: (row.location_country as string) ?? "", - state: (row.location_state as string) ?? "", - city: (row.location_city as string) ?? "", - longitude: (row.location_longitude as number) ?? 0, - latitude: (row.location_latitude as number) ?? 0, - }, - status: (row.status as boolean) ?? false, - statusCode: (row.status_code as number) ?? 0, - timings: { - total: (row.timing_total as number) ?? 0, - dns: (row.timing_dns as number) ?? 0, - tcp: (row.timing_tcp as number) ?? 0, - tls: (row.timing_tls as number) ?? 0, - firstByte: (row.timing_first_byte as number) ?? 0, - download: (row.timing_download as number) ?? 0, - }, - }); - - private toGeoCheck = (row: Record, results: GeoCheckResult[]): GeoCheck => ({ - id: row.id as string, - metadata: { - monitorId: row.monitor_id as string, - teamId: row.team_id as string, - type: row.monitor_type as MonitorType, - }, - results, - expiry: row.expiry ? (row.expiry as Date).toISOString() : new Date(0).toISOString(), - __v: 0, - createdAt: (row.created_at as Date).toISOString(), - updatedAt: (row.updated_at as Date).toISOString(), - }); -} diff --git a/server/src/repositories/incidents/TimescaleIncidentsRepository.ts b/server/src/repositories/incidents/TimescaleIncidentsRepository.ts deleted file mode 100644 index 8a6ad7d22..000000000 --- a/server/src/repositories/incidents/TimescaleIncidentsRepository.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { Pool } from "pg"; -import type { Incident, IncidentSummary, IncidentResolutionType } from "@/types/incident.js"; -import type { IIncidentsRepository } from "@/repositories/incidents/IIncidentsRepository.js"; -import { AppError } from "@/utils/AppError.js"; - -interface IncidentRow { - id: string; - monitor_id: string; - team_id: string; - start_time: Date; - end_time: Date | null; - status: boolean; - message: string | null; - status_code: number | null; - resolution_type: IncidentResolutionType; - resolved_by: string | null; - resolved_by_email: string | null; - comment: string | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, monitor_id, team_id, start_time, end_time, status, message, status_code, - resolution_type, resolved_by, resolved_by_email, comment, created_at, updated_at`; - -export class TimescaleIncidentsRepository implements IIncidentsRepository { - constructor(private pool: Pool) {} - - create = async (incident: Partial): Promise => { - const result = await this.pool.query( - `INSERT INTO incidents (monitor_id, team_id, start_time, end_time, status, message, status_code, resolution_type, resolved_by, resolved_by_email, comment) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING ${COLUMNS}`, - [ - incident.monitorId, - incident.teamId, - incident.startTime ? new Date(Number(incident.startTime) || incident.startTime) : new Date(), - incident.endTime ? new Date(Number(incident.endTime) || incident.endTime) : null, - incident.status ?? true, - incident.message ?? null, - incident.statusCode ?? null, - incident.resolutionType ?? null, - incident.resolvedBy ?? null, - incident.resolvedByEmail ?? null, - incident.comment ?? null, - ] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create incident", status: 500 }); - } - return this.toEntity(row); - }; - - findById = async (incidentId: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2`, [incidentId, teamId]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: `Incident with id ${incidentId} not found`, status: 404 }); - } - return this.toEntity(row); - }; - - findActiveByIncidentId = async (incidentId: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM incidents WHERE id = $1 AND team_id = $2 AND status = TRUE`, [ - incidentId, - teamId, - ]); - const row = result.rows[0]; - return row ? this.toEntity(row) : null; - }; - - findActiveByMonitorId = async (monitorId: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM incidents WHERE monitor_id = $1 AND team_id = $2 AND status = TRUE`, [ - monitorId, - teamId, - ]); - const row = result.rows[0]; - return row ? this.toEntity(row) : null; - }; - - findByTeamId = async ( - teamId: string, - startDate: Date | undefined, - page: number, - rowsPerPage: number, - sortOrder?: string, - status?: boolean, - monitorId?: string, - resolutionType?: string - ): Promise => { - const { conditions, values, paramIndex } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType); - const direction = sortOrder === "asc" ? "ASC" : "DESC"; - const offset = page * rowsPerPage; - - const result = await this.pool.query( - `SELECT ${COLUMNS} FROM incidents - WHERE ${conditions.join(" AND ")} - ORDER BY created_at ${direction} - LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, - [...values, rowsPerPage, offset] - ); - return result.rows.map(this.toEntity); - }; - - countByTeamId = async ( - teamId: string, - startDate: Date | undefined, - status?: boolean, - monitorId?: string, - resolutionType?: string - ): Promise => { - const { conditions, values } = this.buildWhere(teamId, startDate, status, monitorId, resolutionType); - const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM incidents WHERE ${conditions.join(" AND ")}`, values); - return result.rows[0].count; - }; - - findSummaryByTeamId = async (teamId: string, limit?: number): Promise => { - // Counts by status and resolution type - const countsResult = await this.pool.query( - `SELECT - COUNT(*)::int AS total, - COUNT(*) FILTER (WHERE status = TRUE)::int AS active, - COUNT(*) FILTER (WHERE resolution_type = 'manual')::int AS manual, - COUNT(*) FILTER (WHERE resolution_type = 'automatic')::int AS automatic - FROM incidents WHERE team_id = $1`, - [teamId] - ); - const counts = countsResult.rows[0] ?? { total: 0, active: 0, manual: 0, automatic: 0 }; - - // Average resolution time - const resTimeResult = await this.pool.query( - `SELECT AVG(EXTRACT(EPOCH FROM (end_time - start_time))) AS avg_seconds - FROM incidents - WHERE team_id = $1 AND status = FALSE AND end_time IS NOT NULL`, - [teamId] - ); - const avgSeconds = resTimeResult.rows[0]?.avg_seconds ?? 0; - const avgResolutionTimeHours = Math.round((avgSeconds / 3600) * 100) / 100; - - // Top monitor by incident count - const topMonitorResult = await this.pool.query( - `SELECT i.monitor_id, m.name AS monitor_name, COUNT(*)::int AS count - FROM incidents i - LEFT JOIN monitors m ON m.id = i.monitor_id - WHERE i.team_id = $1 - GROUP BY i.monitor_id, m.name - ORDER BY count DESC - LIMIT 1`, - [teamId] - ); - - // Latest incidents - const latestLimit = Math.max(1, Number.isFinite(Number(limit)) ? Number(limit) : 10); - const latestResult = await this.pool.query( - `SELECT i.id, i.monitor_id, m.name AS monitor_name, i.status, i.start_time, i.end_time, - i.resolution_type, i.message, i.status_code, i.created_at - FROM incidents i - LEFT JOIN monitors m ON m.id = i.monitor_id - WHERE i.team_id = $1 - ORDER BY i.created_at DESC - LIMIT $2`, - [teamId, latestLimit] - ); - - const topRow = topMonitorResult.rows[0]; - - return { - total: counts.total, - totalActive: counts.active, - totalManualResolutions: counts.manual, - totalAutomaticResolutions: counts.automatic, - avgResolutionTimeHours, - topMonitor: topRow - ? { - monitorId: topRow.monitor_id, - monitorName: topRow.monitor_name ?? null, - incidentCount: topRow.count, - } - : null, - latestIncidents: latestResult.rows.map((row) => ({ - id: row.id, - monitorId: row.monitor_id, - monitorName: row.monitor_name ?? null, - status: row.status, - startTime: row.start_time.toISOString(), - endTime: row.end_time ? row.end_time.toISOString() : null, - resolutionType: row.resolution_type ?? null, - message: row.message ?? null, - statusCode: row.status_code ?? null, - createdAt: row.created_at.toISOString(), - })), - }; - }; - - updateById = async (incidentId: string, teamId: string, patch: Partial): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - const fieldMap: [keyof Incident, string][] = [ - ["status", "status"], - ["message", "message"], - ["statusCode", "status_code"], - ["endTime", "end_time"], - ["resolutionType", "resolution_type"], - ["resolvedBy", "resolved_by"], - ["resolvedByEmail", "resolved_by_email"], - ["comment", "comment"], - ]; - - for (const [key, column] of fieldMap) { - if (patch[key] !== undefined) { - const value = key === "endTime" && patch[key] ? new Date(Number(patch[key]) || (patch[key] as string)) : patch[key]; - sets.push(`${column} = $${paramIndex++}`); - values.push(value); - } - } - - if (sets.length === 0) { - return this.findById(incidentId, teamId); - } - - sets.push(`updated_at = NOW()`); - values.push(incidentId, teamId); - - const result = await this.pool.query( - `UPDATE incidents SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex} - RETURNING ${COLUMNS}`, - values - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: `Failed to update incident with id ${incidentId}`, status: 500 }); - } - return this.toEntity(row); - }; - - deleteByMonitorId = async (monitorId: string, teamId: string): Promise => { - const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id = $1 AND team_id = $2`, [monitorId, teamId]); - return result.rowCount ?? 0; - }; - - deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { - if (!monitorIds.length) { - const result = await this.pool.query(`DELETE FROM incidents`); - return result.rowCount ?? 0; - } - const result = await this.pool.query(`DELETE FROM incidents WHERE monitor_id != ALL($1)`, [monitorIds]); - return result.rowCount ?? 0; - }; - - private buildWhere = (teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string) => { - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - let paramIndex = 2; - - if (startDate) { - conditions.push(`created_at >= $${paramIndex++}`); - values.push(startDate); - } - if (status !== undefined) { - conditions.push(`status = $${paramIndex++}`); - values.push(status); - } - if (monitorId) { - conditions.push(`monitor_id = $${paramIndex++}`); - values.push(monitorId); - } - if (resolutionType) { - conditions.push(`resolution_type = $${paramIndex++}`); - values.push(resolutionType); - } - - return { conditions, values, paramIndex }; - }; - - private toEntity = (row: IncidentRow): Incident => ({ - id: row.id, - monitorId: row.monitor_id, - teamId: row.team_id, - startTime: row.start_time.toISOString(), - endTime: row.end_time ? row.end_time.toISOString() : null, - status: row.status, - message: row.message ?? null, - statusCode: row.status_code ?? null, - resolutionType: row.resolution_type ?? null, - resolvedBy: row.resolved_by ?? null, - resolvedByEmail: row.resolved_by_email ?? null, - comment: row.comment ?? null, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 84a9a7cda..fc5eefee9 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -1,54 +1,41 @@ export * from "@/repositories/monitors/IMonitorsRepository.js"; export { default as MongoMonitorsRepository } from "@/repositories/monitors/MongoMonitorsRepository.js"; -export { TimescaleMonitorsRepository } from "@/repositories/monitors/TimescaleMonitorsRepository.js"; export * from "@/repositories/checks/IChecksRepository.js"; export { default as MongoChecksRepository } from "@/repositories/checks/MongoChecksRepistory.js"; -export { TimescaleChecksRepository } from "@/repositories/checks/TimescaleChecksRepository.js"; export * from "@/repositories/monitor-stats/IMonitorStatsRepository.js"; export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-stats/MongoMonitorStatsRepository.js"; -export { TimescaleMonitorStatsRepository } from "@/repositories/monitor-stats/TimescaleMonitorStatsRepository.js"; export * from "@/repositories/status-pages/IStatusPagesRepository.js"; export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.js"; -export { TimescaleStatusPagesRepository } from "@/repositories/status-pages/TimescaleStatusPagesRepository.js"; export * from "@/repositories/users/IUsersRepository.js"; export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js"; -export { TimescaleUsersRepository } from "@/repositories/users/TimescaleUsersRepository.js"; export * from "@/repositories/invites/IInvitesRepository.js"; export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js"; -export { TimescaleInvitesRepository } from "@/repositories/invites/TimescaleInvitesRepository.js"; export * from "@/repositories/recovery-tokens/IRecoveryTokensRepository.js"; export { default as MongoRecoveryTokensRepository } from "@/repositories/recovery-tokens/MongoRecoveryTokensRepository.js"; -export { TimescaleRecoveryTokensRepository } from "@/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.js"; export * from "@/repositories/settings/ISettingsRepository.js"; export { default as MongoSettingsRepository } from "@/repositories/settings/MongoSettingsRepository.js"; -export { TimescaleSettingsRepository } from "@/repositories/settings/TimescaleSettingsRepository.js"; export * from "@/repositories/notifications/INotificationsRepository.js"; export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js"; -export { TimescaleNotificationsRepository } from "@/repositories/notifications/TimescaleNotificationsRepository.js"; export * from "@/repositories/incidents/IIncidentsRepository.js"; export { default as MongoIncidentsRepository } from "@/repositories/incidents/MongoIncidentsRepository.js"; -export { TimescaleIncidentsRepository } from "@/repositories/incidents/TimescaleIncidentsRepository.js"; export * from "@/repositories/teams/ITeamsRepository.js"; export { default as MongoTeamsRepository } from "@/repositories/teams/MongoTeamsRepository.js"; -export { TimescaleTeamsRepository } from "@/repositories/teams/TimescaleTeamsRepository.js"; export * from "@/repositories/maintenance-windows/IMaintenanceWindowsRepository.js"; export { default as MongoMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.js"; -export { TimescaleMaintenanceWindowsRepository } from "@/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.js"; export * from "@/repositories/geo-checks/IGeoChecksRepository.js"; export { default as MongoGeoChecksRepository } from "@/repositories/geo-checks/MongoGeoChecksRepository.js"; export * from "@/repositories/dlq/IDLQRepository.js"; export { default as MongoDLQRepository } from "@/repositories/dlq/MongoDLQRepository.js"; -export { TimescaleGeoChecksRepository } from "@/repositories/geo-checks/TimescaleGeoChecksRepository.js"; diff --git a/server/src/repositories/invites/TimescaleInvitesRepository.ts b/server/src/repositories/invites/TimescaleInvitesRepository.ts deleted file mode 100644 index 853047745..000000000 --- a/server/src/repositories/invites/TimescaleInvitesRepository.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Pool } from "pg"; -import crypto from "crypto"; -import type { IInvitesRepository } from "@/repositories/invites/IInvitesRepository.js"; -import type { Invite } from "@/types/invite.js"; -import type { UserRole } from "@/types/user.js"; -import { AppError } from "@/utils/AppError.js"; - -const parsePostgresArray = (value: unknown): UserRole[] => { - if (typeof value !== "string") return []; - const inner = value.replace(/^\{|\}$/g, ""); - if (inner === "") return []; - return inner.split(",") as UserRole[]; -}; - -interface InviteRow { - id: string; - email: string; - team_id: string; - roles: UserRole[]; - token: string; - expiry: Date | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, email, team_id, roles, token, expiry, created_at, updated_at`; - -export class TimescaleInvitesRepository implements IInvitesRepository { - constructor(private pool: Pool) {} - - create = async (invite: Partial): Promise => { - // Delete existing invites for this email - await this.pool.query(`DELETE FROM invites WHERE email = $1`, [invite.email]); - - const token = crypto.randomBytes(32).toString("hex"); - const result = await this.pool.query( - `INSERT INTO invites (email, team_id, roles, token, expiry) - VALUES ($1, $2, $3, $4, $5) - RETURNING ${COLUMNS}`, - [invite.email, invite.teamId, invite.role ?? ["user"], token, invite.expiry ? new Date(invite.expiry) : null] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create invite", status: 500 }); - } - return this.toEntity(row); - }; - - findByToken = async (token: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM invites WHERE token = $1`, [token]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Invite not found", status: 404 }); - } - return this.toEntity(row); - }; - - findByTokenAndDelete = async (token: string): Promise => { - const result = await this.pool.query(`DELETE FROM invites WHERE token = $1 RETURNING ${COLUMNS}`, [token]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Invite not found", status: 404 }); - } - return this.toEntity(row); - }; - - private toEntity = (row: InviteRow): Invite => ({ - id: row.id, - email: row.email, - teamId: row.team_id, - role: Array.isArray(row.roles) ? row.roles : parsePostgresArray(row.roles), - token: row.token, - expiry: row.expiry ? row.expiry.toISOString() : new Date(0).toISOString(), - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.ts deleted file mode 100644 index cfe28198a..000000000 --- a/server/src/repositories/maintenance-windows/TimescaleMaintenanceWindowsRepository.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { Pool } from "pg"; -import type { IMaintenanceWindowsRepository } from "./IMaintenanceWindowsRepository.js"; -import type { MaintenanceWindow, DurationUnit } from "@/types/maintenanceWindow.js"; -import { AppError } from "@/utils/AppError.js"; - -interface MaintenanceWindowRow { - id: string; - monitor_id: string; - team_id: string; - active: boolean; - name: string; - duration: number; - duration_unit: DurationUnit; - repeat: number; - start_time: Date; - end_time: Date; - expiry: Date | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, monitor_id, team_id, active, name, duration, duration_unit, repeat, - start_time, end_time, expiry, created_at, updated_at`; - -export class TimescaleMaintenanceWindowsRepository implements IMaintenanceWindowsRepository { - constructor(private pool: Pool) {} - - create = async (data: Partial): Promise => { - const startTime = data.start ? new Date(data.start) : null; - const endTime = data.end ? new Date(data.end) : null; - // One-time windows expire at end time - const expiry = data.repeat === 0 ? endTime : null; - - const result = await this.pool.query( - `INSERT INTO maintenance_windows (monitor_id, team_id, active, name, duration, duration_unit, repeat, start_time, end_time, expiry) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING ${COLUMNS}`, - [ - data.monitorId, - data.teamId, - data.active ?? true, - data.name ?? null, - data.duration ?? null, - data.durationUnit ?? null, - data.repeat ?? 0, - startTime, - endTime, - expiry, - ] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create maintenance window", status: 500 }); - } - return this.toEntity(row); - }; - - findById = async (id: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM maintenance_windows WHERE id = $1 AND team_id = $2`, [ - id, - teamId, - ]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Maintenance Window not found", status: 404 }); - } - return this.toEntity(row); - }; - - findByMonitorId = async (monitorId: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM maintenance_windows WHERE monitor_id = $1 AND team_id = $2`, [ - monitorId, - teamId, - ]); - return result.rows.map(this.toEntity); - }; - - findByTeamId = async ( - teamId: string, - page: number, - rowsPerPage: number, - field?: string, - order?: string, - active?: boolean - ): Promise => { - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - let paramIndex = 2; - - if (active !== undefined) { - conditions.push(`active = $${paramIndex++}`); - values.push(active); - } - - const fieldMap: Record = { - createdAt: "created_at", - name: "name", - start: "start_time", - end: "end_time", - active: "active", - }; - const sortColumn = field ? (fieldMap[field] ?? "created_at") : "created_at"; - const sortDirection = order === "asc" ? "ASC" : "DESC"; - - const offset = page && rowsPerPage ? page * rowsPerPage : 0; - - const result = await this.pool.query( - `SELECT ${COLUMNS} FROM maintenance_windows - WHERE ${conditions.join(" AND ")} - ORDER BY ${sortColumn} ${sortDirection} - LIMIT $${paramIndex++} OFFSET $${paramIndex}`, - [...values, rowsPerPage, offset] - ); - return result.rows.map(this.toEntity); - }; - - updateById = async (id: string, teamId: string, patch: Partial): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - const fieldMap: [keyof MaintenanceWindow, string, boolean][] = [ - ["active", "active", false], - ["name", "name", false], - ["duration", "duration", false], - ["durationUnit", "duration_unit", false], - ["repeat", "repeat", false], - ["start", "start_time", true], - ["end", "end_time", true], - ]; - - for (const [key, column, isDate] of fieldMap) { - if (patch[key] !== undefined) { - sets.push(`${column} = $${paramIndex++}`); - values.push(isDate ? new Date(patch[key] as string) : patch[key]); - } - } - - if (sets.length === 0) { - return this.findById(id, teamId); - } - - sets.push(`updated_at = NOW()`); - values.push(id, teamId); - - const result = await this.pool.query( - `UPDATE maintenance_windows SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex} - RETURNING ${COLUMNS}`, - values - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Maintenance window not found or could not be updated", status: 404 }); - } - return this.toEntity(row); - }; - - deleteById = async (id: string, teamId: string): Promise => { - const result = await this.pool.query( - `DELETE FROM maintenance_windows WHERE id = $1 AND team_id = $2 RETURNING ${COLUMNS}`, - [id, teamId] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Maintenance window not found or could not be deleted", status: 404 }); - } - return this.toEntity(row); - }; - - countByTeamId = async (teamId: string, active?: boolean): Promise => { - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - - if (active !== undefined) { - conditions.push(`active = $2`); - values.push(active); - } - - const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM maintenance_windows WHERE ${conditions.join(" AND ")}`, values); - return result.rows[0].count; - }; - - private toEntity = (row: MaintenanceWindowRow): MaintenanceWindow => ({ - id: row.id, - monitorId: row.monitor_id, - teamId: row.team_id, - active: row.active, - name: row.name, - duration: row.duration, - durationUnit: row.duration_unit, - repeat: row.repeat, - start: row.start_time.toISOString(), - end: row.end_time.toISOString(), - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts index 4abdeb33f..101283e32 100644 --- a/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts +++ b/server/src/repositories/monitor-stats/IMonitorStatsRepository.ts @@ -1,11 +1,12 @@ -import type { MonitorStats } from "@/types/index.js"; +import type { MonitorStats, CheckResultInput } from "@/types/index.js"; + export interface IMonitorStatsRepository { // create create(data: Omit): Promise; // single fetch findByMonitorId(monitorId: string): Promise; - // update - updateByMonitorId(monitorId: string, data: Omit): Promise; + // Upsert must be atomic to prevent race conditions + updateByMonitorId(monitorId: string, result: CheckResultInput): Promise; // delete deleteByMonitorId(monitorId: string): Promise; deleteByMonitorIds(monitorIds: string[]): Promise; diff --git a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts index 774405587..ea255f7ad 100644 --- a/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts +++ b/server/src/repositories/monitor-stats/MongoMonitorStatsRepository.ts @@ -1,5 +1,5 @@ import { type MonitorStatsDocument, MonitorStatsModel } from "@/db/models/index.js"; -import type { MonitorStats } from "@/types/index.js"; +import type { MonitorStats, CheckResultInput } from "@/types/index.js"; import { IMonitorStatsRepository } from "@/repositories/index.js"; import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; @@ -46,10 +46,48 @@ class MongoMonitorStatsRepository implements IMonitorStatsRepository { return this.toEntity(monitorStats); }; - updateByMonitorId = async (monitorId: string, data: Omit): Promise => { - const updated = await MonitorStatsModel.findOneAndUpdate({ monitorId: new mongoose.Types.ObjectId(monitorId) }, { $set: data }, { new: true }); + updateByMonitorId = async (monitorId: string, result: CheckResultInput): Promise => { + const { status, responseTime, now } = result; + const objectId = new mongoose.Types.ObjectId(monitorId); + const upDelta = status ? 1 : 0; + const downDelta = status ? 0 : 1; + const pipeline = [ + { + $set: { + monitorId: objectId, + totalChecks: { $add: [{ $ifNull: ["$totalChecks", 0] }, 1] }, + totalUpChecks: { $add: [{ $ifNull: ["$totalUpChecks", 0] }, upDelta] }, + totalDownChecks: { $add: [{ $ifNull: ["$totalDownChecks", 0] }, downDelta] }, + avgResponseTime: { + $divide: [ + { + $add: [{ $multiply: [{ $ifNull: ["$avgResponseTime", 0] }, { $ifNull: ["$totalChecks", 0] }] }, responseTime], + }, + { $add: [{ $ifNull: ["$totalChecks", 0] }, 1] }, + ], + }, + uptimePercentage: { + $divide: [{ $add: [{ $ifNull: ["$totalUpChecks", 0] }, upDelta] }, { $add: [{ $ifNull: ["$totalChecks", 0] }, 1] }], + }, + maxResponseTime: { $max: [{ $ifNull: ["$maxResponseTime", 0] }, responseTime] }, + lastResponseTime: responseTime, + lastCheckTimestamp: now, + timeOfLastFailure: status + ? { + $cond: [{ $eq: [{ $ifNull: ["$timeOfLastFailure", 0] }, 0] }, now, "$timeOfLastFailure"], + } + : 0, + }, + }, + ]; + + const updated = await MonitorStatsModel.findOneAndUpdate({ monitorId: objectId }, pipeline, { + upsert: true, + new: true, + setDefaultsOnInsert: true, + }); if (!updated) { - throw new AppError({ message: "Monitor stats not found", status: 404 }); + throw new AppError({ message: "Failed to apply check result to monitor stats", status: 500 }); } return this.toEntity(updated); }; diff --git a/server/src/repositories/monitor-stats/TimescaleMonitorStatsRepository.ts b/server/src/repositories/monitor-stats/TimescaleMonitorStatsRepository.ts deleted file mode 100644 index f5801a1c0..000000000 --- a/server/src/repositories/monitor-stats/TimescaleMonitorStatsRepository.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { Pool } from "pg"; -import type { IMonitorStatsRepository } from "./IMonitorStatsRepository.js"; -import type { MonitorStats } from "@/types/monitorStats.js"; -import { AppError } from "@/utils/AppError.js"; - -interface MonitorStatsRow { - id: string; - monitor_id: string; - avg_response_time: number; - max_response_time: number; - total_checks: number; - total_up_checks: number; - total_down_checks: number; - uptime_percentage: number; - last_check_timestamp: Date | null; - last_response_time: number; - time_of_last_failure: Date | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, monitor_id, avg_response_time, max_response_time, total_checks, total_up_checks, - total_down_checks, uptime_percentage, last_check_timestamp, last_response_time, time_of_last_failure, - created_at, updated_at`; - -export class TimescaleMonitorStatsRepository implements IMonitorStatsRepository { - constructor(private pool: Pool) {} - - create = async (data: Omit): Promise => { - const result = await this.pool.query( - `INSERT INTO monitor_stats (monitor_id, avg_response_time, max_response_time, total_checks, total_up_checks, - total_down_checks, uptime_percentage, last_check_timestamp, last_response_time, time_of_last_failure) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING ${COLUMNS}`, - [ - data.monitorId, - data.avgResponseTime, - data.maxResponseTime, - data.totalChecks, - data.totalUpChecks, - data.totalDownChecks, - data.uptimePercentage, - data.lastCheckTimestamp ? new Date(data.lastCheckTimestamp) : null, - data.lastResponseTime, - data.timeOfLastFailure ? new Date(data.timeOfLastFailure) : null, - ] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create monitor stats", status: 500 }); - } - return this.toEntity(row); - }; - - findByMonitorId = async (monitorId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM monitor_stats WHERE monitor_id = $1`, [monitorId]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Monitor stats not found", status: 404 }); - } - return this.toEntity(row); - }; - - updateByMonitorId = async (monitorId: string, data: Omit): Promise => { - const result = await this.pool.query( - `UPDATE monitor_stats SET - avg_response_time = $2, - max_response_time = $3, - total_checks = $4, - total_up_checks = $5, - total_down_checks = $6, - uptime_percentage = $7, - last_check_timestamp = $8, - last_response_time = $9, - time_of_last_failure = $10, - updated_at = NOW() - WHERE monitor_id = $1 - RETURNING ${COLUMNS}`, - [ - monitorId, - data.avgResponseTime, - data.maxResponseTime, - data.totalChecks, - data.totalUpChecks, - data.totalDownChecks, - data.uptimePercentage, - data.lastCheckTimestamp ? new Date(data.lastCheckTimestamp) : null, - data.lastResponseTime, - data.timeOfLastFailure ? new Date(data.timeOfLastFailure) : null, - ] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Monitor stats not found", status: 404 }); - } - return this.toEntity(row); - }; - - deleteByMonitorId = async (monitorId: string): Promise => { - const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id = $1 RETURNING ${COLUMNS}`, [monitorId]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Monitor stats not found", status: 404 }); - } - return this.toEntity(row); - }; - - deleteByMonitorIds = async (monitorIds: string[]): Promise => { - if (!monitorIds.length) { - return 0; - } - const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id = ANY($1)`, [monitorIds]); - return result.rowCount ?? 0; - }; - - deleteByMonitorIdsNotIn = async (monitorIds: string[]): Promise => { - if (!monitorIds.length) { - const result = await this.pool.query(`DELETE FROM monitor_stats`); - return result.rowCount ?? 0; - } - const result = await this.pool.query(`DELETE FROM monitor_stats WHERE monitor_id != ALL($1)`, [monitorIds]); - return result.rowCount ?? 0; - }; - - private toEntity = (row: MonitorStatsRow): MonitorStats => ({ - id: row.id, - monitorId: row.monitor_id, - avgResponseTime: row.avg_response_time, - maxResponseTime: row.max_response_time, - totalChecks: Number(row.total_checks), - totalUpChecks: Number(row.total_up_checks), - totalDownChecks: Number(row.total_down_checks), - uptimePercentage: row.uptime_percentage, - lastCheckTimestamp: row.last_check_timestamp ? row.last_check_timestamp.getTime() : 0, - lastResponseTime: row.last_response_time, - timeOfLastFailure: row.time_of_last_failure ? row.time_of_last_failure.getTime() : undefined, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index 8c5f63e7d..ac4c8f9eb 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -1,4 +1,4 @@ -import { type MonitorType, type Monitor, type MonitorsSummary } from "@/types/index.js"; +import { type MonitorType, type Monitor, type MonitorsSummary, CheckSnapshot } from "@/types/index.js"; export interface TeamQueryConfig { limit?: number; @@ -29,7 +29,17 @@ export interface IMonitorsRepository { // update updateById(monitorId: string, teamId: string, updates: Partial): Promise; + updateStatusWindowAndChecks( + monitorId: string, + teamId: string, + status: boolean, + checkSnapshot: CheckSnapshot, + windowSize: number, + maxRecentChecks: number, + 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 }>; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index aed881006..d814c3348 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -182,6 +182,33 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.toEntity(updatedMonitor); }; + updateStatusWindowAndChecks = async ( + monitorId: string, + teamId: string, + status: boolean, + checkSnapshot: CheckSnapshot, + windowSize: number, + maxRecentChecks: number, + statusPatch?: Partial + ): Promise => { + const updatedMonitor = await MonitorModel.findOneAndUpdate( + { _id: monitorId, teamId }, + { + $push: { + statusWindow: { $each: [status], $slice: -windowSize }, + recentChecks: { $each: [checkSnapshot], $slice: -maxRecentChecks }, + }, + ...(statusPatch && { $set: statusPatch }), + }, + { returnDocument: "after" } + ); + + if (!updatedMonitor) { + throw new AppError({ message: `Failed to update status and checks for monitor with id ${monitorId}`, status: 500 }); + } + return this.toEntity(updatedMonitor); + }; + togglePauseById = async (monitorId: string, teamId: string) => { const monitor = await MonitorModel.findOneAndUpdate( { _id: monitorId, teamId }, @@ -207,6 +234,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 }); @@ -392,6 +447,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { 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), }; @@ -452,6 +509,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { 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/monitors/TimescaleMonitorsRepository.ts b/server/src/repositories/monitors/TimescaleMonitorsRepository.ts deleted file mode 100644 index 60c382cf1..000000000 --- a/server/src/repositories/monitors/TimescaleMonitorsRepository.ts +++ /dev/null @@ -1,1049 +0,0 @@ -import type { Pool } from "pg"; -import type { Monitor, MonitorsSummary, MonitorStatus, MonitorType, MonitorMatchMethod, GeoContinent, PageSpeedStrategy } from "@/types/monitor.js"; - -import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "./IMonitorsRepository.js"; -import { AppError } from "@/utils/AppError.js"; - -interface MonitorRow { - id: string; - user_id: string; - team_id: string; - name: string; - description: string | null; - type: MonitorType; - status: MonitorStatus; - url: string | null; - port: number | null; - ignore_tls_errors: boolean; - use_advanced_matching: boolean; - json_path: string | null; - expected_value: string | null; - match_method: MonitorMatchMethod | null; - secret: string | null; - interval_ms: number; - is_active: boolean; - status_window: boolean[] | null; - status_window_size: number; - status_window_threshold: number; - uptime_percentage: number | null; - cpu_alert_threshold: number; - cpu_alert_counter: number; - memory_alert_threshold: number; - memory_alert_counter: number; - disk_alert_threshold: number; - disk_alert_counter: number; - temp_alert_threshold: number; - temp_alert_counter: number; - selected_disks: string[] | null; - game_id: string | null; - grpc_service_name: string | null; - strategy: PageSpeedStrategy | null; - monitor_group: string | null; - geo_check_enabled: boolean; - geo_check_locations: GeoContinent[] | null; - geo_check_interval_ms: number; - created_at: Date; - updated_at: Date; -} - -const MONITOR_COLUMNS = `id, user_id, team_id, name, description, type, status, url, port, - ignore_tls_errors, use_advanced_matching, json_path, expected_value, match_method, secret, - interval_ms, is_active, status_window, status_window_size, status_window_threshold, uptime_percentage, - cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, - disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, - game_id, grpc_service_name, strategy, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms, - created_at, updated_at`; - -export class TimescaleMonitorsRepository implements IMonitorsRepository { - constructor(private pool: Pool) {} - - create = async (monitor: Monitor, teamId: string, userId: string): Promise => { - const result = await this.pool.query( - `INSERT INTO monitors (user_id, team_id, name, description, type, status, url, port, - ignore_tls_errors, use_advanced_matching, json_path, expected_value, match_method, secret, - interval_ms, is_active, status_window, status_window_size, status_window_threshold, - cpu_alert_threshold, cpu_alert_counter, memory_alert_threshold, memory_alert_counter, - disk_alert_threshold, disk_alert_counter, temp_alert_threshold, temp_alert_counter, selected_disks, - game_id, grpc_service_name, strategy, monitor_group, geo_check_enabled, geo_check_locations, geo_check_interval_ms) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35) - RETURNING ${MONITOR_COLUMNS}`, - [ - userId, - teamId, - monitor.name, - monitor.description ?? null, - monitor.type, - monitor.status ?? "initializing", - monitor.url ?? null, - monitor.port ?? null, - monitor.ignoreTlsErrors ?? false, - monitor.useAdvancedMatching ?? false, - monitor.jsonPath ?? null, - monitor.expectedValue ?? null, - monitor.matchMethod || null, - monitor.secret ?? null, - monitor.interval ?? 60000, - monitor.isActive ?? true, - monitor.statusWindow ?? null, - monitor.statusWindowSize ?? 5, - monitor.statusWindowThreshold ?? 60, - monitor.cpuAlertThreshold ?? 0, - monitor.cpuAlertCounter ?? 0, - monitor.memoryAlertThreshold ?? 0, - monitor.memoryAlertCounter ?? 0, - monitor.diskAlertThreshold ?? 0, - monitor.diskAlertCounter ?? 0, - monitor.tempAlertThreshold ?? 0, - monitor.tempAlertCounter ?? 0, - monitor.selectedDisks ?? [], - monitor.gameId ?? null, - monitor.grpcServiceName ?? null, - monitor.strategy ?? null, - monitor.group ?? null, - monitor.geoCheckEnabled ?? false, - monitor.geoCheckLocations ?? [], - monitor.geoCheckInterval ?? 300000, - ] - ); - const row = result.rows[0]; - if (!row) { - return null; - } - - // Insert notification associations - if (monitor.notifications?.length) { - for (const notificationId of monitor.notifications) { - await this.pool.query(`INSERT INTO monitor_notifications (monitor_id, notification_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [ - row.id, - notificationId, - ]); - } - } - - const entity = this.toEntity(row); - entity.notifications = monitor.notifications ?? []; - return entity; - }; - - createMonitors = async (monitors: Monitor[]): Promise => { - if (!monitors.length) { - return []; - } - const created: Monitor[] = []; - for (const monitor of monitors) { - const result = await this.create(monitor, monitor.teamId, monitor.userId); - if (result) { - created.push(result); - } - } - return created; - }; - - findById = async (monitorId: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${MONITOR_COLUMNS} FROM monitors WHERE id = $1 AND team_id = $2`, [monitorId, teamId]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: `Monitor with ID ${monitorId} not found`, status: 404 }); - } - const monitor = this.toEntity(row); - - // Populate latest check with child data - const checkResult = await this.pool.query(`SELECT * FROM checks WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT 1`, [monitorId]); - if (checkResult.rows[0]) { - const checkRow = checkResult.rows[0]; - const childData = await this.fetchCheckChildData(checkRow.id); - monitor.recentChecks = [this.toCheckSnapshot(checkRow, childData)]; - } - - // Populate notifications - monitor.notifications = await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? []); - - return monitor; - }; - - findAll = async (): Promise => { - const result = await this.pool.query(`SELECT ${MONITOR_COLUMNS} FROM monitors`); - const monitors = result.rows.map(this.toEntity); - if (monitors.length > 0) { - const notifMap = await this.fetchNotificationIds(monitors.map((m) => m.id)); - for (const monitor of monitors) { - monitor.notifications = notifMap.get(monitor.id) ?? []; - } - } - return monitors; - }; - - findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise => { - const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type } = config ?? {}; - - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - let paramIndex = 2; - - if (type !== undefined) { - if (Array.isArray(type)) { - conditions.push(`type = ANY($${paramIndex++})`); - values.push(type); - } else { - conditions.push(`type = $${paramIndex++}`); - values.push(type); - } - } - - if (filter !== undefined) { - switch (field) { - case "name": - conditions.push(`(name ILIKE $${paramIndex} OR url ILIKE $${paramIndex})`); - values.push(`%${filter}%`); - paramIndex++; - break; - case "isActive": - conditions.push(`is_active = $${paramIndex++}`); - values.push(filter === "true"); - break; - case "status": - conditions.push(`status = $${paramIndex++}`); - values.push(filter); - break; - case "type": - conditions.push(`type = $${paramIndex++}`); - values.push(filter); - break; - default: - break; - } - } - - const fieldMap: Record = { - createdAt: "created_at", - name: "name", - status: "status", - type: "type", - isActive: "is_active", - }; - const sortColumn = fieldMap[field] ?? "created_at"; - const sortDirection = order === "asc" ? "ASC" : "DESC"; - - let query = `SELECT ${MONITOR_COLUMNS} FROM monitors WHERE ${conditions.join(" AND ")} ORDER BY ${sortColumn} ${sortDirection}`; - - if (rowsPerPage > 0) { - const offset = Math.max(page, 0) * rowsPerPage; - query += ` LIMIT $${paramIndex++} OFFSET $${paramIndex++}`; - values.push(rowsPerPage, offset); - } - - const result = await this.pool.query(query, values); - const monitors = result.rows.map(this.toEntity); - - if (monitors.length === 0) return monitors; - - // Populate recentChecks — 25 latest checks per monitor in a single query - const monitorIds = monitors.map((m) => m.id); - const checksResult = await this.pool.query( - `SELECT * FROM ( - SELECT *, ROW_NUMBER() OVER (PARTITION BY monitor_id ORDER BY created_at DESC) AS rn - FROM checks - WHERE monitor_id = ANY($1) - ) sub WHERE rn <= 25 - ORDER BY monitor_id, created_at ASC`, - [monitorIds] - ); - - // Group checks by monitor_id - const checksMap = new Map(); - for (const row of checksResult.rows) { - if (!checksMap.has(row.monitor_id)) checksMap.set(row.monitor_id, []); - checksMap.get(row.monitor_id)!.push(row); - } - - // Batch fetch child data for all checks in 3 queries - const checkIds = checksResult.rows.map((r) => r.id); - const diskMap = new Map[]>(); - const netMap = new Map[]>(); - const errorsMap = new Map[]>(); - - if (checkIds.length > 0) { - const [disksResult, netsResult, errsResult] = await Promise.all([ - this.pool.query( - `SELECT check_id, device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, - total_inodes, free_inodes, used_inodes, inodes_usage_percent, - read_bytes, write_bytes, read_time, write_time - FROM check_disks WHERE check_id = ANY($1)`, - [checkIds] - ), - this.pool.query( - `SELECT check_id, name, bytes_sent, bytes_recv, packets_sent, packets_recv, - err_in, err_out, drop_in, drop_out, fifo_in, fifo_out - FROM check_network_interfaces WHERE check_id = ANY($1)`, - [checkIds] - ), - this.pool.query( - `SELECT check_id, metrics, error - FROM check_errors WHERE check_id = ANY($1)`, - [checkIds] - ), - ]); - - for (const d of disksResult.rows) { - const key = d.check_id as string; - if (!diskMap.has(key)) diskMap.set(key, []); - diskMap.get(key)!.push(d); - } - for (const n of netsResult.rows) { - const key = n.check_id as string; - if (!netMap.has(key)) netMap.set(key, []); - netMap.get(key)!.push(n); - } - for (const e of errsResult.rows) { - const key = e.check_id as string; - if (!errorsMap.has(key)) errorsMap.set(key, []); - errorsMap.get(key)!.push(e); - } - } - - for (const monitor of monitors) { - const rows = checksMap.get(monitor.id) ?? []; - monitor.recentChecks = rows.map((row) => { - const childData = { - disk: (diskMap.get(row.id) ?? []).map((d) => ({ - device: d.device, - mountpoint: d.mountpoint, - total_bytes: Number(d.total_bytes), - free_bytes: Number(d.free_bytes), - used_bytes: Number(d.used_bytes), - usage_percent: d.usage_percent as number, - total_inodes: Number(d.total_inodes), - free_inodes: Number(d.free_inodes), - used_inodes: Number(d.used_inodes), - inodes_usage_percent: d.inodes_usage_percent as number, - read_bytes: Number(d.read_bytes), - write_bytes: Number(d.write_bytes), - read_time: Number(d.read_time), - write_time: Number(d.write_time), - })), - net: (netMap.get(row.id) ?? []).map((n) => ({ - name: n.name as string, - bytes_sent: Number(n.bytes_sent), - bytes_recv: Number(n.bytes_recv), - packets_sent: Number(n.packets_sent), - packets_recv: Number(n.packets_recv), - err_in: Number(n.err_in), - err_out: Number(n.err_out), - drop_in: Number(n.drop_in), - drop_out: Number(n.drop_out), - fifo_in: Number(n.fifo_in), - fifo_out: Number(n.fifo_out), - })), - errors: (errorsMap.get(row.id) ?? []).map((e) => ({ - metric: (e.metrics as string[]) ?? [], - err: (e.error as string) ?? "", - })), - }; - return this.toCheckSnapshot(row, childData); - }); - } - - // Populate notifications in batch - const notifMap = await this.fetchNotificationIds(monitorIds); - for (const monitor of monitors) { - monitor.notifications = notifMap.get(monitor.id) ?? []; - } - - return monitors; - }; - - findByIds = async (monitorIds: string[]): Promise => { - if (!monitorIds.length) { - return []; - } - const result = await this.pool.query(`SELECT ${MONITOR_COLUMNS} FROM monitors WHERE id = ANY($1)`, [monitorIds]); - const monitors = result.rows.map(this.toEntity); - const notifMap = await this.fetchNotificationIds(monitorIds); - for (const monitor of monitors) { - monitor.notifications = notifMap.get(monitor.id) ?? []; - } - return monitors; - }; - - findByIdsWithChecks = async (monitorIds: string[], checksCount: number = 25): Promise => { - if (!monitorIds.length) { - return []; - } - const monitors = await this.findByIds(monitorIds); - - for (const monitor of monitors) { - const checksResult = await this.pool.query( - `SELECT * FROM checks - WHERE monitor_id = $1 - ORDER BY created_at DESC - LIMIT $2`, - [monitor.id, checksCount] - ); - monitor.recentChecks = await Promise.all( - checksResult.rows.map(async (row) => { - // Fetch child records for hardware monitors - let disk: import("@/types/index.js").CheckDiskInfo[] = []; - let net: import("@/types/index.js").CheckNetworkInterfaceInfo[] = []; - let errors: import("@/types/index.js").CheckErrorInfo[] = []; - - if (monitor.type === "hardware") { - const [diskResult, netResult, errorsResult] = await Promise.all([ - this.pool.query( - `SELECT device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, - total_inodes, free_inodes, used_inodes, inodes_usage_percent, - read_bytes, write_bytes, read_time, write_time - FROM check_disks WHERE check_id = $1`, - [row.id] - ), - this.pool.query( - `SELECT name, bytes_sent, bytes_recv, packets_sent, packets_recv, - err_in, err_out, drop_in, drop_out, fifo_in, fifo_out - FROM check_network_interfaces WHERE check_id = $1`, - [row.id] - ), - this.pool.query(`SELECT metrics, error FROM check_errors WHERE check_id = $1`, [row.id]), - ]); - disk = diskResult.rows.map((d) => ({ - device: d.device, - mountpoint: d.mountpoint, - total_bytes: Number(d.total_bytes), - free_bytes: Number(d.free_bytes), - used_bytes: Number(d.used_bytes), - usage_percent: d.usage_percent, - total_inodes: Number(d.total_inodes), - free_inodes: Number(d.free_inodes), - used_inodes: Number(d.used_inodes), - inodes_usage_percent: d.inodes_usage_percent, - read_bytes: Number(d.read_bytes), - write_bytes: Number(d.write_bytes), - read_time: Number(d.read_time), - write_time: Number(d.write_time), - })); - net = netResult.rows.map((n) => ({ - name: n.name, - bytes_sent: Number(n.bytes_sent), - bytes_recv: Number(n.bytes_recv), - packets_sent: Number(n.packets_sent), - packets_recv: Number(n.packets_recv), - err_in: Number(n.err_in), - err_out: Number(n.err_out), - drop_in: Number(n.drop_in), - drop_out: Number(n.drop_out), - fifo_in: Number(n.fifo_in), - fifo_out: Number(n.fifo_out), - })); - errors = errorsResult.rows.map((e) => ({ metric: e.metrics ?? [], err: e.error ?? "" })); - } - - return { - id: row.id, - status: row.status, - responseTime: row.response_time ?? 0, - statusCode: row.status_code ?? 0, - message: row.message ?? "", - timings: - row.timing_start !== null - ? { - start: row.timing_start, - socket: row.timing_socket, - lookup: row.timing_lookup, - connect: row.timing_connect, - secureConnect: row.timing_secure_connect, - upload: row.timing_upload, - response: row.timing_response, - end: row.timing_end, - phases: { - wait: row.phase_wait, - dns: row.phase_dns, - tcp: row.phase_tcp, - tls: row.phase_tls, - request: row.phase_request, - firstByte: row.phase_first_byte, - download: row.phase_download, - total: row.phase_total, - }, - } - : undefined, - cpu: - row.cpu_usage_percent !== null - ? { - physical_core: row.cpu_physical_core, - logical_core: row.cpu_logical_core, - frequency: row.cpu_frequency, - current_frequency: row.cpu_current_frequency, - temperature: row.cpu_temperature ?? [], - free_percent: row.cpu_free_percent, - usage_percent: row.cpu_usage_percent, - } - : undefined, - memory: - row.mem_usage_percent !== null - ? { - total_bytes: Number(row.mem_total_bytes), - available_bytes: Number(row.mem_available_bytes), - used_bytes: Number(row.mem_used_bytes), - usage_percent: row.mem_usage_percent, - } - : undefined, - disk, - host: - row.host_os !== null - ? { - os: row.host_os, - platform: row.host_platform, - kernel_version: row.host_kernel_version, - pretty_name: row.host_pretty_name, - } - : undefined, - errors, - capture: - row.capture_version !== null - ? { - version: row.capture_version, - mode: row.capture_mode, - } - : undefined, - net, - performance: row.lighthouse_performance ?? undefined, - accessibility: row.lighthouse_accessibility ?? undefined, - bestPractices: row.lighthouse_best_practices ?? undefined, - seo: row.lighthouse_seo ?? undefined, - audits: - row.audit_cls_score !== null - ? { - cls: { score: row.audit_cls_score, numericValue: row.audit_cls_value, displayValue: row.audit_cls_display }, - si: { score: row.audit_si_score, numericValue: row.audit_si_value, displayValue: row.audit_si_display }, - fcp: { score: row.audit_fcp_score, numericValue: row.audit_fcp_value, displayValue: row.audit_fcp_display }, - lcp: { score: row.audit_lcp_score, numericValue: row.audit_lcp_value, displayValue: row.audit_lcp_display }, - tbt: { score: row.audit_tbt_score, numericValue: row.audit_tbt_value, displayValue: row.audit_tbt_display }, - } - : undefined, - createdAt: row.created_at.toISOString(), - }; - }) - ); - - const maintResult = await this.pool.query( - `SELECT 1 FROM maintenance_windows - WHERE monitor_id = $1 AND active = TRUE AND start_time <= NOW() AND end_time >= NOW() - LIMIT 1`, - [monitor.id] - ); - if ((maintResult.rowCount ?? 0) > 0) { - monitor.status = "maintenance"; - } - - const statsResult = await this.pool.query(`SELECT uptime_percentage FROM monitor_stats WHERE monitor_id = $1`, [monitor.id]); - if (statsResult.rows[0]) { - monitor.uptimePercentage = statsResult.rows[0].uptime_percentage; - } - } - - return monitors; - }; - - findMonitorCountByTeamIdAndType = async (teamId: string, config?: TeamQueryConfig): Promise => { - const { type } = config ?? {}; - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - let paramIndex = 2; - - if (type !== undefined) { - if (Array.isArray(type)) { - conditions.push(`type = ANY($${paramIndex++})`); - values.push(type); - } else { - conditions.push(`type = $${paramIndex++}`); - values.push(type); - } - } - - const result = await this.pool.query(`SELECT COUNT(*)::int AS count FROM monitors WHERE ${conditions.join(" AND ")}`, values); - return result.rows[0].count; - }; - - updateById = async (monitorId: string, teamId: string, patch: Partial): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - const fieldMap: [keyof Monitor, string][] = [ - ["name", "name"], - ["description", "description"], - ["type", "type"], - ["status", "status"], - ["url", "url"], - ["port", "port"], - ["ignoreTlsErrors", "ignore_tls_errors"], - ["useAdvancedMatching", "use_advanced_matching"], - ["jsonPath", "json_path"], - ["expectedValue", "expected_value"], - ["matchMethod", "match_method"], - ["secret", "secret"], - ["interval", "interval_ms"], - ["isActive", "is_active"], - ["statusWindow", "status_window"], - ["statusWindowSize", "status_window_size"], - ["statusWindowThreshold", "status_window_threshold"], - ["uptimePercentage", "uptime_percentage"], - ["cpuAlertThreshold", "cpu_alert_threshold"], - ["cpuAlertCounter", "cpu_alert_counter"], - ["memoryAlertThreshold", "memory_alert_threshold"], - ["memoryAlertCounter", "memory_alert_counter"], - ["diskAlertThreshold", "disk_alert_threshold"], - ["diskAlertCounter", "disk_alert_counter"], - ["tempAlertThreshold", "temp_alert_threshold"], - ["tempAlertCounter", "temp_alert_counter"], - ["selectedDisks", "selected_disks"], - ["gameId", "game_id"], - ["grpcServiceName", "grpc_service_name"], - ["strategy", "strategy"], - ["group", "monitor_group"], - ["geoCheckEnabled", "geo_check_enabled"], - ["geoCheckLocations", "geo_check_locations"], - ["geoCheckInterval", "geo_check_interval_ms"], - ]; - - for (const [key, column] of fieldMap) { - if (patch[key] !== undefined) { - sets.push(`${column} = $${paramIndex++}`); - // Empty string matchMethod is stored as NULL (not a valid enum value) - values.push(key === "matchMethod" && patch[key] === "" ? null : patch[key]); - } - } - - if (sets.length === 0) { - return this.findById(monitorId, teamId); - } - - sets.push(`updated_at = NOW()`); - values.push(monitorId, teamId); - - const result = await this.pool.query( - `UPDATE monitors SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex} - RETURNING ${MONITOR_COLUMNS}`, - values - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: `Failed to update monitor with id ${monitorId}`, status: 500 }); - } - - // Update notification associations if provided - if (patch.notifications !== undefined) { - await this.pool.query(`DELETE FROM monitor_notifications WHERE monitor_id = $1`, [monitorId]); - for (const notificationId of patch.notifications) { - await this.pool.query(`INSERT INTO monitor_notifications (monitor_id, notification_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [ - monitorId, - notificationId, - ]); - } - } - - const entity = this.toEntity(row); - entity.notifications = patch.notifications ?? (await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? [])); - return entity; - }; - - togglePauseById = async (monitorId: string, teamId: string): Promise => { - const result = await this.pool.query( - `UPDATE monitors SET - is_active = NOT is_active, - status = CASE WHEN status = 'paused' THEN 'initializing'::monitor_status ELSE 'paused'::monitor_status END, - updated_at = NOW() - WHERE id = $1 AND team_id = $2 - RETURNING ${MONITOR_COLUMNS}`, - [monitorId, teamId] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: `Monitor with ID ${monitorId} not found for the given team.`, status: 404 }); - } - const entity = this.toEntity(row); - entity.notifications = await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? []); - return entity; - }; - - deleteById = async (monitorId: string, teamId: string): Promise => { - // Fetch notifications before delete (FK cascade will remove join rows) - const notifs = await this.fetchNotificationIds([monitorId]).then((m) => m.get(monitorId) ?? []); - const result = await this.pool.query(`DELETE FROM monitors WHERE id = $1 AND team_id = $2 RETURNING ${MONITOR_COLUMNS}`, [ - monitorId, - teamId, - ]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: `Monitor with ID ${monitorId} not found for the given team.`, status: 404 }); - } - const entity = this.toEntity(row); - entity.notifications = notifs; - return entity; - }; - - deleteByTeamId = async (teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }> => { - // Fetch notifications before delete - const monitorsResult = await this.pool.query(`SELECT ${MONITOR_COLUMNS} FROM monitors WHERE team_id = $1`, [teamId]); - const monitorIds = monitorsResult.rows.map((r) => r.id); - const notifMap = monitorIds.length > 0 ? await this.fetchNotificationIds(monitorIds) : new Map(); - - const result = await this.pool.query(`DELETE FROM monitors WHERE team_id = $1 RETURNING ${MONITOR_COLUMNS}`, [teamId]); - const monitors = result.rows.map((row) => { - const entity = this.toEntity(row); - entity.notifications = notifMap.get(row.id) ?? []; - return entity; - }); - return { monitors, deletedCount: result.rowCount ?? 0 }; - }; - - findMonitorsSummaryByTeamId = async (teamId: string, config?: SummaryConfig): Promise => { - const conditions: string[] = ["team_id = $1"]; - const values: unknown[] = [teamId]; - let paramIndex = 2; - - if (config?.type !== undefined) { - if (Array.isArray(config.type)) { - conditions.push(`type = ANY($${paramIndex++})`); - values.push(config.type); - } else { - conditions.push(`type = $${paramIndex++}`); - values.push(config.type); - } - } - - const result = await this.pool.query( - `SELECT - COUNT(*)::int AS "totalMonitors", - COUNT(*) FILTER (WHERE status = 'up')::int AS "upMonitors", - COUNT(*) FILTER (WHERE status = 'down')::int AS "downMonitors", - COUNT(*) FILTER (WHERE status = 'paused')::int AS "pausedMonitors", - COUNT(*) FILTER (WHERE status = 'initializing')::int AS "initializingMonitors", - COUNT(*) FILTER (WHERE status = 'maintenance')::int AS "maintenanceMonitors", - COUNT(*) FILTER (WHERE status = 'breached')::int AS "breachedMonitors" - FROM monitors WHERE ${conditions.join(" AND ")}`, - values - ); - - return ( - result.rows[0] ?? { - totalMonitors: 0, - upMonitors: 0, - downMonitors: 0, - pausedMonitors: 0, - initializingMonitors: 0, - maintenanceMonitors: 0, - breachedMonitors: 0, - } - ); - }; - - findGroupsByTeamId = async (teamId: string): Promise => { - const result = await this.pool.query( - `SELECT DISTINCT monitor_group FROM monitors - WHERE team_id = $1 AND monitor_group IS NOT NULL AND monitor_group != '' - ORDER BY monitor_group`, - [teamId] - ); - return result.rows.map((row) => row.monitor_group); - }; - - removeNotificationFromMonitors = async (notificationId: string): Promise => { - await this.pool.query(`DELETE FROM monitor_notifications WHERE notification_id = $1`, [notificationId]); - }; - - updateNotifications = async ( - teamId: string, - monitorIds: string[], - notificationIds: string[], - action: "add" | "remove" | "set" - ): Promise => { - if (!monitorIds.length) { - return 0; - } - - // Verify monitors belong to team - const monitorCheck = await this.pool.query(`SELECT id FROM monitors WHERE id = ANY($1) AND team_id = $2`, [monitorIds, teamId]); - const validMonitorIds = monitorCheck.rows.map((row) => row.id); - - if (!validMonitorIds.length) { - return 0; - } - - let modified = 0; - - switch (action) { - case "set": { - // Track which monitors actually change - const existingSet = await this.pool.query(`SELECT monitor_id, notification_id FROM monitor_notifications WHERE monitor_id = ANY($1)`, [ - validMonitorIds, - ]); - const existingByMonitor = new Map>(); - for (const row of existingSet.rows) { - if (!existingByMonitor.has(row.monitor_id)) existingByMonitor.set(row.monitor_id, new Set()); - existingByMonitor.get(row.monitor_id)!.add(row.notification_id); - } - - await this.pool.query(`DELETE FROM monitor_notifications WHERE monitor_id = ANY($1)`, [validMonitorIds]); - for (const monitorId of validMonitorIds) { - const existing = existingByMonitor.get(monitorId); - const newSet = new Set(notificationIds); - const changed = !existing || existing.size !== newSet.size || [...newSet].some((id) => !existing.has(id)); - if (changed) modified++; - - for (const notificationId of notificationIds) { - await this.pool.query( - `INSERT INTO monitor_notifications (monitor_id, notification_id) - VALUES ($1, $2) - ON CONFLICT DO NOTHING`, - [monitorId, notificationId] - ); - } - } - break; - } - case "add": { - // Count monitors where at least one new notification was added - const monitorsModified = new Set(); - for (const monitorId of validMonitorIds) { - for (const notificationId of notificationIds) { - const res = await this.pool.query( - `INSERT INTO monitor_notifications (monitor_id, notification_id) - VALUES ($1, $2) - ON CONFLICT DO NOTHING`, - [monitorId, notificationId] - ); - if ((res.rowCount ?? 0) > 0) { - monitorsModified.add(monitorId); - } - } - } - modified = monitorsModified.size; - break; - } - case "remove": { - // Count distinct monitors that will be affected before deleting - const affectedResult = await this.pool.query( - `SELECT COUNT(DISTINCT monitor_id)::int AS count FROM monitor_notifications - WHERE monitor_id = ANY($1) AND notification_id = ANY($2)`, - [validMonitorIds, notificationIds] - ); - modified = affectedResult.rows[0]?.count ?? 0; - await this.pool.query( - `DELETE FROM monitor_notifications - WHERE monitor_id = ANY($1) AND notification_id = ANY($2)`, - [validMonitorIds, notificationIds] - ); - break; - } - default: - throw new AppError({ message: `Invalid action: ${action}`, status: 400 }); - } - - return modified; - }; - - deleteByTeamIdsNotIn = async (teamIds: string[]): Promise => { - if (!teamIds.length) { - const result = await this.pool.query(`DELETE FROM monitors`); - return result.rowCount ?? 0; - } - const result = await this.pool.query(`DELETE FROM monitors WHERE team_id != ALL($1)`, [teamIds]); - return result.rowCount ?? 0; - }; - - findAllMonitorIds = async (): Promise => { - const result = await this.pool.query(`SELECT id FROM monitors`); - return result.rows.map((row) => row.id); - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private toCheckSnapshot = (row: any, childData?: { disk: any[]; net: any[]; errors: any[] }) => ({ - id: row.id, - status: row.status, - responseTime: row.response_time ?? 0, - statusCode: row.status_code ?? 0, - message: row.message ?? "", - timings: - row.timing_start !== null - ? { - start: row.timing_start, - socket: row.timing_socket, - lookup: row.timing_lookup, - connect: row.timing_connect, - secureConnect: row.timing_secure_connect, - upload: row.timing_upload, - response: row.timing_response, - end: row.timing_end, - phases: { - wait: row.phase_wait, - dns: row.phase_dns, - tcp: row.phase_tcp, - tls: row.phase_tls, - request: row.phase_request, - firstByte: row.phase_first_byte, - download: row.phase_download, - total: row.phase_total, - }, - } - : undefined, - cpu: - row.cpu_usage_percent !== null - ? { - physical_core: row.cpu_physical_core, - logical_core: row.cpu_logical_core, - frequency: row.cpu_frequency, - current_frequency: row.cpu_current_frequency, - temperature: row.cpu_temperature ?? [], - free_percent: row.cpu_free_percent, - usage_percent: row.cpu_usage_percent, - } - : undefined, - memory: - row.mem_usage_percent !== null - ? { - total_bytes: Number(row.mem_total_bytes), - available_bytes: Number(row.mem_available_bytes), - used_bytes: Number(row.mem_used_bytes), - usage_percent: row.mem_usage_percent, - } - : undefined, - disk: childData?.disk ?? [], - host: - row.host_os !== null - ? { - os: row.host_os, - platform: row.host_platform, - kernel_version: row.host_kernel_version, - pretty_name: row.host_pretty_name, - } - : undefined, - errors: childData?.errors ?? [], - capture: - row.capture_version !== null - ? { - version: row.capture_version, - mode: row.capture_mode, - } - : undefined, - net: childData?.net ?? [], - performance: row.lighthouse_performance ?? undefined, - accessibility: row.lighthouse_accessibility ?? undefined, - bestPractices: row.lighthouse_best_practices ?? undefined, - seo: row.lighthouse_seo ?? undefined, - audits: - row.audit_cls_score !== null - ? { - cls: { score: row.audit_cls_score, numericValue: row.audit_cls_value, displayValue: row.audit_cls_display }, - si: { score: row.audit_si_score, numericValue: row.audit_si_value, displayValue: row.audit_si_display }, - fcp: { score: row.audit_fcp_score, numericValue: row.audit_fcp_value, displayValue: row.audit_fcp_display }, - lcp: { score: row.audit_lcp_score, numericValue: row.audit_lcp_value, displayValue: row.audit_lcp_display }, - tbt: { score: row.audit_tbt_score, numericValue: row.audit_tbt_value, displayValue: row.audit_tbt_display }, - } - : undefined, - createdAt: row.created_at.toISOString(), - }); - - private fetchNotificationIds = async (monitorIds: string[]): Promise> => { - const result = await this.pool.query(`SELECT monitor_id, notification_id FROM monitor_notifications WHERE monitor_id = ANY($1)`, [monitorIds]); - const map = new Map(); - for (const row of result.rows) { - if (!map.has(row.monitor_id)) map.set(row.monitor_id, []); - map.get(row.monitor_id)!.push(row.notification_id); - } - return map; - }; - - private fetchCheckChildData = async (checkId: string) => { - const [diskResult, netResult, errorsResult] = await Promise.all([ - this.pool.query( - `SELECT device, mountpoint, total_bytes, free_bytes, used_bytes, usage_percent, - total_inodes, free_inodes, used_inodes, inodes_usage_percent, - read_bytes, write_bytes, read_time, write_time - FROM check_disks WHERE check_id = $1`, - [checkId] - ), - this.pool.query( - `SELECT name, bytes_sent, bytes_recv, packets_sent, packets_recv, - err_in, err_out, drop_in, drop_out, fifo_in, fifo_out - FROM check_network_interfaces WHERE check_id = $1`, - [checkId] - ), - this.pool.query(`SELECT metrics, error FROM check_errors WHERE check_id = $1`, [checkId]), - ]); - return { - disk: diskResult.rows.map((d) => ({ - device: d.device, - mountpoint: d.mountpoint, - total_bytes: Number(d.total_bytes), - free_bytes: Number(d.free_bytes), - used_bytes: Number(d.used_bytes), - usage_percent: d.usage_percent, - total_inodes: Number(d.total_inodes), - free_inodes: Number(d.free_inodes), - used_inodes: Number(d.used_inodes), - inodes_usage_percent: d.inodes_usage_percent, - read_bytes: Number(d.read_bytes), - write_bytes: Number(d.write_bytes), - read_time: Number(d.read_time), - write_time: Number(d.write_time), - })), - net: netResult.rows.map((n) => ({ - name: n.name, - bytes_sent: Number(n.bytes_sent), - bytes_recv: Number(n.bytes_recv), - packets_sent: Number(n.packets_sent), - packets_recv: Number(n.packets_recv), - err_in: Number(n.err_in), - err_out: Number(n.err_out), - drop_in: Number(n.drop_in), - drop_out: Number(n.drop_out), - fifo_in: Number(n.fifo_in), - fifo_out: Number(n.fifo_out), - })), - errors: errorsResult.rows.map((e) => ({ metric: e.metrics ?? [], err: e.error ?? "" })), - }; - }; - - private toEntity = (row: MonitorRow): Monitor => ({ - id: row.id, - userId: row.user_id, - teamId: row.team_id, - name: row.name, - description: row.description ?? undefined, - status: row.status as MonitorStatus, - statusWindow: row.status_window ?? [], - statusWindowSize: row.status_window_size, - statusWindowThreshold: row.status_window_threshold, - type: row.type, - ignoreTlsErrors: row.ignore_tls_errors, - useAdvancedMatching: row.use_advanced_matching, - jsonPath: row.json_path ?? undefined, - expectedValue: row.expected_value ?? undefined, - matchMethod: row.match_method ?? undefined, - url: row.url ?? "", - port: row.port ?? undefined, - isActive: row.is_active, - interval: row.interval_ms, - uptimePercentage: row.uptime_percentage ?? undefined, - notifications: [], - secret: row.secret ?? undefined, - cpuAlertThreshold: row.cpu_alert_threshold, - cpuAlertCounter: row.cpu_alert_counter, - memoryAlertThreshold: row.memory_alert_threshold, - memoryAlertCounter: row.memory_alert_counter, - diskAlertThreshold: row.disk_alert_threshold, - diskAlertCounter: row.disk_alert_counter, - tempAlertThreshold: row.temp_alert_threshold, - tempAlertCounter: row.temp_alert_counter, - selectedDisks: row.selected_disks ?? [], - gameId: row.game_id ?? undefined, - grpcServiceName: row.grpc_service_name ?? undefined, - strategy: row.strategy ?? undefined, - group: row.monitor_group, - geoCheckEnabled: row.geo_check_enabled, - geoCheckLocations: row.geo_check_locations ?? [], - geoCheckInterval: row.geo_check_interval_ms, - recentChecks: [], - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/repositories/notifications/MongoNotificationsRepository.ts index 3c4225b50..5303aa6ab 100644 --- a/server/src/repositories/notifications/MongoNotificationsRepository.ts +++ b/server/src/repositories/notifications/MongoNotificationsRepository.ts @@ -32,6 +32,8 @@ class MongoNotificationsRepository implements INotificationsRepository { homeserverUrl: doc.homeserverUrl ?? undefined, roomId: doc.roomId ?? undefined, accessToken: doc.accessToken ?? undefined, + accountSid: doc.accountSid ?? undefined, + twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/repositories/notifications/TimescaleNotificationsRepository.ts b/server/src/repositories/notifications/TimescaleNotificationsRepository.ts deleted file mode 100644 index b2c369c86..000000000 --- a/server/src/repositories/notifications/TimescaleNotificationsRepository.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { Pool } from "pg"; -import { INotificationsRepository } from "@/repositories/notifications/INotificationsRepository.js"; -import type { Notification, NotificationChannel } from "@/types/notification.js"; -import { AppError } from "@/utils/AppError.js"; - -interface NotificationRow { - id: string; - user_id: string; - team_id: string; - type: NotificationChannel; - notification_name: string; - address: string | null; - phone: string | null; - homeserver_url: string | null; - room_id: string | null; - access_token: string | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, user_id, team_id, type, notification_name, address, phone, - homeserver_url, room_id, access_token, created_at, updated_at`; - -export class TimescaleNotificationsRepository implements INotificationsRepository { - constructor(private pool: Pool) {} - - create = async (data: Partial): Promise => { - const result = await this.pool.query( - `INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING ${COLUMNS}`, - [ - data.userId, - data.teamId, - data.type, - data.notificationName, - data.address ?? null, - data.phone ?? null, - data.homeserverUrl ?? null, - data.roomId ?? null, - data.accessToken ?? null, - ] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create notification", status: 500 }); - } - return this.toEntity(row); - }; - - findById = async (id: string, teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM notifications WHERE id = $1 AND team_id = $2`, [id, teamId]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Notification not found", status: 404 }); - } - return this.toEntity(row); - }; - - findNotificationsByIds = async (ids: string[]): Promise => { - if (!ids.length) { - return []; - } - const result = await this.pool.query(`SELECT ${COLUMNS} FROM notifications WHERE id = ANY($1)`, [ids]); - return result.rows.map(this.toEntity); - }; - - findByTeamId = async (teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM notifications WHERE team_id = $1`, [teamId]); - return result.rows.map(this.toEntity); - }; - - updateById = async (id: string, teamId: string, patch: Partial): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - const fieldMap: [keyof Notification, string][] = [ - ["type", "type"], - ["notificationName", "notification_name"], - ["address", "address"], - ["phone", "phone"], - ["homeserverUrl", "homeserver_url"], - ["roomId", "room_id"], - ["accessToken", "access_token"], - ]; - - for (const [key, column] of fieldMap) { - if (patch[key] !== undefined) { - sets.push(`${column} = $${paramIndex++}`); - values.push(patch[key]); - } - } - - if (sets.length === 0) { - return this.findById(id, teamId); - } - - sets.push(`updated_at = NOW()`); - values.push(id, teamId); - - const result = await this.pool.query( - `UPDATE notifications SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex} - RETURNING ${COLUMNS}`, - values - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Notification not found or could not be updated", status: 404 }); - } - return this.toEntity(row); - }; - - deleteById = async (id: string, teamId: string): Promise => { - const result = await this.pool.query(`DELETE FROM notifications WHERE id = $1 AND team_id = $2 RETURNING ${COLUMNS}`, [ - id, - teamId, - ]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Notification not found or could not be deleted", status: 404 }); - } - return this.toEntity(row); - }; - - private toEntity = (row: NotificationRow): Notification => ({ - id: row.id, - userId: row.user_id, - teamId: row.team_id, - type: row.type, - notificationName: row.notification_name, - address: row.address ?? undefined, - phone: row.phone ?? undefined, - homeserverUrl: row.homeserver_url ?? undefined, - roomId: row.room_id ?? undefined, - accessToken: row.access_token ?? undefined, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.ts b/server/src/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.ts deleted file mode 100644 index 4c5f0fe6a..000000000 --- a/server/src/repositories/recovery-tokens/TimescaleRecoveryTokensRepository.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Pool } from "pg"; -import crypto from "crypto"; -import type { IRecoveryTokensRepository } from "./IRecoveryTokensRepository.js"; -import type { RecoveryToken } from "@/types/recoveryToken.js"; -import { AppError } from "@/utils/AppError.js"; - -interface RecoveryTokenRow { - id: string; - email: string; - token: string; - expiry: Date | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, email, token, expiry, created_at, updated_at`; - -export class TimescaleRecoveryTokensRepository implements IRecoveryTokensRepository { - constructor(private pool: Pool) {} - - create = async (email: string): Promise => { - const token = crypto.randomBytes(32).toString("hex"); - const result = await this.pool.query( - `INSERT INTO recovery_tokens (email, token) - VALUES ($1, $2) - ON CONFLICT (email) DO UPDATE SET token = $2, updated_at = NOW() - RETURNING ${COLUMNS}`, - [email, token] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create recovery token", status: 500 }); - } - return this.toEntity(row); - }; - - findByToken = async (token: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM recovery_tokens WHERE token = $1`, [token]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Recovery token not found", status: 404 }); - } - return this.toEntity(row); - }; - - deleteManyByEmail = async (email: string): Promise => { - const result = await this.pool.query(`DELETE FROM recovery_tokens WHERE email = $1`, [email]); - return result.rowCount ?? 0; - }; - - private toEntity = (row: RecoveryTokenRow): RecoveryToken => ({ - id: row.id, - email: row.email, - token: row.token, - expiry: row.expiry ? row.expiry.toISOString() : new Date(0).toISOString(), - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/settings/TimescaleSettingsRepository.ts b/server/src/repositories/settings/TimescaleSettingsRepository.ts deleted file mode 100644 index 82038b23f..000000000 --- a/server/src/repositories/settings/TimescaleSettingsRepository.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { Pool } from "pg"; -import type { ISettingsRepository } from "./ISettingsRepository.js"; -import type { Settings, SettingsUpdate } from "@/types/settings.js"; - -interface SettingsRow { - id: string; - check_ttl: number; - language: string | null; - jwt_secret: string | null; - pagespeed_api_key: string | null; - system_email_host: string | null; - system_email_port: number | null; - system_email_address: string | null; - system_email_password: string | null; - system_email_user: string | null; - system_email_connection_host: string | null; - system_email_tls_servername: string | null; - system_email_secure: boolean | null; - system_email_pool: boolean | null; - system_email_ignore_tls: boolean | null; - system_email_require_tls: boolean | null; - system_email_reject_unauthorized: boolean | null; - show_url: boolean | null; - version: string | null; - threshold_cpu_usage: number | null; - threshold_memory_usage: number | null; - threshold_disk_usage: number | null; - threshold_temperature: number | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, check_ttl, language, jwt_secret, pagespeed_api_key, - system_email_host, system_email_port, system_email_address, system_email_password, system_email_user, - system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool, - system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized, - show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature, - created_at, updated_at`; - -export class TimescaleSettingsRepository implements ISettingsRepository { - constructor(private pool: Pool) {} - - create = async (settings: Partial): Promise => { - const result = await this.pool.query( - `INSERT INTO app_settings (check_ttl, language, jwt_secret, pagespeed_api_key, - system_email_host, system_email_port, system_email_address, system_email_password, system_email_user, - system_email_connection_host, system_email_tls_servername, system_email_secure, system_email_pool, - system_email_ignore_tls, system_email_require_tls, system_email_reject_unauthorized, - show_url, version, threshold_cpu_usage, threshold_memory_usage, threshold_disk_usage, threshold_temperature) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) - RETURNING ${COLUMNS}`, - [ - settings.checkTTL ?? 30, - settings.language ?? null, - settings.jwtSecret ?? null, - settings.pagespeedApiKey ?? null, - settings.systemEmailHost ?? null, - settings.systemEmailPort ?? null, - settings.systemEmailAddress ?? null, - settings.systemEmailPassword ?? null, - settings.systemEmailUser ?? null, - settings.systemEmailConnectionHost ?? null, - settings.systemEmailTLSServername ?? null, - settings.systemEmailSecure ?? false, - settings.systemEmailPool ?? false, - settings.systemEmailIgnoreTLS ?? false, - settings.systemEmailRequireTLS ?? false, - settings.systemEmailRejectUnauthorized ?? true, - settings.showURL ?? false, - settings.version ?? 1, - settings.globalThresholds?.cpu ?? null, - settings.globalThresholds?.memory ?? null, - settings.globalThresholds?.disk ?? null, - settings.globalThresholds?.temperature ?? null, - ] - ); - const row = result.rows[0]; - if (!row) { - throw new Error("Failed to create app settings"); - } - return this.toEntity(row); - }; - - findSingleton = async (): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM app_settings LIMIT 1`); - const row = result.rows[0]; - if (!row) { - return null; - } - return this.toEntity(row); - }; - - update = async (settings: SettingsUpdate): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - const fieldMap: [keyof SettingsUpdate, string][] = [ - ["checkTTL", "check_ttl"], - ["language", "language"], - ["jwtSecret", "jwt_secret"], - ["pagespeedApiKey", "pagespeed_api_key"], - ["systemEmailHost", "system_email_host"], - ["systemEmailPort", "system_email_port"], - ["systemEmailAddress", "system_email_address"], - ["systemEmailPassword", "system_email_password"], - ["systemEmailUser", "system_email_user"], - ["systemEmailConnectionHost", "system_email_connection_host"], - ["systemEmailTLSServername", "system_email_tls_servername"], - ["systemEmailSecure", "system_email_secure"], - ["systemEmailPool", "system_email_pool"], - ["systemEmailIgnoreTLS", "system_email_ignore_tls"], - ["systemEmailRequireTLS", "system_email_require_tls"], - ["systemEmailRejectUnauthorized", "system_email_reject_unauthorized"], - ["showURL", "show_url"], - ["version", "version"], - ]; - - for (const [key, column] of fieldMap) { - if (key in settings) { - sets.push(`${column} = $${paramIndex++}`); - // null means unset the field - values.push(settings[key] ?? null); - } - } - - // Handle globalThresholds - if ("globalThresholds" in settings) { - const thresholds = settings.globalThresholds; - if (thresholds === null || thresholds === undefined) { - sets.push(`threshold_cpu_usage = NULL`); - sets.push(`threshold_memory_usage = NULL`); - sets.push(`threshold_disk_usage = NULL`); - sets.push(`threshold_temperature = NULL`); - } else { - if ("cpu" in thresholds) { - sets.push(`threshold_cpu_usage = $${paramIndex++}`); - values.push(thresholds.cpu ?? null); - } - if ("memory" in thresholds) { - sets.push(`threshold_memory_usage = $${paramIndex++}`); - values.push(thresholds.memory ?? null); - } - if ("disk" in thresholds) { - sets.push(`threshold_disk_usage = $${paramIndex++}`); - values.push(thresholds.disk ?? null); - } - if ("temperature" in thresholds) { - sets.push(`threshold_temperature = $${paramIndex++}`); - values.push(thresholds.temperature ?? null); - } - } - } - - if (sets.length === 0) { - const existing = await this.findSingleton(); - if (!existing) { - throw new Error("App settings not found"); - } - return existing; - } - - sets.push(`updated_at = NOW()`); - - // Upsert: update the single row, or insert if none exists - const result = await this.pool.query(`UPDATE app_settings SET ${sets.join(", ")} RETURNING ${COLUMNS}`, values); - const row = result.rows[0]; - if (!row) { - // No row existed, create one - return this.create(settings as Partial); - } - return this.toEntity(row); - }; - - deleteLegacy = async (): Promise => { - // No legacy rows in TimescaleDB — singleton is enforced by trigger - const result = await this.pool.query(`DELETE FROM app_settings WHERE version IS NULL`); - return (result.rowCount ?? 0) > 0; - }; - - private toEntity = (row: SettingsRow): Settings => ({ - id: row.id, - checkTTL: row.check_ttl, - language: row.language ?? "", - jwtSecret: row.jwt_secret ?? undefined, - pagespeedApiKey: row.pagespeed_api_key ?? undefined, - systemEmailHost: row.system_email_host ?? undefined, - systemEmailPort: row.system_email_port ?? undefined, - systemEmailAddress: row.system_email_address ?? undefined, - systemEmailPassword: row.system_email_password ?? undefined, - systemEmailUser: row.system_email_user ?? undefined, - systemEmailConnectionHost: row.system_email_connection_host ?? undefined, - systemEmailTLSServername: row.system_email_tls_servername ?? undefined, - systemEmailSecure: row.system_email_secure ?? false, - systemEmailPool: row.system_email_pool ?? false, - systemEmailIgnoreTLS: row.system_email_ignore_tls ?? false, - systemEmailRequireTLS: row.system_email_require_tls ?? false, - systemEmailRejectUnauthorized: row.system_email_reject_unauthorized ?? true, - showURL: row.show_url ?? false, - singleton: true, - version: Number(row.version ?? 1), - globalThresholds: - row.threshold_cpu_usage !== null || - row.threshold_memory_usage !== null || - row.threshold_disk_usage !== null || - row.threshold_temperature !== null - ? { - cpu: row.threshold_cpu_usage ?? undefined, - memory: row.threshold_memory_usage ?? undefined, - disk: row.threshold_disk_usage ?? undefined, - temperature: row.threshold_temperature ?? undefined, - } - : undefined, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts b/server/src/repositories/status-pages/MongoStatusPagesRepository.ts index d8e942271..3f4630eb1 100644 --- a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts +++ b/server/src/repositories/status-pages/MongoStatusPagesRepository.ts @@ -59,6 +59,8 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { showAdminLoginLink: doc.showAdminLoginLink, showInfrastructure: doc.showInfrastructure, customCSS: doc.customCSS, + theme: doc.theme, + themeMode: doc.themeMode, createdAt: this.toDateString(doc.createdAt), updatedAt: this.toDateString(doc.updatedAt), }; diff --git a/server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts b/server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts deleted file mode 100644 index 583e775b5..000000000 --- a/server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { Pool } from "pg"; -import type { IStatusPagesRepository } from "./IStatusPagesRepository.js"; -import type { StatusPage, StatusPageType } from "@/types/statusPage.js"; -import { AppError } from "@/utils/AppError.js"; - -const parsePostgresArray = (value: unknown): StatusPageType[] => { - if (typeof value !== "string") return []; - const inner = value.replace(/^\{|\}$/g, ""); - if (inner === "") return []; - return inner.split(",") as StatusPageType[]; -}; - -interface StatusPageRow { - id: string; - user_id: string; - team_id: string; - types: StatusPageType[]; - company_name: string; - url: string; - timezone: string | null; - color: string; - logo_data: Buffer | null; - logo_content_type: string | null; - is_published: boolean; - show_charts: boolean; - show_uptime_percentage: boolean; - show_admin_login_link: boolean; - show_infrastructure: boolean; - custom_css: string | null; - created_at: Date; - updated_at: Date; -} - -const COLUMNS = `id, user_id, team_id, types, company_name, url, timezone, color, - logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage, - show_admin_login_link, show_infrastructure, custom_css, created_at, updated_at`; - -export class TimescaleStatusPagesRepository implements IStatusPagesRepository { - constructor(private pool: Pool) {} - - create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { - const result = await this.pool.query( - `INSERT INTO status_pages (user_id, team_id, types, company_name, url, timezone, color, - logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage, - show_admin_login_link, show_infrastructure, custom_css) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) - RETURNING ${COLUMNS}`, - [ - userId, - teamId, - data.type ?? ["uptime"], - data.companyName ?? "", - data.url ?? "", - data.timezone ?? null, - data.color ?? "#4169E1", - image?.buffer ?? null, - image?.mimetype ?? null, - data.isPublished ?? false, - data.showCharts ?? false, - data.showUptimePercentage ?? false, - data.showAdminLoginLink ?? false, - data.showInfrastructure ?? false, - data.customCSS ?? null, - ] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create status page", status: 500 }); - } - - // Insert monitor associations - if (data.monitors?.length) { - for (const [i, monitorId] of data.monitors.entries()) { - await this.pool.query( - `INSERT INTO status_page_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, - [row.id, monitorId, i] - ); - } - } - if (data.subMonitors?.length) { - for (const [i, monitorId] of data.subMonitors.entries()) { - await this.pool.query( - `INSERT INTO status_page_sub_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, - [row.id, monitorId, i] - ); - } - } - - return this.loadEntity(row); - }; - - findByUrl = async (url: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM status_pages WHERE url = $1`, [url]); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Status page not found", status: 404 }); - } - return this.loadEntity(row); - }; - - findByTeamId = async (teamId: string): Promise => { - const result = await this.pool.query(`SELECT ${COLUMNS} FROM status_pages WHERE team_id = $1`, [teamId]); - const pages: StatusPage[] = []; - for (const row of result.rows) { - pages.push(await this.loadEntity(row)); - } - return pages; - }; - - updateById = async ( - id: string, - teamId: string, - image: Express.Multer.File | undefined, - patch: Partial & { removeLogo?: string } - ): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - const fieldMap: [keyof StatusPage, string][] = [ - ["type", "types"], - ["companyName", "company_name"], - ["url", "url"], - ["timezone", "timezone"], - ["color", "color"], - ["isPublished", "is_published"], - ["showCharts", "show_charts"], - ["showUptimePercentage", "show_uptime_percentage"], - ["showAdminLoginLink", "show_admin_login_link"], - ["showInfrastructure", "show_infrastructure"], - ["customCSS", "custom_css"], - ]; - - for (const [key, column] of fieldMap) { - if (patch[key] !== undefined) { - sets.push(`${column} = $${paramIndex++}`); - values.push(patch[key]); - } - } - - if (image) { - sets.push(`logo_data = $${paramIndex++}`); - values.push(image.buffer); - sets.push(`logo_content_type = $${paramIndex++}`); - values.push(image.mimetype); - } else if (patch.removeLogo === "true") { - sets.push(`logo_data = NULL`); - sets.push(`logo_content_type = NULL`); - } - - if (sets.length > 0) { - sets.push(`updated_at = NOW()`); - values.push(id, teamId); - - const result = await this.pool.query( - `UPDATE status_pages SET ${sets.join(", ")} WHERE id = $${paramIndex++} AND team_id = $${paramIndex} - RETURNING ${COLUMNS}`, - values - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Status page not found", status: 404 }); - } - } - - // Update monitor associations if provided - if (patch.monitors !== undefined) { - await this.pool.query(`DELETE FROM status_page_monitors WHERE status_page_id = $1`, [id]); - for (const [i, monitorId] of patch.monitors.entries()) { - await this.pool.query( - `INSERT INTO status_page_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, - [id, monitorId, i] - ); - } - } - if (patch.subMonitors !== undefined) { - await this.pool.query(`DELETE FROM status_page_sub_monitors WHERE status_page_id = $1`, [id]); - for (const [i, monitorId] of patch.subMonitors.entries()) { - await this.pool.query( - `INSERT INTO status_page_sub_monitors (status_page_id, monitor_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, - [id, monitorId, i] - ); - } - } - - // Re-fetch to return full entity - const freshResult = await this.pool.query(`SELECT ${COLUMNS} FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]); - const freshRow = freshResult.rows[0]; - if (!freshRow) { - throw new AppError({ message: "Status page not found", status: 404 }); - } - return this.loadEntity(freshRow); - }; - - deleteById = async (id: string, teamId: string): Promise => { - // Load entity before delete (join tables cascade) - const existing = await this.pool.query(`SELECT ${COLUMNS} FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]); - const row = existing.rows[0]; - if (!row) { - throw new AppError({ message: "Status page not found", status: 404 }); - } - const entity = await this.loadEntity(row); - - await this.pool.query(`DELETE FROM status_pages WHERE id = $1 AND team_id = $2`, [id, teamId]); - return entity; - }; - - removeMonitorFromStatusPages = async (monitorId: string): Promise => { - // Return count of distinct status pages affected, matching Mongo's modifiedCount - const result = await this.pool.query( - `SELECT COUNT(DISTINCT status_page_id)::int AS count FROM ( - SELECT status_page_id FROM status_page_monitors WHERE monitor_id = $1 - UNION - SELECT status_page_id FROM status_page_sub_monitors WHERE monitor_id = $1 - ) affected`, - [monitorId] - ); - const affectedCount = result.rows[0]?.count ?? 0; - - await this.pool.query(`DELETE FROM status_page_monitors WHERE monitor_id = $1`, [monitorId]); - await this.pool.query(`DELETE FROM status_page_sub_monitors WHERE monitor_id = $1`, [monitorId]); - - return affectedCount; - }; - - private loadEntity = async (row: StatusPageRow): Promise => { - const [monitorsResult, subMonitorsResult] = await Promise.all([ - this.pool.query(`SELECT monitor_id FROM status_page_monitors WHERE status_page_id = $1 ORDER BY sort_order`, [row.id]), - this.pool.query(`SELECT monitor_id FROM status_page_sub_monitors WHERE status_page_id = $1 ORDER BY sort_order`, [row.id]), - ]); - - return { - id: row.id, - userId: row.user_id, - teamId: row.team_id, - type: Array.isArray(row.types) ? row.types : parsePostgresArray(row.types), - companyName: row.company_name, - url: row.url, - timezone: row.timezone ?? undefined, - color: row.color, - monitors: monitorsResult.rows.map((r) => r.monitor_id), - subMonitors: subMonitorsResult.rows.map((r) => r.monitor_id), - originalMonitors: [], - logo: row.logo_data - ? { - data: row.logo_data.toString("base64"), - contentType: row.logo_content_type ?? "", - } - : undefined, - isPublished: row.is_published, - showCharts: row.show_charts, - showUptimePercentage: row.show_uptime_percentage, - showAdminLoginLink: row.show_admin_login_link, - showInfrastructure: row.show_infrastructure, - customCSS: row.custom_css ?? "", - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }; - }; -} diff --git a/server/src/repositories/teams/TimescaleTeamsRepository.ts b/server/src/repositories/teams/TimescaleTeamsRepository.ts deleted file mode 100644 index be16b7790..000000000 --- a/server/src/repositories/teams/TimescaleTeamsRepository.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Pool } from "pg"; -import { ITeamsRepository } from "@/repositories/teams/ITeamsRepository.js"; -import { Team } from "@/types/team.js"; - -export class TimescaleTeamsRepository implements ITeamsRepository { - constructor(private pool: Pool) {} - - create = async (email: string): Promise => { - const result = await this.pool.query(`INSERT INTO teams (email) VALUES ($1) RETURNING id, email, created_at, updated_at`, [email]); - return this.toEntity(result.rows[0]); - }; - - findAllTeamIds = async (): Promise => { - const result = await this.pool.query(`SELECT id FROM teams`); - return result.rows.map((row) => row.id); - }; - - private toEntity = (row: { id: string; email: string; created_at: Date; updated_at: Date }): Team => ({ - id: row.id, - email: row.email, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/repositories/users/TimescaleUsersRepository.ts b/server/src/repositories/users/TimescaleUsersRepository.ts deleted file mode 100644 index 5d7da51b8..000000000 --- a/server/src/repositories/users/TimescaleUsersRepository.ts +++ /dev/null @@ -1,222 +0,0 @@ -import type { Pool } from "pg"; -import { IUsersRepository } from "./IUsersRepository.js"; -import type { User, UserRole } from "@/types/user.js"; -import { GenerateAvatarImage } from "@/utils/imageProcessing.js"; -import { ParseBoolean } from "@/utils/utils.js"; -import { AppError } from "@/utils/AppError.js"; - -const SERVICE_NAME = "TimescaleUsersRepository"; - -// pg returns custom enum arrays as strings like "{user,admin}" — parse to JS array -const parsePostgresArray = (value: unknown): UserRole[] => { - if (typeof value !== "string") return []; - const inner = value.replace(/^\{|\}$/g, ""); - if (inner === "") return []; - return inner.split(",") as UserRole[]; -}; - -interface UserRow { - id: string; - team_id: string; - first_name: string; - last_name: string; - email: string; - password: string; - avatar_image: string | null; - profile_image: Buffer | null; - profile_image_content_type: string | null; - is_active: boolean; - is_verified: boolean; - roles: UserRole[]; - check_ttl: number | null; - created_at: Date; - updated_at: Date; -} - -export class TimescaleUsersRepository implements IUsersRepository { - constructor(private pool: Pool) {} - - create = async (user: Partial, imageFile?: Express.Multer.File | null): Promise => { - let avatarImage: string | undefined; - let profileImage: Buffer | undefined; - let profileImageContentType: string | undefined; - - if (imageFile) { - profileImage = imageFile.buffer; - profileImageContentType = imageFile.mimetype; - avatarImage = await GenerateAvatarImage(imageFile); - } - - const result = await this.pool.query( - `INSERT INTO users (team_id, first_name, last_name, email, password, avatar_image, profile_image, profile_image_content_type, is_active, is_verified, roles, check_ttl) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - RETURNING id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`, - [ - user.teamId || null, - user.firstName, - user.lastName, - user.email, - user.password, - avatarImage || null, - profileImage || null, - profileImageContentType || null, - user.isActive ?? true, - user.isVerified ?? false, - user.role ?? ["user"], - user.checkTTL ?? null, - ] - ); - - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "Failed to create user", service: SERVICE_NAME, status: 500 }); - } - const entity = this.toEntity(row); - entity.password = ""; - return entity; - }; - - findByEmail = async (email: string): Promise => { - const result = await this.pool.query( - `SELECT id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at - FROM users WHERE email = $1`, - [email] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); - } - return this.toEntity(row); - }; - - findById = async (id: string): Promise => { - const result = await this.pool.query( - `SELECT id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at - FROM users WHERE id = $1`, - [id] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); - } - return this.toEntity(row); - }; - - findAll = async (): Promise => { - const result = await this.pool.query( - `SELECT id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at - FROM users` - ); - return result.rows.map(this.toEntity); - }; - - updateById = async (id: string, patch: Partial, file?: Express.Multer.File | null): Promise => { - const sets: string[] = []; - const values: unknown[] = []; - let paramIndex = 1; - - if (ParseBoolean(patch.deleteProfileImage) === true) { - sets.push(`profile_image = NULL`); - sets.push(`profile_image_content_type = NULL`); - sets.push(`avatar_image = NULL`); - } else if (file) { - const avatarImage = await GenerateAvatarImage(file); - sets.push(`profile_image = $${paramIndex++}`); - values.push(file.buffer); - sets.push(`profile_image_content_type = $${paramIndex++}`); - values.push(file.mimetype); - sets.push(`avatar_image = $${paramIndex++}`); - values.push(avatarImage); - } - - if (patch.firstName !== undefined) { - sets.push(`first_name = $${paramIndex++}`); - values.push(patch.firstName); - } - if (patch.lastName !== undefined) { - sets.push(`last_name = $${paramIndex++}`); - values.push(patch.lastName); - } - if (patch.email !== undefined) { - sets.push(`email = $${paramIndex++}`); - values.push(patch.email); - } - if (patch.password !== undefined) { - sets.push(`password = $${paramIndex++}`); - values.push(patch.password); - } - if (patch.isActive !== undefined) { - sets.push(`is_active = $${paramIndex++}`); - values.push(patch.isActive); - } - if (patch.isVerified !== undefined) { - sets.push(`is_verified = $${paramIndex++}`); - values.push(patch.isVerified); - } - if (patch.role !== undefined) { - sets.push(`roles = $${paramIndex++}`); - values.push(patch.role); - } - if (patch.teamId !== undefined) { - sets.push(`team_id = $${paramIndex++}`); - values.push(patch.teamId); - } - if (patch.checkTTL !== undefined) { - sets.push(`check_ttl = $${paramIndex++}`); - values.push(patch.checkTTL); - } - - if (sets.length === 0) { - return this.findById(id); - } - - sets.push(`updated_at = NOW()`); - values.push(id); - - const result = await this.pool.query( - `UPDATE users SET ${sets.join(", ")} WHERE id = $${paramIndex} - RETURNING id, team_id, first_name, last_name, email, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`, - values - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); - } - return this.toEntity(row); - }; - - deleteById = async (id: string): Promise => { - const result = await this.pool.query( - `DELETE FROM users WHERE id = $1 - RETURNING id, team_id, first_name, last_name, email, password, avatar_image, is_active, is_verified, roles, check_ttl, created_at, updated_at`, - [id] - ); - const row = result.rows[0]; - if (!row) { - throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 }); - } - return this.toEntity(row); - }; - - findSuperAdmin = async (): Promise => { - const result = await this.pool.query(`SELECT 1 FROM users WHERE 'superadmin' = ANY(roles) LIMIT 1`); - return (result.rowCount ?? 0) > 0; - }; - - private toEntity = (row: UserRow): User => ({ - id: row.id, - firstName: row.first_name, - lastName: row.last_name, - email: row.email, - password: row.password, - avatarImage: row.avatar_image ?? undefined, - profileImage: undefined, - isActive: row.is_active, - isVerified: row.is_verified, - role: Array.isArray(row.roles) ? row.roles : parsePostgresArray(row.roles), - teamId: row.team_id ?? "", - checkTTL: row.check_ttl ?? undefined, - createdAt: row.created_at.toISOString(), - updatedAt: row.updated_at.toISOString(), - }); -} diff --git a/server/src/routes/authRoute.ts b/server/src/routes/authRoute.ts index fd951c6b1..af6fd4266 100755 --- a/server/src/routes/authRoute.ts +++ b/server/src/routes/authRoute.ts @@ -32,8 +32,8 @@ class AuthRoutes { this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById); this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById); - this.router.patch("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser); - this.router.delete("/user", verifyJWT, this.authController.deleteUser); + this.router.patch("/user", verifyJWT, upload.single("profileImage"), isAllowed(["admin", "superadmin", "user"]), this.authController.editUser); + this.router.delete("/user", verifyJWT, isAllowed(["admin", "superadmin", "user"]), this.authController.deleteUser); } getRouter() { 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/service/business/checkService.ts b/server/src/service/business/checkService.ts index 374090709..dd2a66202 100644 --- a/server/src/service/business/checkService.ts +++ b/server/src/service/business/checkService.ts @@ -107,16 +107,16 @@ export class CheckService implements ICheckService { numericUnit: audit.numericUnit, }; }; - check.accessibility = (categories?.accessibility?.score || 0) * 100; - check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100; - check.seo = (categories?.seo?.score || 0) * 100; - check.performance = (categories?.performance?.score || 0) * 100; + check.accessibility = (categories.accessibility?.score || 0) * 100; + check.bestPractices = (categories["best-practices"]?.score || 0) * 100; + check.seo = (categories.seo?.score || 0) * 100; + check.performance = (categories.performance?.score || 0) * 100; check.audits = { - cls: mapAudit(audits?.["cumulative-layout-shift"]), - si: mapAudit(audits?.["speed-index"]), - fcp: mapAudit(audits?.["first-contentful-paint"]), - lcp: mapAudit(audits?.["largest-contentful-paint"]), - tbt: mapAudit(audits?.["total-blocking-time"]), + cls: mapAudit(audits["cumulative-layout-shift"]), + si: mapAudit(audits["speed-index"]), + fcp: mapAudit(audits["first-contentful-paint"]), + lcp: mapAudit(audits["largest-contentful-paint"]), + tbt: mapAudit(audits["total-blocking-time"]), }; } @@ -124,7 +124,7 @@ export class CheckService implements ICheckService { const hardwarePayload = payload as HardwareStatusPayload | undefined; const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {}; const errorsSource = Array.isArray(hardwarePayload?.errors) - ? hardwarePayload?.errors + ? hardwarePayload.errors : (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors; check.cpu = cpu; check.memory = memory; diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 4790f9aac..2070a8937 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -95,17 +95,14 @@ export class IncidentService implements IIncidentService { } } - if (decision.shouldResolveIncident) { - if (!activeIncident) { - return null; - } - activeIncident.status = false; - activeIncident.endTime = Date.now().toString(); - activeIncident.resolutionType = "automatic"; - return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident); + if (!decision.shouldResolveIncident || !activeIncident) { + return null; } - return null; + activeIncident.status = false; + activeIncident.endTime = Date.now().toString(); + activeIncident.resolutionType = "automatic"; + return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident); }; private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string { diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 71c9d9d90..72c8f1fe8 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, @@ -23,12 +24,14 @@ 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 { IEmailService } from "../infrastructure/emailService.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; @@ -74,6 +77,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; @@ -91,7 +95,6 @@ export class MonitorService implements IMonitorService { static SERVICE_NAME = SERVICE_NAME; private jobQueue: ISuperSimpleQueue; - private emailService: IEmailService; private logger: ILogger; private games: GamesMap; private monitorsRepository: IMonitorsRepository; @@ -103,7 +106,6 @@ export class MonitorService implements IMonitorService { constructor({ jobQueue, - emailService, logger, games, monitorsRepository, @@ -114,7 +116,6 @@ export class MonitorService implements IMonitorService { incidentsRepository, }: { jobQueue: ISuperSimpleQueue; - emailService: IEmailService; logger: ILogger; games: GamesMap; monitorsRepository: IMonitorsRepository; @@ -125,7 +126,6 @@ export class MonitorService implements IMonitorService { incidentsRepository: IIncidentsRepository; }) { this.jobQueue = jobQueue; - this.emailService = emailService; this.logger = logger; this.games = games; this.monitorsRepository = monitorsRepository; @@ -176,7 +176,7 @@ export class MonitorService implements IMonitorService { createMonitors = async (monitors: Array): Promise => { const createdMonitors = await this.monitorsRepository.createMonitors(monitors); - if (!monitors || monitors.length === 0) { + if (!createdMonitors || createdMonitors.length === 0) { throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" }); } @@ -185,8 +185,7 @@ export class MonitorService implements IMonitorService { }; addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise => { - const demoData = demoMonitorsData; - const monitors = demoData.map((monitor) => ({ + const monitors = demoMonitorsData.map((monitor) => ({ userId, teamId, name: monitor.name, @@ -214,22 +213,14 @@ export class MonitorService implements IMonitorService { if (!monitor) { throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 }); } - const rangeKey = (dateRange as DateRangeKey) ?? "recent"; + const rangeKey = dateRange as DateRangeKey; const { start, end } = this.getDateRange(rangeKey); const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), { type: monitor.type, }); 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 }); } @@ -263,7 +254,7 @@ export class MonitorService implements IMonitorService { throw new AppError({ message: `${monitor.type} monitors are not supported for hardware details`, status: 400 }); } - const rangeKey = (dateRange as DateRangeKey) ?? "recent"; + const rangeKey = dateRange as DateRangeKey; const { start, end } = this.getDateRange(rangeKey); const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), { type: monitor.type, @@ -305,7 +296,7 @@ export class MonitorService implements IMonitorService { throw new AppError({ message: `${monitor.type} monitors are not supported for pagespeed details`, status: 400 }); } - const rangeKey = (dateRange as DateRangeKey) ?? "recent"; + const rangeKey = dateRange as DateRangeKey; const { start, end } = this.getDateRange(rangeKey); const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, start, end, this.getDateFormat(rangeKey), { type: monitor.type, @@ -346,7 +337,7 @@ export class MonitorService implements IMonitorService { return { groupedGeoChecks: [] }; } - const rangeKey = (dateRange as DateRangeKey) ?? "recent"; + const rangeKey = dateRange as DateRangeKey; const { start, end } = this.getDateRange(rangeKey); const groupedGeoChecks = await this.geoChecksRepository.findGroupedByMonitorIdAndDateRange( monitor.id, @@ -360,8 +351,7 @@ export class MonitorService implements IMonitorService { }; getMonitorById = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => { - const monitor = await this.monitorsRepository.findById(monitorId, teamId); - return monitor; + return await this.monitorsRepository.findById(monitorId, teamId); }; getMonitorsByTeamId = async ({ @@ -373,11 +363,7 @@ export class MonitorService implements IMonitorService { type?: MonitorType | MonitorType[]; filter?: string; }): Promise => { - const monitors = await this.monitorsRepository.findByTeamId(teamId, { - type, - filter, - }); - return monitors; + return await this.monitorsRepository.findByTeamId(teamId, { type, filter }); }; getMonitorsWithChecksByTeamId = async ({ @@ -432,8 +418,7 @@ export class MonitorService implements IMonitorService { }; getGroupsByTeamId = async ({ teamId }: { teamId: string }): Promise => { - const groups = await this.monitorsRepository.findGroupsByTeamId(teamId); - return groups; + return await this.monitorsRepository.findGroupsByTeamId(teamId); }; editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial }) => { @@ -474,6 +459,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) => { @@ -574,15 +596,6 @@ export class MonitorService implements IMonitorService { const createdMonitors = await this.createMonitors(cleanedMonitors); - if (!createdMonitors || createdMonitors.length === 0) { - throw new AppError({ - message: "Failed to import any monitors. Please check the file format and try again.", - service: SERVICE_NAME, - method: "importMonitorsFromJSON", - status: 400, - }); - } - - return { imported: createdMonitors.length, errors }; + return { imported: createdMonitors!.length, errors }; }; } diff --git a/server/src/service/business/statusPageService.ts b/server/src/service/business/statusPageService.ts index 971f6897f..179461f25 100644 --- a/server/src/service/business/statusPageService.ts +++ b/server/src/service/business/statusPageService.ts @@ -1,5 +1,7 @@ import { type IStatusPagesRepository } from "@/repositories/index.js"; -import { StatusPage } from "@/types/index.js"; +import { ISettingsService } from "@/service/system/settingsService.js"; +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/types/index.js"; + export interface IStatusPageService { createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; getStatusPageByUrl(url: string): Promise; @@ -11,29 +13,52 @@ export interface IStatusPageService { export class StatusPageService implements IStatusPageService { private statusPagesRepository: IStatusPagesRepository; - constructor(statusPagesRepository: IStatusPagesRepository) { + private settingsService: ISettingsService; + constructor(statusPagesRepository: IStatusPagesRepository, settingsService: ISettingsService) { this.statusPagesRepository = statusPagesRepository; + this.settingsService = settingsService; } + private withoutThemeFields = (data: Partial): Partial => { + const { theme: _theme, themeMode: _themeMode, ...rest } = data; + return rest; + }; + + private applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({ + ...statusPage, + theme: DEFAULT_STATUS_PAGE_THEME, + themeMode: DEFAULT_STATUS_PAGE_THEME_MODE, + }); + + private normalizeTheme = (statusPage: StatusPage): StatusPage => + this.settingsService.areStatusPageThemesEnabled() ? statusPage : this.applyDefaultTheme(statusPage); + + private normalizeInput = (data: Partial): Partial => + this.settingsService.areStatusPageThemesEnabled() ? data : this.withoutThemeFields(data); + createStatusPage = async ( userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial ): Promise => { - return await this.statusPagesRepository.create(userId, teamId, image, data); + const created = await this.statusPagesRepository.create(userId, teamId, image, this.normalizeInput(data)); + return this.normalizeTheme(created); }; getStatusPageByUrl = async (url: string): Promise => { - return await this.statusPagesRepository.findByUrl(url); + const statusPage = await this.statusPagesRepository.findByUrl(url); + return this.normalizeTheme(statusPage); }; getStatusPagesByTeamId = async (teamId: string): Promise => { - return await this.statusPagesRepository.findByTeamId(teamId); + const statusPages = await this.statusPagesRepository.findByTeamId(teamId); + return statusPages.map((sp) => this.normalizeTheme(sp)); }; updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { - return await this.statusPagesRepository.updateById(id, teamId, image, data); + const updated = await this.statusPagesRepository.updateById(id, teamId, image, this.normalizeInput(data)); + return this.normalizeTheme(updated); }; deleteStatusPage = async (statusPageId: string, teamId: string): Promise => { diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index 48ee5cfbf..7acca5742 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -110,10 +110,7 @@ export class UserService implements IUserService { } issueToken = (payload: Partial, appSettings: EnvConfig) => { - const tokenTTL = appSettings?.jwtTTL ?? "2h"; - const tokenSecret = appSettings?.jwtSecret; - const payloadData = payload; - return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL }); + return this.jwt.sign(payload, appSettings.jwtSecret, { expiresIn: appSettings.jwtTTL }); }; registerUser = async (user: Partial, inviteToken: string, file: Express.Multer.File | null) => { @@ -253,14 +250,10 @@ export class UserService implements IUserService { currentUserEmail: string ) => { // Change Password check - if (updates?.password && updates?.newPassword) { - // Get user's email - // Add user email to body for DB operation + if (updates.password && updates.newPassword) { updates.email = currentUserEmail; - // Get user const user = await this.usersRepository.findByEmail(currentUserEmail); - // Compare passwords - const match = await bcrypt.compare(updates?.password, user.password); + const match = await bcrypt.compare(updates.password, user.password); // If not a match, throw a 403 // 403 instead of 401 to avoid triggering axios interceptor if (!match) { @@ -286,7 +279,7 @@ export class UserService implements IUserService { const recoveryToken = await this.recoveryTokensRepository.create(email); const name = user.firstName; const settings = this.settingsService.getSettings(); - const url = `${settings.clientHost!}/set-new-password/${recoveryToken.token}`; + const url = `${settings.clientHost}/set-new-password/${recoveryToken.token}`; const html = await this.emailService.buildEmail("passwordResetTemplate", { name, @@ -336,17 +329,10 @@ export class UserService implements IUserService { throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUser", status: 400 }); } - // 1. Find all the monitors associated with the team ID if superadmin - const res = await this.monitorsRepository.findByTeamId(teamId, {}); - if (roles.includes("superadmin")) { - // 2. Remove all jobs, delete checks and alerts - if (res && res.length > 0) { - await Promise.all( - res.map(async (monitor) => { - await this.jobQueue.deleteJob(monitor); - }) - ); + const monitors = await this.monitorsRepository.findByTeamId(teamId, {}); + if (monitors) { + await Promise.all(monitors.map((monitor) => this.jobQueue.deleteJob(monitor))); } } // 6. Delete the user by id @@ -410,9 +396,7 @@ export class UserService implements IUserService { if (!roles.includes("superadmin") && !roles.includes("admin")) { throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 }); } - const user = await this.usersRepository.findById(userId); - - return user; + return await this.usersRepository.findById(userId); }; editUserById = async (userId: string, patch: Partial) => { diff --git a/server/src/service/index.ts b/server/src/service/index.ts index 1e3b20ba6..5db8afc4f 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -30,6 +30,8 @@ export * from "@/service/infrastructure/notificationProviders/slack.js"; export * from "@/service/infrastructure/notificationProviders/teams.js"; 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"; // System services export * from "@/service/system/settingsService.js"; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index e9c487109..58e7a9585 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -28,6 +28,8 @@ type QueueJobSummary = { monitorUrl: string | null; monitorType: string | null; monitorInterval: number | null; + monitorGeoInterval: number | null; + monitorActive: boolean | null; active: boolean; lockedAt: number | null; runCount: number; @@ -37,6 +39,7 @@ type QueueJobSummary = { lastFinishedAt: number | null; lastRunTook: number | null; lastFailedAt: number | null; + repeat: number | null; }; export interface ISuperSimpleQueue { @@ -51,7 +54,6 @@ export interface ISuperSimpleQueue { getMetrics(): Promise; getJobs(): Promise; flushQueues(): Promise<{ success: boolean }>; - obliterate(): Promise; } export class SuperSimpleQueue implements ISuperSimpleQueue { @@ -73,22 +75,91 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { return SuperSimpleQueue.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", (error) => { + this.logger.error({ + message: `Scheduler error: ${error instanceof Error ? error.message : String(error)}`, + service: SERVICE_NAME, + stack: error instanceof Error ? error.stack : undefined, + }); + }); + + this.scheduler.on("job:abort", (job, reason) => { + this.logger.warn({ + message: `${job.id} aborted: ${reason}`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:attempt", (job, attempt) => { + this.logger.debug({ + message: `${job.id} attempt ${attempt}`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:complete", (job) => { + this.logger.debug({ + message: `${job.id} completed successfully`, + service: SERVICE_NAME, + }); + }); + + this.scheduler.on("job:exhausted", (job, error) => { + this.logger.error({ + message: `${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", (job, error, attempt) => { + this.logger.warn({ + message: `${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", (job) => { + this.logger.debug({ + message: `${job.id} started`, + service: SERVICE_NAME, + }); + }); + }; + static async create(logger: ILogger, helper: ISuperSimpleQueueHelper, monitorsRepository: IMonitorsRepository) { const scheduler = new Scheduler({ // storeType: "mongo", // storeType: "redis", - logLevel: "debug", // dbUri: envSettings.dbConnectionString, }); const instance = new SuperSimpleQueue(logger, helper, monitorsRepository, scheduler); await instance.init(); + return instance; } init = async () => { try { - this.scheduler.start(); + this.registerListeners(); + 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()); @@ -155,8 +226,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}`, @@ -170,9 +253,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, @@ -237,8 +331,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { if (failCount > 0) { acc.jobsWithFailures.push({ monitorId: job.id, - monitorUrl: job?.data?.url || null, - monitorType: job?.data?.type || null, + monitorUrl: job.data?.url || null, + monitorType: job.data?.type || null, failedAt: job.lastFailedAt ?? null, failCount, failReason: job.lastFailReason ?? null, @@ -270,10 +364,13 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { return jobs.map((job) => { return { monitorId: job.id, - monitorUrl: job?.data?.url || null, - monitorType: job?.data?.type || null, - monitorInterval: job?.data?.interval || null, + 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, lockedAt: job.lockedAt ?? null, runCount: job.runCount ?? 0, failCount: job.failCount ?? 0, @@ -294,12 +391,4 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { success: Boolean(stopRes && flushRes && initRes), }; }; - - obliterate = async () => { - this.logger.warn({ - message: "obliterate method not implemented", - service: SERVICE_NAME, - method: "obliterate", - }); - }; } diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index b6908127b..956d19c98 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -168,15 +168,17 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { }); } - // Step 7. Handle incidents (best effort, don't wait) - this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: unknown) => { + // Step 7. Handle incidents + try { + await this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status); + } catch (error: unknown) { this.logger.warn({ message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, service: SERVICE_NAME, method: "getMonitorJob", stack: error instanceof Error ? error.stack : undefined, }); - }); + } } catch (error: unknown) { this.logger.warn({ message: error instanceof Error ? error.message : "Unknown error", @@ -377,7 +379,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { return true; } } - return false; + return acc; } return acc; }, false); diff --git a/server/src/service/infrastructure/bufferService.ts b/server/src/service/infrastructure/bufferService.ts index 229c5e7d4..07be446c6 100755 --- a/server/src/service/infrastructure/bufferService.ts +++ b/server/src/service/infrastructure/bufferService.ts @@ -9,7 +9,6 @@ const SERVICE_NAME = "BufferService"; export interface IBufferService { addToBuffer(check: Check): void; addGeoCheckToBuffer(geoCheck: GeoCheck): void; - removeCheckFromBuffer(check: Check): boolean; scheduleNextFlush(): void; flushBuffer(): Promise; flushGeoBuffer(): Promise; @@ -72,44 +71,6 @@ export class BufferService implements IBufferService { } } - removeCheckFromBuffer(checkToRemove: Check) { - try { - if (!checkToRemove) { - return false; - } - - const index = this.buffer.findIndex((check) => { - if (checkToRemove.id && check.id) { - return check.id.toString() === checkToRemove.id.toString(); - } - return ( - check.metadata.monitorId?.toString() === checkToRemove.metadata.monitorId && - check.metadata.teamId?.toString() === checkToRemove.metadata.teamId && - check.metadata.type === checkToRemove.metadata.type && - check.status === checkToRemove.status && - check.statusCode === checkToRemove.statusCode && - check.responseTime === checkToRemove.responseTime && - check.message === checkToRemove.message - ); - }); - - if (index !== -1) { - this.buffer.splice(index, 1); - return true; - } - - return false; - } catch (error: unknown) { - this.logger.error({ - message: error instanceof Error ? error.message : "Unknown error", - service: this.SERVICE_NAME, - method: "removeCheckFromBuffer", - stack: error instanceof Error ? error.stack : undefined, - }); - return false; - } - } - scheduleNextFlush() { if (this.bufferTimer) { clearTimeout(this.bufferTimer); diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index f536f3f18..4428b0477 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -35,7 +35,6 @@ export class EmailService implements IEmailService { private logger: ILogger; private transporter: ReturnType | null = null; private templateLookup: Record) => string) | undefined>; - private loadTemplate: (templateName: string) => ((context: Record) => string) | undefined; constructor( settingsService: ISettingsService, @@ -54,7 +53,6 @@ export class EmailService implements IEmailService { this.nodemailer = nodemailer; this.logger = logger; this.templateLookup = {}; - this.loadTemplate = () => undefined; this.init(); } @@ -63,7 +61,7 @@ export class EmailService implements IEmailService { } init = () => { - this.loadTemplate = (templateName) => { + const loadTemplate = (templateName: string) => { try { const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`); const templateContent = this.fs.readFileSync(templatePath, "utf8"); @@ -79,12 +77,12 @@ export class EmailService implements IEmailService { }; this.templateLookup = { - welcomeEmailTemplate: this.loadTemplate("welcomeEmail"), - employeeActivationTemplate: this.loadTemplate("employeeActivation"), - noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"), - passwordResetTemplate: this.loadTemplate("passwordReset"), - testEmailTemplate: this.loadTemplate("testEmailTemplate"), - unifiedNotificationTemplate: this.loadTemplate("unifiedNotification"), + welcomeEmailTemplate: loadTemplate("welcomeEmail"), + employeeActivationTemplate: loadTemplate("employeeActivation"), + noIncidentsThisWeekTemplate: loadTemplate("noIncidentsThisWeek"), + passwordResetTemplate: loadTemplate("passwordReset"), + testEmailTemplate: loadTemplate("testEmailTemplate"), + unifiedNotificationTemplate: loadTemplate("unifiedNotification"), }; }; @@ -120,6 +118,7 @@ export class EmailService implements IEmailService { systemEmailPool, systemEmailUser, systemEmailAddress, + systemEmailDisplayName, systemEmailPassword, systemEmailConnectionHost, systemEmailTLSServername, @@ -160,14 +159,17 @@ 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, }); - return info?.messageId; + return info.messageId; } catch (error: unknown) { this.logger.error({ message: error instanceof Error ? error.message : "Unknown error", 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/DockerProvider.ts b/server/src/service/infrastructure/network/DockerProvider.ts index 8e84b5dc6..1d4b98818 100644 --- a/server/src/service/infrastructure/network/DockerProvider.ts +++ b/server/src/service/infrastructure/network/DockerProvider.ts @@ -90,7 +90,8 @@ export class DockerProvider implements IStatusProvider { if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID"); if (matchTypes.length > 1) { - const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`; + const matchUsed = exactIdMatch ? "exact ID" : "exact name"; + const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${matchUsed} match.`; this.logger.warn({ message, @@ -138,11 +139,24 @@ export class DockerProvider implements IStatusProvider { }; } + if (!response) { + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: NETWORK_ERROR, + message: "No response from Docker container inspect", + responseTime, + payload: null, + }; + } + return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, - status: response?.State?.Status === "running", + status: response.State?.Status === "running", code: 200, message: "Docker container status fetched successfully", responseTime, diff --git a/server/src/service/infrastructure/network/GrpcProvider.ts b/server/src/service/infrastructure/network/GrpcProvider.ts index 66684ba0c..14a063bca 100644 --- a/server/src/service/infrastructure/network/GrpcProvider.ts +++ b/server/src/service/infrastructure/network/GrpcProvider.ts @@ -60,7 +60,7 @@ export class GrpcProvider implements IStatusProvider { throw new AppError({ message: "Monitor port is required", service: SERVICE_NAME, method: "handle" }); } - const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0]; + const host = url.replace(/^https?:\/\//, "").split(/[/?#:]/)[0]; const target = `${host}:${port}`; const currentFilePath = fileURLToPath(import.meta.url); diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 9111ef7b2..ca85d12ac 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -118,7 +118,7 @@ export class NetworkService implements INetworkService { }, }); - if (response?.data?.status !== "success") return false; + if (response.data?.status !== "success") return false; return true; } catch (err: unknown) { const originalMessage = err instanceof Error ? err.message : String(err); diff --git a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts index 2743dfab9..b5c886408 100644 --- a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts +++ b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts @@ -1,7 +1,30 @@ import type { Notification } from "@/types/index.js"; import type { NotificationMessage } from "@/types/notificationMessage.js"; +import { ILogger } from "@/utils/logger.js"; +import type { OptionsOfJSONResponseBody } from "got"; +export const DEFAULT_NOTIFICATION_TIMEOUT_MS = 30_000; export interface INotificationProvider { sendMessage: (notification: Notification, message: NotificationMessage) => Promise; sendTestAlert(notification: Partial): Promise; } + +export abstract class NotificationProvider implements INotificationProvider { + protected readonly logger: ILogger; + protected readonly timeoutMs: number; + + constructor(logger: ILogger, timeoutMs: number = DEFAULT_NOTIFICATION_TIMEOUT_MS) { + this.logger = logger; + this.timeoutMs = timeoutMs; + } + + protected gotRequestOptions(): Pick { + return { + timeout: { request: this.timeoutMs }, + retry: { limit: 0 }, + }; + } + + abstract sendMessage(notification: Notification, message: NotificationMessage): Promise; + abstract sendTestAlert(notification: Partial): Promise; +} diff --git a/server/src/service/infrastructure/notificationProviders/discord.ts b/server/src/service/infrastructure/notificationProviders/discord.ts index 3bd2556ae..942e0b578 100644 --- a/server/src/service/infrastructure/notificationProviders/discord.ts +++ b/server/src/service/infrastructure/notificationProviders/discord.ts @@ -1,18 +1,11 @@ const SERVICE_NAME = "DiscordProvider"; import type { AlertDiscordPayload, DiscordEmbedField, Notification } from "@/types/index.js"; -import { INotificationProvider } from "@/service/index.js"; +import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; import type { NotificationMessage, NotificationSeverity } from "@/types/notificationMessage.js"; import got from "got"; -import { ILogger } from "@/utils/logger.js"; - -export class DiscordProvider implements INotificationProvider { - private logger: ILogger; - - constructor(logger: ILogger) { - this.logger = logger; - } +export class DiscordProvider extends NotificationProvider { sendTestAlert = async (notification: Partial) => { if (!notification.address) { return false; @@ -23,6 +16,7 @@ export class DiscordProvider implements INotificationProvider { headers: { "Content-Type": "application/json", }, + ...this.gotRequestOptions(), }); return true; } catch (error) { @@ -55,6 +49,7 @@ export class DiscordProvider implements INotificationProvider { headers: { "Content-Type": "application/json", }, + ...this.gotRequestOptions(), }); return true; } catch (error) { diff --git a/server/src/service/infrastructure/notificationProviders/email.ts b/server/src/service/infrastructure/notificationProviders/email.ts index b3686651c..0890eac7f 100644 --- a/server/src/service/infrastructure/notificationProviders/email.ts +++ b/server/src/service/infrastructure/notificationProviders/email.ts @@ -1,17 +1,16 @@ const SERVICE_NAME = "EmailProvider"; import type { Notification } from "@/types/index.js"; -import { INotificationProvider } from "@/service/index.js"; +import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; import { buildTestEmail } from "@/service/infrastructure/notificationProviders/utils.js"; import type { NotificationMessage } from "@/types/notificationMessage.js"; import type { ILogger } from "@/utils/logger.js"; import { IEmailService } from "@/service/infrastructure/emailService.js"; -export class EmailProvider implements INotificationProvider { +export class EmailProvider extends NotificationProvider { private emailService: IEmailService; - private logger: ILogger; constructor(emailService: IEmailService, logger: ILogger) { + super(logger); this.emailService = emailService; - this.logger = logger; } async sendTestAlert(notification: Partial): Promise { diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts index f14ea4d17..f8a093ab7 100644 --- a/server/src/service/infrastructure/notificationProviders/matrix.ts +++ b/server/src/service/infrastructure/notificationProviders/matrix.ts @@ -1,80 +1,63 @@ const SERVICE_NAME = "MatrixProvider"; import got from "got"; -import type { INotificationProvider } from "@/service/index.js"; +import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; import type { AlertMatrixPayload, Notification } from "@/types/index.js"; import type { NotificationMessage } from "@/types/notificationMessage.js"; import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; -import { ILogger } from "@/utils/logger.js"; - -export class MatrixProvider implements INotificationProvider { - private logger: ILogger; - - constructor(logger: ILogger) { - this.logger = logger; - } +import { randomUUID } from "crypto"; +export class MatrixProvider extends NotificationProvider { sendTestAlert = async (notification: Partial) => { const { homeserverUrl, accessToken, roomId } = notification; if (!homeserverUrl || !accessToken || !roomId) { return false; } - const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`; + const body = { msgtype: "m.text", body: getTestMessage(), }; - try { - await got.post(url, { - json: body, - headers: { - "Content-Type": "application/json", - }, - }); - return true; - } catch (error) { - const err = error as Error; - this.logger.warn({ - message: "Matrix test alert failed", - service: SERVICE_NAME, - method: "sendTestAlert", - stack: err?.stack, - }); - return false; - } + return await this.sendMessageWithBody(notification, body); }; sendMessage = async (notification: Notification, message: NotificationMessage): Promise => { const { homeserverUrl, accessToken, roomId } = notification; - if (!homeserverUrl || !accessToken || !roomId) { return false; } const { plainText, htmlText } = this.buildMatrixMessage(message); - - const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`; const body = { msgtype: "m.text", body: plainText, format: "org.matrix.custom.html", formatted_body: htmlText, }; + return this.sendMessageWithBody(notification, body); + }; + + private sendMessageWithBody = async (notification: Partial, body: unknown): Promise => { + const { homeserverUrl, accessToken, roomId } = notification; + const txnId = randomUUID(); + const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message/${txnId}`; try { - await got.post(url, { + await got.put(url, { json: body, headers: { "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, }, + ...this.gotRequestOptions(), }); return true; } catch (error) { const err = error as Error; this.logger.warn({ - message: "Matrix notification failed", + message: `Matrix notification failed : ${err.message}`, service: SERVICE_NAME, method: "sendMessage", - stack: err?.stack, + stack: err.stack, }); return false; } diff --git a/server/src/service/infrastructure/notificationProviders/pagerduty.ts b/server/src/service/infrastructure/notificationProviders/pagerduty.ts index 304c38cf4..0d2fa75be 100644 --- a/server/src/service/infrastructure/notificationProviders/pagerduty.ts +++ b/server/src/service/infrastructure/notificationProviders/pagerduty.ts @@ -1,19 +1,12 @@ const SERVICE_NAME = "PagerDutyProvider"; import got from "got"; import type { Notification } from "@/types/index.js"; -import { INotificationProvider } from "@/service/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 { ILogger } from "@/utils/logger.js"; import { AlertPagerDutyPayload } from "@/types/index.js"; -export class PagerDutyProvider implements INotificationProvider { - private logger: ILogger; - - constructor(logger: ILogger) { - this.logger = logger; - } - +export class PagerDutyProvider extends NotificationProvider { async sendTestAlert(notification: Partial): Promise { try { await got.post("https://events.pagerduty.com/v2/enqueue", { @@ -28,6 +21,7 @@ export class PagerDutyProvider implements INotificationProvider { }, }, responseType: "json", + ...this.gotRequestOptions(), }); return true; } catch (error) { @@ -59,6 +53,7 @@ export class PagerDutyProvider implements INotificationProvider { const payload = this.buildPagerDutyPayload(notification, message); await got.post("https://events.pagerduty.com/v2/enqueue", { json: payload, + ...this.gotRequestOptions(), }); this.logger.info({ message: "[NEW] PagerDuty notification sent via sendMessage", diff --git a/server/src/service/infrastructure/notificationProviders/pushover.ts b/server/src/service/infrastructure/notificationProviders/pushover.ts new file mode 100644 index 000000000..5d001892d --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/pushover.ts @@ -0,0 +1,109 @@ +const SERVICE_NAME = "PushoverProvider"; +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 PushoverProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.address || !notification.accessToken) { + return false; + } + + try { + await got.post("https://api.pushover.net/1/messages.json", { + form: { + token: notification.accessToken, + user: notification.address, + message: getTestMessage(), + title: "Checkmate Test Notification", + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Pushover test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address || !notification.accessToken) { + return false; + } + + const text = this.buildPushoverText(message); + + try { + await got.post("https://api.pushover.net/1/messages.json", { + form: { + token: notification.accessToken, + user: notification.address, + message: text, + title: message.content.title, + }, + ...this.gotRequestOptions(), + }); + + this.logger.info({ + message: "Pushover notification sent", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Pushover alert failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + private buildPushoverText(message: NotificationMessage): string { + const lines: string[] = []; + + lines.push(message.content.summary); + lines.push(""); + lines.push("Monitor Details:"); + lines.push(`• Name: ${message.monitor.name}`); + lines.push(`• URL: ${message.monitor.url}`); + lines.push(`• Type: ${message.monitor.type}`); + lines.push(`• Status: ${message.monitor.status}`); + + if (message.content.details && message.content.details.length > 0) { + lines.push(""); + lines.push("Additional Information:"); + message.content.details.forEach((detail) => lines.push(`• ${detail}`)); + } + + if (message.content.thresholds && message.content.thresholds.length > 0) { + lines.push(""); + 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(""); + lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`); + } + + return lines.join("\n"); + } +} diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/service/infrastructure/notificationProviders/slack.ts index cfb135901..1ab046f7f 100644 --- a/server/src/service/infrastructure/notificationProviders/slack.ts +++ b/server/src/service/infrastructure/notificationProviders/slack.ts @@ -1,18 +1,11 @@ const SERVICE_NAME = "SlackProvider"; import type { Notification } from "@/types/index.js"; -import { INotificationProvider } from "@/service/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, { HTTPError } from "got"; -import { ILogger } from "@/utils/logger.js"; - -export class SlackProvider implements INotificationProvider { - private logger: ILogger; - - constructor(logger: ILogger) { - this.logger = logger; - } +export class SlackProvider extends NotificationProvider { async sendTestAlert(notification: Partial): Promise { if (!notification.address) { return false; @@ -24,6 +17,7 @@ export class SlackProvider implements INotificationProvider { headers: { "Content-Type": "application/json", }, + ...this.gotRequestOptions(), }); return true; } catch (error) { @@ -54,6 +48,7 @@ export class SlackProvider implements INotificationProvider { headers: { "Content-Type": "application/json", }, + ...this.gotRequestOptions(), }); this.logger.info({ message: "[NEW] Slack notification sent via sendMessage", diff --git a/server/src/service/infrastructure/notificationProviders/teams.ts b/server/src/service/infrastructure/notificationProviders/teams.ts index dcf48c4cd..f98724295 100644 --- a/server/src/service/infrastructure/notificationProviders/teams.ts +++ b/server/src/service/infrastructure/notificationProviders/teams.ts @@ -1,9 +1,8 @@ const SERVICE_NAME = "TeamsProvider"; import type { Notification } from "@/types/index.js"; -import { INotificationProvider } from "@/service/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 type { ILogger } from "@/utils/logger.js"; import got, { HTTPError } from "got"; // Types for Adaptive Card elements @@ -58,13 +57,7 @@ type TeamsMessage = { }>; }; -export class TeamsProvider implements INotificationProvider { - private logger: ILogger; - - constructor(logger: ILogger) { - this.logger = logger; - } - +export class TeamsProvider extends NotificationProvider { async sendTestAlert(notification: Partial): Promise { if (!notification.address) { return false; @@ -90,6 +83,7 @@ export class TeamsProvider implements INotificationProvider { headers: { "Content-Type": "application/json", }, + ...this.gotRequestOptions(), }); return true; } catch (error) { @@ -117,6 +111,7 @@ export class TeamsProvider implements INotificationProvider { headers: { "Content-Type": "application/json", }, + ...this.gotRequestOptions(), }); this.logger.info({ message: "Teams notification sent via sendMessage", diff --git a/server/src/service/infrastructure/notificationProviders/telegram.ts b/server/src/service/infrastructure/notificationProviders/telegram.ts index fcb7bdacc..9d6968fdd 100644 --- a/server/src/service/infrastructure/notificationProviders/telegram.ts +++ b/server/src/service/infrastructure/notificationProviders/telegram.ts @@ -1,18 +1,11 @@ const SERVICE_NAME = "TelegramProvider"; import type { Notification } from "@/types/index.js"; -import { INotificationProvider } from "@/service/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"; -import { ILogger } from "@/utils/logger.js"; - -export class TelegramProvider implements INotificationProvider { - private logger: ILogger; - - constructor(logger: ILogger) { - this.logger = logger; - } +export class TelegramProvider extends NotificationProvider { async sendTestAlert(notification: Partial): Promise { if (!notification.address || !notification.accessToken) { return false; @@ -25,6 +18,7 @@ export class TelegramProvider implements INotificationProvider { text: getTestMessage(), parse_mode: "HTML", }, + ...this.gotRequestOptions(), }); return true; } catch (error) { @@ -55,7 +49,9 @@ export class TelegramProvider implements INotificationProvider { text, parse_mode: "HTML", }, + ...this.gotRequestOptions(), }); + this.logger.info({ message: "Telegram notification sent", service: SERVICE_NAME, diff --git a/server/src/service/infrastructure/notificationProviders/twilio.ts b/server/src/service/infrastructure/notificationProviders/twilio.ts new file mode 100644 index 000000000..a871fe5cb --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/twilio.ts @@ -0,0 +1,96 @@ +const SERVICE_NAME = "TwilioProvider"; +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 TwilioProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.accountSid || !notification.accessToken || !notification.phone || !notification.twilioPhoneNumber) { + return false; + } + + try { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.accountSid}/Messages.json`, { + form: { + To: notification.phone, + From: notification.twilioPhoneNumber, + Body: getTestMessage(), + }, + username: notification.accountSid, + password: notification.accessToken, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Twilio test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.accountSid || !notification.accessToken || !notification.phone || !notification.twilioPhoneNumber) { + return false; + } + + const text = this.buildSmsText(message); + + try { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.accountSid}/Messages.json`, { + form: { + To: notification.phone, + From: notification.twilioPhoneNumber, + Body: text, + }, + username: notification.accountSid, + password: notification.accessToken, + ...this.gotRequestOptions(), + }); + + this.logger.info({ + message: "Twilio SMS notification sent", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Twilio SMS alert failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + private buildSmsText(message: NotificationMessage): string { + const lines: string[] = []; + + lines.push(message.content.title); + lines.push(message.content.summary); + lines.push(""); + lines.push(`URL: ${message.monitor.url}`); + lines.push(`Status: ${message.monitor.status}`); + + if (message.content.thresholds && message.content.thresholds.length > 0) { + message.content.thresholds.forEach((breach) => { + lines.push(`${breach.metric.toUpperCase()}: ${breach.formattedValue}`); + }); + } + + return lines.join("\n"); + } +} diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts index fcb5728d4..292650f91 100644 --- a/server/src/service/infrastructure/notificationProviders/webhook.ts +++ b/server/src/service/infrastructure/notificationProviders/webhook.ts @@ -1,18 +1,11 @@ const SERVICE_NAME = "WebhookProvider"; import type { Notification } from "@/types/index.js"; -import { INotificationProvider } from "@/service/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"; -import { ILogger } from "@/utils/logger.js"; - -export class WebhookProvider implements INotificationProvider { - private logger: ILogger; - - constructor(logger: ILogger) { - this.logger = logger; - } +export class WebhookProvider extends NotificationProvider { sendMessage = async (notification: Notification, message: NotificationMessage): Promise => { if (!notification.address) { return false; @@ -27,6 +20,7 @@ export class WebhookProvider implements INotificationProvider { headers: { "Content-Type": "application/json", }, + ...this.gotRequestOptions(), }); this.logger.info({ message: "Webhook notification sent", @@ -108,6 +102,7 @@ export class WebhookProvider implements INotificationProvider { headers: { "Content-Type": "application/json", }, + ...this.gotRequestOptions(), }); return true; } catch (error) { diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index b131c4ff9..90e9d637a 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -34,6 +34,8 @@ export class NotificationsService implements INotificationsService { private matrixProvider: INotificationProvider; private teamsProvider: INotificationProvider; private telegramProvider: INotificationProvider; + private pushoverProvider: INotificationProvider; + private twilioProvider: INotificationProvider; private logger: ILogger; private settingsService: ISettingsService; private notificationMessageBuilder: INotificationMessageBuilder; @@ -49,6 +51,8 @@ export class NotificationsService implements INotificationsService { matrixProvider: INotificationProvider, teamsProvider: INotificationProvider, telegramProvider: INotificationProvider, + pushoverProvider: INotificationProvider, + twilioProvider: INotificationProvider, settingsService: ISettingsService, logger: ILogger, notificationMessageBuilder: INotificationMessageBuilder @@ -63,6 +67,8 @@ export class NotificationsService implements INotificationsService { this.matrixProvider = matrixProvider; this.teamsProvider = teamsProvider; this.telegramProvider = telegramProvider; + this.pushoverProvider = pushoverProvider; + this.twilioProvider = twilioProvider; this.settingsService = settingsService; this.logger = logger; this.notificationMessageBuilder = notificationMessageBuilder; @@ -102,6 +108,10 @@ export class NotificationsService implements INotificationsService { return await this.teamsProvider.sendMessage!(notification, notificationMessage); case "telegram": return await this.telegramProvider.sendMessage!(notification, notificationMessage); + case "pushover": + return await this.pushoverProvider.sendMessage!(notification, notificationMessage); + case "twilio": + return await this.twilioProvider.sendMessage!(notification, notificationMessage); default: this.logger.warn({ message: `Unknown notification type: ${notification.type}`, @@ -164,6 +174,10 @@ export class NotificationsService implements INotificationsService { return await this.teamsProvider.sendTestAlert(notification); case "telegram": return await this.telegramProvider.sendTestAlert(notification); + case "pushover": + return await this.pushoverProvider.sendTestAlert(notification); + case "twilio": + return await this.twilioProvider.sendTestAlert(notification); default: return false; } @@ -200,8 +214,8 @@ export class NotificationsService implements INotificationsService { }; deleteById = async (id: string, teamId: string): Promise => { - const deleted = await this.notificationsRepository.deleteById(id, teamId); await this.monitorsRepository.removeNotificationFromMonitors(id); + const deleted = await this.notificationsRepository.deleteById(id, teamId); return deleted; }; } diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index ef725b80a..617f15944 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -14,13 +14,20 @@ import type { GameStatusPayload, GrpcStatusPayload, CheckSnapshot, - MonitorStats, CheckDiskInfo, } from "@/types/index.js"; 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 HARDWARE_ALERT_COUNTER_START = 5; +const HARDWARE_METRIC_KEYS = ["cpu", "memory", "disk", "temp"] as const; +type HardwareMetricKey = (typeof HARDWARE_METRIC_KEYS)[number]; +type HardwareBreaches = Record; +type HardwareCounters = Record; export interface IStatusService { updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse): Promise; @@ -68,103 +75,11 @@ export class StatusService implements IStatusService { async updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse) { try { - const monitorId = monitor.id; - const { responseTime, status } = networkResponse; - let existingStats: MonitorStats | null = null; - existingStats = await this.monitorStatsRepository - .findByMonitorId(monitorId) - .then((result) => result) - .catch(() => { - this.logger.debug({ - service: SERVICE_NAME, - method: "updateRunningStats", - message: `No existing stats found for monitor ${monitorId}, initializing new stats.`, - }); - return null; - }); - - let stats: Omit; - - if (!existingStats) { - // Initialize new stats - stats = { - avgResponseTime: 0, - maxResponseTime: 0, - totalChecks: 0, - totalUpChecks: 0, - totalDownChecks: 0, - uptimePercentage: 0, - lastResponseTime: 0, - lastCheckTimestamp: 0, - }; - } else { - // Use existing stats (omit id, monitorId, createdAt, updatedAt) - stats = { - avgResponseTime: existingStats.avgResponseTime, - maxResponseTime: existingStats.maxResponseTime, - totalChecks: existingStats.totalChecks, - totalUpChecks: existingStats.totalUpChecks, - totalDownChecks: existingStats.totalDownChecks, - uptimePercentage: existingStats.uptimePercentage, - lastResponseTime: existingStats.lastResponseTime, - lastCheckTimestamp: existingStats.lastCheckTimestamp, - timeOfLastFailure: existingStats.timeOfLastFailure, - }; - } - - // Update stats - stats.totalChecks++; - - // Last response time - stats.lastResponseTime = responseTime ?? 0; - - // Max response time - if (responseTime && responseTime > stats.maxResponseTime) { - stats.maxResponseTime = responseTime; - } - - // Avg response time: - let avgResponseTime = stats.avgResponseTime; - if (typeof responseTime !== "undefined" && responseTime !== null) { - if (avgResponseTime === 0) { - avgResponseTime = responseTime; - } else { - avgResponseTime = (avgResponseTime * (stats.totalChecks - 1) + responseTime) / stats.totalChecks; - } - } - stats.avgResponseTime = avgResponseTime; - - // Total checks - if (status === true) { - stats.totalUpChecks++; - // Update the timeSinceLastFailure if needed - if (stats.timeOfLastFailure === 0) { - stats.timeOfLastFailure = new Date().getTime(); - } - } else { - stats.totalDownChecks++; - stats.timeOfLastFailure = 0; - } - - // Calculate uptime percentage - let uptimePercentage; - if (stats.totalChecks > 0) { - uptimePercentage = stats.totalUpChecks / stats.totalChecks; - } else { - uptimePercentage = status === true ? 100 : 0; - } - stats.uptimePercentage = uptimePercentage; - - // latest check - stats.lastCheckTimestamp = new Date().getTime(); - - // Create or update - if (!existingStats) { - await this.monitorStatsRepository.create({ monitorId, ...stats }); - } else { - await this.monitorStatsRepository.updateByMonitorId(monitorId, stats); - } - + await this.monitorStatsRepository.updateByMonitorId(monitor.id, { + status: networkResponse.status === true, + responseTime: networkResponse.responseTime ?? 0, + now: Date.now(), + }); return true; } catch (error: unknown) { this.logger.error({ @@ -177,6 +92,115 @@ export class StatusService implements IStatusService { } } + private tryUpdateRunningStats = async (monitor: Monitor, statusResponse: MonitorStatusResponse) => { + const statsOk = await this.updateRunningStats(monitor, statusResponse); + if (!statsOk) { + this.logger.warn({ + service: SERVICE_NAME, + method: "updateMonitorStatus", + message: `Stats update failed for monitor ${monitor.id}`, + }); + } + }; + + private toCheckSnapshot = (check: Check): CheckSnapshot => { + return { + id: check.id, + status: check.status, + responseTime: check.responseTime, + timings: check.timings, + statusCode: check.statusCode, + message: check.message, + cpu: check.cpu, + memory: check.memory, + disk: check.disk, + host: check.host, + errors: check.errors, + capture: check.capture, + net: check.net, + accessibility: check.accessibility, + bestPractices: check.bestPractices, + seo: check.seo, + performance: check.performance, + audits: check.audits, + createdAt: check.createdAt, + }; + }; + + private computeReachability = ( + currentStatus: MonitorStatus, + window: Array, + threshold: number + ): { nextStatus: "up" | "down"; transitioned: boolean } => { + const failures = window.filter((status) => status === false).length; + const failureRate = (failures / window.length) * 100; + + if (failureRate >= threshold && currentStatus !== "down") { + return { nextStatus: "down", transitioned: true }; + } + + if (failureRate < threshold && currentStatus === "down") { + return { nextStatus: "up", transitioned: true }; + } + return { nextStatus: "up", transitioned: false }; + }; + + private computeHardwareStatus = (params: { + currentStatus: MonitorStatus; + reachabilityDown: boolean; + metrics: HardwareStatusMetrics; + thresholds: { cpu: number; memory: number; disk: number; temp: number }; + counters: HardwareCounters; + }): { + nextStatus: MonitorStatus; + transitioned: boolean; + breaches: HardwareBreaches; + nextCounters: HardwareCounters; + } => { + const { metrics, thresholds, counters, currentStatus, reachabilityDown } = params; + + const cpuUsage = metrics.cpu?.usage_percent ?? -1; + const memoryUsage = metrics.memory?.usage_percent ?? -1; + const temps = metrics.cpu?.temperature ?? []; + + const breaches: HardwareBreaches = { + cpu: cpuUsage !== -1 && cpuUsage > thresholds.cpu / 100, + memory: memoryUsage !== -1 && memoryUsage > thresholds.memory / 100, + disk: metrics.disk + ? metrics.disk.some((d: CheckDiskInfo) => d != null && typeof d.usage_percent === "number" && d.usage_percent > thresholds.disk / 100) + : false, + temp: temps.some((temp: number) => temp > thresholds.temp), + }; + + // Update counters: decrement (floored at 0) if breached, reset to start otherwise. + const nextCounters = { ...counters }; + for (const key of HARDWARE_METRIC_KEYS) { + nextCounters[key] = breaches[key] ? Math.max(0, counters[key] - 1) : HARDWARE_ALERT_COUNTER_START; + } + + // Status transition: reachability "down" takes precedence; hardware can only drive + // transitions into "breached" and back out to "up". Any other case leaves status untouched. + let nextStatus: MonitorStatus = currentStatus; + let transitioned = false; + + if (!reachabilityDown) { + // A counter can only reach zero via the decrement path, which only runs when that + // metric is currently breaching — so anyCounterZero already implies anyBreached. + const anyCounterZero = HARDWARE_METRIC_KEYS.some((k) => nextCounters[k] === 0); + const allNormal = HARDWARE_METRIC_KEYS.every((k) => !breaches[k]); + + if (anyCounterZero && currentStatus !== "breached") { + nextStatus = "breached"; + transitioned = true; + } else if (allNormal && currentStatus === "breached") { + nextStatus = "up"; + transitioned = true; + } + } + + return { nextStatus, transitioned, breaches, nextCounters }; + }; + updateMonitorStatus = async ( statusResponse: MonitorStatusResponse< | PingStatusPayload @@ -196,159 +220,102 @@ export class StatusService implements IStatusService { const monitor = await this.monitorsRepository.findById(monitorId, teamId); // Update running stats - this.updateRunningStats(monitor, statusResponse); - - monitor.statusWindow = monitor.statusWindow || []; - monitor.statusWindow.push(status); - while (monitor.statusWindow.length > monitor.statusWindowSize) { - monitor.statusWindow.shift(); - } - - const checkSnapshot: CheckSnapshot = { - id: check.id, - status: check.status, - responseTime: check.responseTime, - timings: check.timings, - statusCode: check.statusCode, - message: check.message, - cpu: check.cpu, - memory: check.memory, - disk: check.disk, - host: check.host, - errors: check.errors, - capture: check.capture, - net: check.net, - accessibility: check.accessibility, - bestPractices: check.bestPractices, - seo: check.seo, - performance: check.performance, - audits: check.audits, - createdAt: check.createdAt, - }; - monitor.recentChecks = monitor.recentChecks || []; - monitor.recentChecks.push(checkSnapshot); - const maxRecentChecks = 25; - while (monitor.recentChecks.length > maxRecentChecks) { - monitor.recentChecks.shift(); - } + await this.tryUpdateRunningStats(monitor, statusResponse); const prevStatus = monitor.status; - let newStatus: MonitorStatus = status === true ? "up" : "down"; - let statusChanged = false; + const checkSnapshot = this.toCheckSnapshot(check); + + // Project the window as it will look after updating DB + // This is done because we need the updated status window to compute new status, but we don't + // want an an extra DB write just to get the window. + const projectedWindow = [...(monitor.statusWindow || []), check.status].slice(-monitor.statusWindowSize); + + // Build the status patch — computed against the projected window + const patch: Partial = {}; + + // Resolve "initializing" up-front, incidents should be created on initialization && down + let newStatus: MonitorStatus = monitor.status; + let statusChanged = false; + if (monitor.status === "initializing") { + newStatus = status === true ? "up" : "down"; + patch.status = newStatus; + statusChanged = newStatus === "down"; + } + + // Not enough data points yet — record the check and return + if (projectedWindow.length < monitor.statusWindowSize) { + const updated = await this.monitorsRepository.updateStatusWindowAndChecks( + monitor.id, + monitor.teamId, + check.status, + checkSnapshot, + monitor.statusWindowSize, + MAX_RECENT_CHECKS, + patch + ); - // Return early if not enough data points - if (monitor.statusWindow.length < monitor.statusWindowSize) { - monitor.status = newStatus; - const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor); return { monitor: updated, - statusChanged: false, + statusChanged, prevStatus, code, timestamp: Date.now(), }; } - // Check if threshold has been met - const failures = monitor.statusWindow.filter((s) => s === false).length; - const failureRate = (failures / monitor.statusWindow.length) * 100; - - // If threshold has been met and the monitor is not already down, mark down: - if (failureRate >= monitor.statusWindowThreshold && monitor.status !== "down") { - newStatus = "down"; - statusChanged = true; - } - // If the failure rate is below the threshold and the monitor is down, recover: - else if (failureRate < monitor.statusWindowThreshold && monitor.status === "down") { - newStatus = "up"; + // First evaluate reachability-based status changes, which apply to all monitor types + // and take precedence over hardware breaches. + const reachabilityResult = this.computeReachability(newStatus, projectedWindow, monitor.statusWindowThreshold); + if (reachabilityResult.transitioned) { + newStatus = reachabilityResult.nextStatus; statusChanged = true; } - // Evaluate hardware threshold breaches (only for hardware monitors) - let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined; - if (monitor.type === "hardware" && statusResponse.payload) { - const payload = statusResponse.payload as HardwareStatusPayload; - const metrics = payload?.data; + // Evaluate hardware threshold breaches (only for hardware monitors with metrics payload) + let thresholdBreaches: HardwareBreaches | undefined; + const hardwarePayload = statusResponse.payload as HardwareStatusPayload | undefined; + if (monitor.type === "hardware" && hardwarePayload?.data) { + const hardware = this.computeHardwareStatus({ + currentStatus: newStatus, + reachabilityDown: newStatus === "down", + metrics: hardwarePayload.data, + thresholds: { + cpu: monitor.cpuAlertThreshold, + memory: monitor.memoryAlertThreshold, + disk: monitor.diskAlertThreshold, + temp: monitor.tempAlertThreshold, + }, + counters: { + cpu: monitor.cpuAlertCounter, + memory: monitor.memoryAlertCounter, + disk: monitor.diskAlertCounter, + temp: monitor.tempAlertCounter, + }, + }); - if (metrics) { - // Evaluate threshold breaches - const cpuUsage = metrics.cpu?.usage_percent ?? -1; - const cpuBreach = cpuUsage !== -1 && cpuUsage > monitor.cpuAlertThreshold / 100; - - const memoryUsage = metrics.memory?.usage_percent ?? -1; - const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100; - - const diskBreach = - metrics.disk?.some((d: CheckDiskInfo) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? - false; - - const temps = metrics.cpu?.temperature ?? []; - const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold); - - thresholdBreaches = { - cpu: cpuBreach, - memory: memoryBreach, - disk: diskBreach, - temp: tempBreach, - }; - - // Update counters: decrement if breached, reset to 5 if not breached - if (cpuBreach) { - monitor.cpuAlertCounter = Math.max(0, monitor.cpuAlertCounter - 1); - } else { - monitor.cpuAlertCounter = 5; - } - - if (memoryBreach) { - monitor.memoryAlertCounter = Math.max(0, monitor.memoryAlertCounter - 1); - } else { - monitor.memoryAlertCounter = 5; - } - - if (diskBreach) { - monitor.diskAlertCounter = Math.max(0, monitor.diskAlertCounter - 1); - } else { - monitor.diskAlertCounter = 5; - } - - if (tempBreach) { - monitor.tempAlertCounter = Math.max(0, monitor.tempAlertCounter - 1); - } else { - monitor.tempAlertCounter = 5; - } - - // Check if any counter has reached zero (initial breach) - const anyCounterZero = - monitor.cpuAlertCounter === 0 || monitor.memoryAlertCounter === 0 || monitor.diskAlertCounter === 0 || monitor.tempAlertCounter === 0; - - const anyThresholdBreached = cpuBreach || memoryBreach || diskBreach || tempBreach; - const allThresholdsNormal = !cpuBreach && !memoryBreach && !diskBreach && !tempBreach; - - // Update monitor status based on threshold breach state - if (newStatus !== "down") { - // Don't override "down" status - service unreachable takes precedence - // Check current monitor status, not newStatus for comparison - if (anyCounterZero && anyThresholdBreached && monitor.status !== "breached") { - // Initial breach: counter hit zero, change status to breached - newStatus = "breached"; - statusChanged = true; - } else if (anyCounterZero && anyThresholdBreached && monitor.status === "breached") { - // Already breached, keep status but don't mark as changed - newStatus = "breached"; - // statusChanged remains false - } else if (allThresholdsNormal && monitor.status === "breached") { - // All thresholds returned to normal, recover from breached state - newStatus = "up"; - statusChanged = true; - } - } + patch.cpuAlertCounter = hardware.nextCounters.cpu; + patch.memoryAlertCounter = hardware.nextCounters.memory; + patch.diskAlertCounter = hardware.nextCounters.disk; + patch.tempAlertCounter = hardware.nextCounters.temp; + thresholdBreaches = hardware.breaches; + if (hardware.transitioned) { + newStatus = hardware.nextStatus; + statusChanged = true; } } - // Apply the final status - monitor.status = newStatus; + patch.status = newStatus; - const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor); + // Single atomic write: push arrays + set status/counters + const updated = await this.monitorsRepository.updateStatusWindowAndChecks( + monitor.id, + monitor.teamId, + check.status, + checkSnapshot, + monitor.statusWindowSize, + MAX_RECENT_CHECKS, + patch + ); return { monitor: updated, @@ -366,27 +333,4 @@ export class StatusService implements IStatusService { }); } }; - - insertCheck = async (check: Check) => { - try { - if (typeof check === "undefined") { - this.logger.warn({ - message: "Failed to build check", - service: SERVICE_NAME, - method: "insertCheck", - }); - return false; - } - this.buffer.addToBuffer(check); - return true; - } catch (error: unknown) { - this.logger.error({ - message: error instanceof Error ? error.message : "Unknown error", - service: SERVICE_NAME, - method: "insertCheck", - details: { msg: `Error inserting check for monitor: ${check?.metadata.monitorId}` }, - stack: error instanceof Error ? error.stack : undefined, - }); - } - }; } diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index f2daff550..32b95d261 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/service/system/settingsService.ts @@ -13,12 +13,14 @@ export type EnvConfig = { clientHost: string; dbConnectionString: string; dbType: DbType; + statusPageThemesEnabled: boolean; }; export interface ISettingsService { readonly serviceName: string; loadSettings(): EnvConfig; getSettings(): EnvConfig; + areStatusPageThemesEnabled(): boolean; getDBSettings(): Promise; updateDbSettings(newSettings: SettingsUpdate): Promise; } @@ -37,6 +39,7 @@ export class SettingsService implements ISettingsService { clientHost: env.CLIENT_HOST, dbConnectionString: env.DB_CONNECTION_STRING, dbType: env.DB_TYPE, + statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED, }; } @@ -53,12 +56,13 @@ export class SettingsService implements ISettingsService { } getSettings() { - if (!this.settings) { - throw new Error("Settings have not been loaded"); - } return this.settings; } + areStatusPageThemesEnabled() { + return this.settings.statusPageThemesEnabled; + } + private getRepository(): ISettingsRepository { if (!this.settingsRepository) { throw new AppError({ 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/maintenanceWindow.ts b/server/src/types/maintenanceWindow.ts index bcbd075a1..950dc1713 100644 --- a/server/src/types/maintenanceWindow.ts +++ b/server/src/types/maintenanceWindow.ts @@ -1,4 +1,5 @@ -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; diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 4e4309fe7..b2ad0f76a 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -3,7 +3,7 @@ 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; @@ -13,12 +13,30 @@ 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; @@ -58,6 +76,8 @@ export interface Monitor { geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + dnsServer?: string; + dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; diff --git a/server/src/types/monitorStats.ts b/server/src/types/monitorStats.ts index 97c3cb28c..a3ac914cf 100644 --- a/server/src/types/monitorStats.ts +++ b/server/src/types/monitorStats.ts @@ -1,3 +1,9 @@ +export interface CheckResultInput { + status: boolean; + responseTime: number; + now: number; +} + export interface MonitorStats { id: string; monitorId: 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 d4c47ae76..9d5b914c0 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -1,4 +1,15 @@ -export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram"] as const; +export const NotificationChannels = [ + "email", + "slack", + "discord", + "webhook", + "pager_duty", + "matrix", + "teams", + "telegram", + "pushover", + "twilio", +] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; export interface Notification { @@ -12,6 +23,8 @@ export interface Notification { homeserverUrl?: string; roomId?: string; accessToken?: string; + accountSid?: string; + twilioPhoneNumber?: string; createdAt: string; updatedAt: string; } diff --git a/server/src/types/settings.ts b/server/src/types/settings.ts index bcedfbe89..3d0d0d931 100644 --- a/server/src/types/settings.ts +++ b/server/src/types/settings.ts @@ -1,4 +1,4 @@ -export const DbTypes = ["timescaledb", "mongodb"] as const; +export const DbTypes = ["mongodb"] as const; export type DbType = (typeof DbTypes)[number]; export interface SettingsThresholds { @@ -21,6 +21,7 @@ export interface Settings { systemEmailHost?: string; systemEmailPort?: number; systemEmailAddress?: string; + systemEmailDisplayName?: string; systemEmailPassword?: string; systemEmailUser?: string; systemEmailConnectionHost?: string; diff --git a/server/src/types/statusPage.ts b/server/src/types/statusPage.ts index 0329f4e6f..0ef4d72a9 100644 --- a/server/src/types/statusPage.ts +++ b/server/src/types/statusPage.ts @@ -1,6 +1,14 @@ export const StatusPageTypes = ["uptime", "infrastructure"] as const; export type StatusPageType = (typeof StatusPageTypes)[number]; +export const StatusPageThemes = ["refined", "modern", "bold", "editorial"] as const; +export type StatusPageTheme = (typeof StatusPageThemes)[number]; +export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined"; + +export const StatusPageThemeModes = ["auto", "light", "dark"] as const; +export type StatusPageThemeMode = (typeof StatusPageThemeModes)[number]; +export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto"; + export interface StatusPageLogo { data: string; contentType: string; @@ -30,6 +38,8 @@ export interface StatusPage { showAdminLoginLink: boolean; showInfrastructure: boolean; customCSS: string; + theme: StatusPageTheme; + themeMode: StatusPageThemeMode; createdAt: string; updatedAt: string; } diff --git a/server/src/validation/authValidation.ts b/server/src/validation/authValidation.ts index 045868c40..d8f592cee 100644 --- a/server/src/validation/authValidation.ts +++ b/server/src/validation/authValidation.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { passwordPattern, nameValidation, lowercaseEmailValidation } from "./shared.js"; +import { UserRoles } from "@/types/user.js"; //**************************************** // Auth Validations @@ -41,7 +42,7 @@ export const newPasswordValidation = z.object({ export const inviteBodyValidation = z.object({ email: z.email("Must be a valid email address"), - role: z.array(z.string()).min(1, "At least one role is required"), + role: z.array(z.enum(UserRoles)).min(1, "At least one role is required"), teamId: z.string().min(1, "Team ID is required"), }); 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 447748516..3adf4c90c 100644 --- a/server/src/validation/envValidation.ts +++ b/server/src/validation/envValidation.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { DbTypes } from "@/types/settings.js"; +import { booleanCoercion } from "./shared.js"; const envSchema = z.object({ // Server Configuration @@ -20,6 +21,9 @@ const envSchema = z.object({ // Optional ORIGIN: z.string().optional(), + + // Feature flags + STATUS_PAGE_THEMES_ENABLED: booleanCoercion.default(true), }); export type ValidatedEnv = z.infer; diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index bee06e7bc..c53e39b20 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()).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 f229927d2..1889aa415 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, PageSpeedStrategies, DefaultPageSpeedStrategy } from "@/types/monitor.js"; +import { DefaultPageSpeedStrategy, DnsRecordTypes, MonitorMatchMethods, MonitorStatuses, MonitorTypes, PageSpeedStrategies } from "@/types/monitor.js"; export const getMonitorByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -20,12 +20,7 @@ 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(), }); @@ -36,12 +31,7 @@ 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(), explain: booleanCoercion.optional(), }); @@ -49,72 +39,98 @@ 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(""), - 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(), -}); +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(), - 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(), -}); +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(""), + 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); + +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(), + 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); 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"), }); @@ -124,48 +140,52 @@ 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(""), - strategy: z.enum(PageSpeedStrategies).default(DefaultPageSpeedStrategy), - 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([]), + 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).default(DefaultPageSpeedStrategy), + 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); export const importMonitorsBodyValidation = z.object({ monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"), @@ -180,3 +200,45 @@ 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()), + 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 e9705c1ad..523cb2a6e 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -72,6 +72,22 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ address: z.string().min(1, "Chat ID is required"), accessToken: z.string().min(1, "Bot token is required"), }), + // Pushover notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("pushover"), + address: z.string().min(1, "User key is required"), + accessToken: z.string().min(1, "App token is required"), + }), + // Twilio SMS notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("twilio"), + accountSid: z.string().min(1, "Account SID is required"), + accessToken: z.string().min(1, "Auth token is required"), + phone: z.string().min(1, "Recipient phone number is required"), + twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), + }), ]); export const testNotificationBodyValidation = createNotificationBodyValidation; @@ -97,6 +113,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/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..99f92f250 100644 --- a/server/src/validation/shared.ts +++ b/server/src/validation/shared.ts @@ -15,6 +15,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/statusPageValidation.ts b/server/src/validation/statusPageValidation.ts index 8b2ab3f20..6a42d340c 100644 --- a/server/src/validation/statusPageValidation.ts +++ b/server/src/validation/statusPageValidation.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { booleanCoercion } from "./shared.js"; -import { StatusPageTypes } from "@/types/statusPage.js"; +import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/types/statusPage.js"; //**************************************** // Status Page Validations @@ -33,6 +33,8 @@ export const createStatusPageBodyValidation = z showAdminLoginLink: booleanCoercion.optional(), showInfrastructure: booleanCoercion.optional(), removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(), + theme: z.enum(StatusPageThemes).optional(), + themeMode: z.enum(StatusPageThemeModes).optional(), }) .strip(); diff --git a/server/src/validation/userValidation.ts b/server/src/validation/userValidation.ts index 9e11c8c57..e613b86e6 100644 --- a/server/src/validation/userValidation.ts +++ b/server/src/validation/userValidation.ts @@ -50,3 +50,19 @@ export const editUserBodyValidation = z.object({ email: lowercaseEmailValidation.optional(), profilePicture: z.string().optional(), }); + +// 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/InMemoryIncidentsRepository.ts b/server/test/helpers/InMemoryIncidentsRepository.ts new file mode 100644 index 000000000..f76236f89 --- /dev/null +++ b/server/test/helpers/InMemoryIncidentsRepository.ts @@ -0,0 +1,138 @@ +import type { IIncidentsRepository } from "../../src/repositories/incidents/IIncidentsRepository.ts"; +import type { Incident, IncidentSummary } from "../../src/types/index.ts"; +import { randomUUID } from "crypto"; + +export class InMemoryIncidentsRepository implements IIncidentsRepository { + private incidents: Incident[] = []; + + async create(incident: Partial): Promise { + const now = new Date().toISOString(); + const full: Incident = { + id: randomUUID(), + monitorId: incident.monitorId ?? "", + teamId: incident.teamId ?? "", + startTime: incident.startTime ?? now, + endTime: incident.endTime ?? null, + status: incident.status ?? true, + message: incident.message ?? null, + statusCode: incident.statusCode ?? null, + resolutionType: incident.resolutionType ?? null, + resolvedBy: incident.resolvedBy ?? null, + resolvedByEmail: incident.resolvedByEmail ?? null, + comment: incident.comment ?? null, + createdAt: now, + updatedAt: now, + }; + this.incidents.push(full); + return { ...full }; + } + + async findById(incidentId: string, teamId: string): Promise { + const incident = this.incidents.find((i) => i.id === incidentId && i.teamId === teamId); + if (!incident) { + throw new Error(`Incident ${incidentId} not found`); + } + return { ...incident }; + } + + async findActiveByIncidentId(incidentId: string, teamId: string): Promise { + const incident = this.incidents.find((i) => i.id === incidentId && i.teamId === teamId && i.status === true); + return incident ? { ...incident } : null; + } + + async findActiveByMonitorId(monitorId: string, teamId: string): Promise { + const incident = this.incidents.find((i) => i.monitorId === monitorId && i.teamId === teamId && i.status === true); + return incident ? { ...incident } : null; + } + + async findByTeamId( + teamId: string, + startDate: Date | undefined, + page: number, + rowsPerPage: number, + sortOrder?: string, + status?: boolean, + monitorId?: string, + resolutionType?: string + ): Promise { + let results = this.incidents.filter((i) => i.teamId === teamId); + if (startDate) { + results = results.filter((i) => new Date(i.startTime) >= startDate); + } + if (status !== undefined) { + results = results.filter((i) => i.status === status); + } + if (monitorId) { + results = results.filter((i) => i.monitorId === monitorId); + } + if (resolutionType) { + results = results.filter((i) => i.resolutionType === resolutionType); + } + results.sort((a, b) => { + const cmp = new Date(a.startTime).getTime() - new Date(b.startTime).getTime(); + return sortOrder === "desc" ? -cmp : cmp; + }); + return results.slice(page * rowsPerPage, (page + 1) * rowsPerPage).map((i) => ({ ...i })); + } + + async findSummaryByTeamId(_teamId: string, _limit?: number): Promise { + throw new Error("Not implemented"); + } + + async countByTeamId(teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string): Promise { + let results = this.incidents.filter((i) => i.teamId === teamId); + if (startDate) { + results = results.filter((i) => new Date(i.startTime) >= startDate); + } + if (status !== undefined) { + results = results.filter((i) => i.status === status); + } + if (monitorId) { + results = results.filter((i) => i.monitorId === monitorId); + } + if (resolutionType) { + results = results.filter((i) => i.resolutionType === resolutionType); + } + return results.length; + } + + async updateById(incidentId: string, teamId: string, updateData: Partial): Promise { + const index = this.incidents.findIndex((i) => i.id === incidentId && i.teamId === teamId); + if (index === -1) { + throw new Error(`Incident ${incidentId} not found`); + } + const updated: Incident = { + ...this.incidents[index], + ...updateData, + id: this.incidents[index].id, + monitorId: this.incidents[index].monitorId, + teamId: this.incidents[index].teamId, + createdAt: this.incidents[index].createdAt, + updatedAt: new Date().toISOString(), + }; + this.incidents[index] = updated; + return { ...updated }; + } + + async deleteByMonitorId(monitorId: string, teamId: string): Promise { + const before = this.incidents.length; + this.incidents = this.incidents.filter((i) => !(i.monitorId === monitorId && i.teamId === teamId)); + return before - this.incidents.length; + } + + async deleteByMonitorIdsNotIn(monitorIds: string[]): Promise { + const before = this.incidents.length; + this.incidents = this.incidents.filter((i) => monitorIds.includes(i.monitorId)); + return before - this.incidents.length; + } + + // Test helpers — not part of the interface + + getAll(): Incident[] { + return this.incidents.map((i) => ({ ...i })); + } + + clear(): void { + this.incidents = []; + } +} diff --git a/server/test/helpers/InMemoryMonitorsRepository.ts b/server/test/helpers/InMemoryMonitorsRepository.ts new file mode 100644 index 000000000..a151ee41e --- /dev/null +++ b/server/test/helpers/InMemoryMonitorsRepository.ts @@ -0,0 +1,147 @@ +import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "../../src/repositories/monitors/IMonitorsRepository.ts"; +import type { Monitor, MonitorsSummary, CheckSnapshot } from "../../src/types/index.ts"; + +export class InMemoryMonitorsRepository implements IMonitorsRepository { + private monitors: Monitor[] = []; + + async create(monitor: Monitor, _teamId: string, _userId: string): Promise { + this.monitors.push({ ...monitor }); + return { ...monitor }; + } + + async createMonitors(monitors: Monitor[]): Promise { + const created = monitors.map((m) => ({ ...m })); + this.monitors.push(...created); + return created; + } + + async findById(monitorId: string, teamId: string): Promise { + const monitor = this.monitors.find((m) => m.id === monitorId && m.teamId === teamId); + if (!monitor) { + throw new Error(`Monitor ${monitorId} not found`); + } + return { ...monitor }; + } + + async findAll(): Promise { + return this.monitors.map((m) => ({ ...m })); + } + + async findByTeamId(_teamId: string, _config: TeamQueryConfig): Promise { + return this.monitors.filter((m) => m.teamId === _teamId).map((m) => ({ ...m })); + } + + async findByIds(monitorIds: string[]): Promise { + return this.monitors.filter((m) => monitorIds.includes(m.id)).map((m) => ({ ...m })); + } + + async findByIdsWithChecks(monitorIds: string[], _checksCount?: number): Promise { + return this.findByIds(monitorIds); + } + + async updateById(monitorId: string, teamId: string, updates: Partial): Promise { + const index = this.monitors.findIndex((m) => m.id === monitorId && m.teamId === teamId); + if (index === -1) { + throw new Error(`Monitor ${monitorId} not found`); + } + const updated = { ...this.monitors[index], ...updates, id: this.monitors[index].id, teamId: this.monitors[index].teamId }; + this.monitors[index] = updated; + return { ...updated }; + } + + async updateStatusWindowAndChecks( + monitorId: string, + teamId: string, + status: boolean, + checkSnapshot: CheckSnapshot, + windowSize: number, + maxRecentChecks: number, + statusPatch?: Partial + ): Promise { + const index = this.monitors.findIndex((m) => m.id === monitorId && m.teamId === teamId); + if (index === -1) { + throw new Error(`Monitor ${monitorId} not found`); + } + const monitor = this.monitors[index]; + monitor.statusWindow = monitor.statusWindow || []; + monitor.statusWindow.push(status); + while (monitor.statusWindow.length > windowSize) { + monitor.statusWindow.shift(); + } + monitor.recentChecks = monitor.recentChecks || []; + monitor.recentChecks.push(checkSnapshot); + while (monitor.recentChecks.length > maxRecentChecks) { + monitor.recentChecks.shift(); + } + if (statusPatch) { + Object.assign(monitor, statusPatch); + } + this.monitors[index] = monitor; + return { ...monitor }; + } + + async togglePauseById(monitorId: string, teamId: string): Promise { + const monitor = await this.findById(monitorId, teamId); + const newStatus = monitor.status === "paused" ? "up" : "paused"; + return this.updateById(monitorId, teamId, { status: newStatus }); + } + + async deleteById(monitorId: string, teamId: string): Promise { + const index = this.monitors.findIndex((m) => m.id === monitorId && m.teamId === teamId); + if (index === -1) { + throw new Error(`Monitor ${monitorId} not found`); + } + const [deleted] = this.monitors.splice(index, 1); + return { ...deleted }; + } + + async deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }> { + const toDelete = this.monitors.filter((m) => m.teamId === teamId); + this.monitors = this.monitors.filter((m) => m.teamId !== teamId); + return { monitors: toDelete, deletedCount: toDelete.length }; + } + + async findMonitorCountByTeamIdAndType(teamId: string, _config: TeamQueryConfig): Promise { + return this.monitors.filter((m) => m.teamId === teamId).length; + } + + async findMonitorsSummaryByTeamId(_teamId: string, _config?: SummaryConfig): Promise { + throw new Error("Not implemented"); + } + + async findGroupsByTeamId(_teamId: string): Promise { + throw new Error("Not implemented"); + } + + async removeNotificationFromMonitors(_notificationId: string): Promise { + throw new Error("Not implemented"); + } + + async updateNotifications(_teamId: string, _monitorIds: string[], _notificationIds: string[], _action: "add" | "remove" | "set"): Promise { + throw new Error("Not implemented"); + } + + async deleteByTeamIdsNotIn(teamIds: string[]): Promise { + const before = this.monitors.length; + this.monitors = this.monitors.filter((m) => teamIds.includes(m.teamId)); + return before - this.monitors.length; + } + + async findAllMonitorIds(): Promise { + return this.monitors.map((m) => m.id); + } + + // Test helpers + + seed(monitor: Monitor): void { + this.monitors.push({ ...monitor }); + } + + getAll(): Monitor[] { + return this.monitors.map((m) => ({ ...m })); + } + + clear(): void { + this.monitors = []; + } +} diff --git a/server/test/helpers/createMockLogger.ts b/server/test/helpers/createMockLogger.ts new file mode 100644 index 000000000..96f6fc7bd --- /dev/null +++ b/server/test/helpers/createMockLogger.ts @@ -0,0 +1,12 @@ +import { jest } from "@jest/globals"; + +export const createMockLogger = () => ({ + serviceName: "test", + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + cacheLog: jest.fn(), + getLogs: jest.fn().mockReturnValue([]), + buildLogEntry: jest.fn().mockReturnValue({ level: "info", timestamp: new Date().toISOString() }), +}); diff --git a/server/test/helpers/heartbeatTestHarness.ts b/server/test/helpers/heartbeatTestHarness.ts new file mode 100644 index 000000000..ec0c3714e --- /dev/null +++ b/server/test/helpers/heartbeatTestHarness.ts @@ -0,0 +1,179 @@ +import { jest } from "@jest/globals"; +import { SuperSimpleQueueHelper } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import { StatusService } from "../../src/service/infrastructure/statusService.ts"; +import { IncidentService } from "../../src/service/business/incidentService.ts"; +import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts"; +import { InMemoryIncidentsRepository } from "./InMemoryIncidentsRepository.ts"; +import { createMockLogger } from "./createMockLogger.ts"; +import type { Monitor } from "../../src/types/monitor.ts"; +import type { MonitorStatusResponse } from "../../src/types/network.ts"; +import type { Check } from "../../src/types/check.ts"; +import type { MaintenanceWindow } from "../../src/types/maintenanceWindow.ts"; + +let checkCounter = 0; + +export const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + name: "Test Monitor", + type: "http", + url: "https://example.com", + status: "up", + // Pre-fill window so threshold evaluation runs immediately. + // Without this, the warm-up path sets monitor.status directly + // from each check, bypassing the threshold logic. + statusWindow: [true, true, true, true, true], + statusWindowSize: 5, + statusWindowThreshold: 60, + recentChecks: [], + notifications: [], + ...overrides, + }) as Monitor; + +export const makeStatusResponse = (status: boolean, code: number): MonitorStatusResponse => ({ + monitorId: "mon-1", + teamId: "team-1", + type: "http", + status, + code, + message: status ? "OK" : "Service Unavailable", + responseTime: status ? 150 : 0, +}); + +export const makeCheck = (status: boolean, code: number): Check => { + const now = new Date().toISOString(); + return { + id: `check-${++checkCounter}`, + metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" }, + status, + statusCode: code, + responseTime: status ? 150 : 0, + message: status ? "OK" : "Service Unavailable", + createdAt: now, + updatedAt: now, + }; +}; + +const createStubMonitorStatsRepo = () => ({ + findByMonitorId: jest.fn().mockRejectedValue(new Error("no stats")), + create: jest.fn().mockResolvedValue({}), + updateByMonitorId: jest.fn().mockResolvedValue({}), + deleteByMonitorId: jest.fn(), + deleteByMonitorIds: jest.fn(), + deleteByMonitorIdsNotIn: jest.fn(), +}); + +const createStubChecksRepo = () => ({ + create: jest.fn(), + createChecks: jest.fn(), + findByMonitorId: jest.fn(), + findByTeamId: jest.fn(), + findLatestByMonitorIds: jest.fn(), + findByDateRangeAndMonitorId: jest.fn(), + findSummaryByTeamId: jest.fn(), + deleteByMonitorId: jest.fn(), + deleteByTeamId: jest.fn(), + deleteByMonitorIdsNotIn: jest.fn(), + deleteOlderThan: jest.fn(), +}); + +export interface HeartbeatTestHarness { + monitorsRepo: InMemoryMonitorsRepository; + incidentsRepo: InMemoryIncidentsRepository; + statusService: StatusService; + incidentService: IncidentService; + notificationsService: { handleNotifications: jest.Mock }; + networkService: { requestStatus: jest.Mock }; + bufferStub: { addToBuffer: jest.Mock; addGeoCheckToBuffer: jest.Mock; scheduleNextFlush: jest.Mock }; + maintenanceWindowsRepo: { findByMonitorId: jest.Mock }; + messageBuilder: { extractThresholdBreaches: jest.Mock }; + heartbeatJob: (monitor: Monitor) => Promise; + setNextResponse: (status: boolean, code: number) => void; + setNextResponseFull: (response: MonitorStatusResponse) => void; +} + +export function createHeartbeatTestHarness(): HeartbeatTestHarness { + checkCounter = 0; + + const monitorsRepo = new InMemoryMonitorsRepository(); + const incidentsRepo = new InMemoryIncidentsRepository(); + const logger = createMockLogger() as any; + const bufferStub = { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn(), scheduleNextFlush: jest.fn() }; + + const statusService = new StatusService( + logger, + bufferStub as any, + monitorsRepo as any, + createStubMonitorStatsRepo() as any, + createStubChecksRepo() as any + ); + + const messageBuilder = { extractThresholdBreaches: jest.fn().mockReturnValue([]) }; + const incidentService = new IncidentService(logger, incidentsRepo, monitorsRepo as any, { findById: jest.fn() } as any, messageBuilder as any); + + const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(true) }; + + let nextResponse: MonitorStatusResponse | null = null; + let nextStatus = true; + let nextCode = 200; + const networkService = { + requestStatus: jest.fn().mockImplementation(() => { + if (nextResponse) { + return Promise.resolve(nextResponse); + } + return Promise.resolve(makeStatusResponse(nextStatus, nextCode)); + }), + }; + const checkService = { + buildCheck: jest.fn().mockImplementation((response: MonitorStatusResponse) => { + return makeCheck(response.status, response.code); + }), + }; + + const setNextResponse = (status: boolean, code: number) => { + nextResponse = null; + nextStatus = status; + nextCode = code; + }; + + const setNextResponseFull = (response: MonitorStatusResponse) => { + nextResponse = response; + }; + + const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) }; + + const helper = new SuperSimpleQueueHelper( + logger, + networkService as any, + statusService as any, + notificationsService as any, + checkService as any, + { getDBSettings: jest.fn() } as any, + bufferStub as any, + incidentService as any, + maintenanceWindowsRepo as any, + monitorsRepo as any, + { findAllTeamIds: jest.fn() } as any, + createStubMonitorStatsRepo() as any, + createStubChecksRepo() as any, + incidentsRepo as any, + { buildGeoCheck: jest.fn() } as any, + { deleteByMonitorIdsNotIn: jest.fn() } as any + ); + + return { + monitorsRepo, + incidentsRepo, + statusService, + incidentService, + notificationsService, + networkService, + bufferStub, + maintenanceWindowsRepo, + messageBuilder, + heartbeatJob: helper.getHeartbeatJob(), + setNextResponse, + setNextResponseFull, + }; +} diff --git a/server/test/helpers/makeStatusPage.ts b/server/test/helpers/makeStatusPage.ts new file mode 100644 index 000000000..23c13bca1 --- /dev/null +++ b/server/test/helpers/makeStatusPage.ts @@ -0,0 +1,14 @@ +import type { StatusPage } from "../../src/types/index.ts"; + +export const makeStatusPage = (overrides?: Partial): StatusPage => + ({ + id: "sp-1", + teamId: "team-1", + userId: "user-1", + url: "my-status-page", + companyName: "Test Co", + monitors: ["mon-1"], + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as StatusPage; diff --git a/server/test/helpers/notificationMessage.ts b/server/test/helpers/notificationMessage.ts new file mode 100644 index 000000000..c25644c81 --- /dev/null +++ b/server/test/helpers/notificationMessage.ts @@ -0,0 +1,51 @@ +import type { Notification } from "../../src/types/index.ts"; +import type { NotificationMessage } from "../../src/types/notificationMessage.ts"; + +export const makeNotification = (overrides?: Partial): Notification => + ({ + address: "https://hooks.example.com/webhook", + accessToken: "token-abc", + homeserverUrl: "https://matrix.example.com", + roomId: "!room:example.com", + ...overrides, + }) as Notification; + +export const makeMessage = (overrides?: Partial): NotificationMessage => ({ + type: "monitor_down", + severity: "critical", + monitor: { id: "mon-1", name: "Test Monitor", url: "https://example.com", type: "http", status: "down" }, + content: { + title: "Monitor Down: Test Monitor", + summary: 'Monitor "Test Monitor" is currently down.', + details: ["URL: https://example.com", "Status: Down"], + timestamp: new Date("2025-01-01T00:00:00Z"), + }, + clientHost: "https://app.example.com", + metadata: { teamId: "team-1", notificationReason: "status_change" }, + ...overrides, +}); + +export const makeMessageWithThresholds = (): NotificationMessage => + makeMessage({ + type: "threshold_breach", + severity: "warning", + monitor: { id: "mon-1", name: "Infra Server", url: "https://infra.example.com", type: "hardware", status: "up" }, + content: { + title: "Threshold Exceeded: Infra Server", + summary: "Thresholds exceeded.", + details: ["URL: https://infra.example.com"], + thresholds: [ + { metric: "cpu", currentValue: 90, threshold: 80, unit: "%", formattedValue: "90.0%" }, + { metric: "memory", currentValue: 85, threshold: 70, unit: "%", formattedValue: "85.0%" }, + ], + timestamp: new Date("2025-01-01T00:00:00Z"), + }, + }); + +export const makeMessageWithIncident = (): NotificationMessage => + makeMessage({ + content: { + ...makeMessage().content, + incident: { id: "inc-1", url: "https://app.example.com/incidents/inc-1", createdAt: new Date("2025-01-01T00:00:00Z") }, + }, + }); diff --git a/server/test/helpers/notificationProviderContract.ts b/server/test/helpers/notificationProviderContract.ts new file mode 100644 index 000000000..f95048f24 --- /dev/null +++ b/server/test/helpers/notificationProviderContract.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "@jest/globals"; +import type { INotificationProvider } from "../../src/service/infrastructure/notificationProviders/INotificationProvider.ts"; +import type { Notification } from "../../src/types/index.ts"; +import type { NotificationMessage } from "../../src/types/notificationMessage.ts"; + +const makeMessage = (): NotificationMessage => ({ + type: "monitor_down", + severity: "critical", + monitor: { id: "mon-1", name: "Test", url: "https://example.com", type: "http", status: "down" }, + content: { + title: "Monitor Down: Test", + summary: "Test is down", + details: ["URL: https://example.com"], + timestamp: new Date("2025-01-01T00:00:00Z"), + }, + clientHost: "https://app.example.com", + metadata: { teamId: "team-1", notificationReason: "status_change" }, +}); + +export const testNotificationProviderContract = ( + name: string, + opts: { + create: () => INotificationProvider; + makeNotification: () => Partial; + } +) => { + describe(`INotificationProvider contract: ${name}`, () => { + it("sendTestAlert returns a boolean", async () => { + const provider = opts.create(); + const result = await provider.sendTestAlert(opts.makeNotification()); + expect(typeof result).toBe("boolean"); + }); + + it("sendMessage returns a boolean", async () => { + const provider = opts.create(); + const result = await provider.sendMessage(opts.makeNotification() as Notification, makeMessage()); + expect(typeof result).toBe("boolean"); + }); + }); +}; diff --git a/server/test/helpers/statusProviderContract.ts b/server/test/helpers/statusProviderContract.ts new file mode 100644 index 000000000..6f7c89466 --- /dev/null +++ b/server/test/helpers/statusProviderContract.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@jest/globals"; +import type { IStatusProvider } from "../../src/service/infrastructure/network/IStatusProvider.ts"; +import type { Monitor } from "../../src/types/index.ts"; + +/** + * Shared contract tests for all IStatusProvider implementations. + * Tests the interface guarantees that every provider must satisfy. + */ +export const testStatusProviderContract = ( + name: string, + opts: { + create: () => IStatusProvider; + supportedType: string; + unsupportedType: string; + makeMonitor: () => Monitor; + } +) => { + describe(`IStatusProvider contract: ${name}`, () => { + it("has a readonly type property matching the supported type", () => { + const provider = opts.create(); + expect(provider.type).toBe(opts.supportedType); + }); + + it("supports() returns true for its own type", () => { + const provider = opts.create(); + expect(provider.supports(opts.supportedType as any)).toBe(true); + }); + + it("supports() returns false for a different type", () => { + const provider = opts.create(); + expect(provider.supports(opts.unsupportedType as any)).toBe(false); + }); + + it("handle() returns an object with required MonitorStatusResponse fields", async () => { + const provider = opts.create(); + try { + const result = await provider.handle(opts.makeMonitor()); + expect(result).toEqual( + expect.objectContaining({ + monitorId: expect.anything(), + teamId: expect.anything(), + type: expect.anything(), + status: expect.any(Boolean), + code: expect.any(Number), + message: expect.any(String), + }) + ); + } catch (err: any) { + // Some providers throw AppError on failure — that's also valid behavior + expect(err).toBeDefined(); + } + }); + }); +}; diff --git a/server/test/integration/heartbeatJobDownDetection.test.ts b/server/test/integration/heartbeatJobDownDetection.test.ts new file mode 100644 index 000000000..71628aabe --- /dev/null +++ b/server/test/integration/heartbeatJobDownDetection.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, beforeEach } from "@jest/globals"; +import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts"; + +describe("Heartbeat job: down detection", () => { + let h: HeartbeatTestHarness; + + beforeEach(() => { + h = createHeartbeatTestHarness(); + }); + + it("transitions monitor to down and creates incident after threshold failures", async () => { + // Monitor starts with full passing window: [true, true, true, true, true] + const monitor = makeMonitor(); + h.monitorsRepo.seed(monitor); + + // 2 failures: window = [true, true, true, false, false] = 40% < 60% + h.setNextResponse(false, 503); + await h.heartbeatJob(monitor); + await h.heartbeatJob(monitor); + + let storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1"); + expect(storedMonitor.status).toBe("up"); + expect(h.incidentsRepo.getAll()).toHaveLength(0); + + // 3rd failure: window = [true, true, false, false, false] = 60% >= 60% + await h.heartbeatJob(monitor); + + storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1"); + expect(storedMonitor.status).toBe("down"); + + // Incident should have been created + const incidents = h.incidentsRepo.getAll(); + expect(incidents).toHaveLength(1); + expect(incidents[0].monitorId).toBe("mon-1"); + expect(incidents[0].status).toBe(true); + expect(incidents[0].statusCode).toBe(503); + + // Notification should have been triggered + expect(h.notificationsService.handleNotifications).toHaveBeenCalled(); + }); + + it("does not create duplicate incidents on continued failures", async () => { + const monitor = makeMonitor(); + h.monitorsRepo.seed(monitor); + + h.setNextResponse(false, 500); + + // 3 failures trigger down, 4 more continue failing + for (let i = 0; i < 7; i++) { + await h.heartbeatJob(monitor); + } + + // Only one incident despite 7 heartbeats + expect(h.incidentsRepo.getAll()).toHaveLength(1); + }); + + it("stays up when failures are below threshold", async () => { + const monitor = makeMonitor(); + h.monitorsRepo.seed(monitor); + + // 2 failures: window = [true, true, true, false, false] = 40% < 60% + h.setNextResponse(false, 500); + await h.heartbeatJob(monitor); + await h.heartbeatJob(monitor); + + // 3 passes: window = [false, false, true, true, true] = 40% < 60% + h.setNextResponse(true, 200); + await h.heartbeatJob(monitor); + await h.heartbeatJob(monitor); + await h.heartbeatJob(monitor); + + const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1"); + expect(storedMonitor.status).toBe("up"); + expect(h.incidentsRepo.getAll()).toHaveLength(0); + // Regression guard for #3438: a sub-threshold blip must produce NO notification + // dispatches. Before PR #3505, a failing check below threshold silently wrote + // status="down" and the next passing check fired a spurious "Recovered" event. + expect(h.notificationsService.handleNotifications).not.toHaveBeenCalled(); + }); + + it("fires zero notifications on a sub-threshold blip (regression #3438)", async () => { + // Dedicated end-to-end guard for the #3438 bug. Distinct from "stays up when + // failures are below threshold" because it pins the notification-dispatch + // contract independently of status/incident assertions, so a future refactor + // that reintroduces the silent-down-write can't sneak past by happening to + // leave status and incidents in the right shape. + const monitor = makeMonitor(); + h.monitorsRepo.seed(monitor); + + // Failing check below threshold (window [t,t,t,t,f], 20%) + h.setNextResponse(false, 503); + await h.heartbeatJob(monitor); + + // Passing check — pre-fix, this is where the spurious "Recovered" fired + h.setNextResponse(true, 200); + await h.heartbeatJob(monitor); + + expect((await h.monitorsRepo.findById("mon-1", "team-1")).status).toBe("up"); + expect(h.incidentsRepo.getAll()).toHaveLength(0); + expect(h.notificationsService.handleNotifications).not.toHaveBeenCalled(); + }); +}); diff --git a/server/test/integration/heartbeatJobMaintenance.test.ts b/server/test/integration/heartbeatJobMaintenance.test.ts new file mode 100644 index 000000000..663c2e61e --- /dev/null +++ b/server/test/integration/heartbeatJobMaintenance.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, beforeEach } from "@jest/globals"; +import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts"; +import type { MaintenanceWindow } from "../../src/types/maintenanceWindow.ts"; + +const makeMaintenanceWindow = (overrides?: Partial): MaintenanceWindow => { + const now = new Date(); + const start = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago + const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now + return { + id: "mw-1", + monitorId: "mon-1", + teamId: "team-1", + active: true, + name: "Test Maintenance", + duration: 2, + durationUnit: "hours", + repeat: 0, + start: start.toISOString(), + end: end.toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + }; +}; + +describe("Heartbeat job: maintenance windows", () => { + let h: HeartbeatTestHarness; + + beforeEach(() => { + h = createHeartbeatTestHarness(); + }); + + it("skips checks and sets monitor to maintenance when window is active", async () => { + const monitor = makeMonitor(); + h.monitorsRepo.seed(monitor); + + // Configure an active maintenance window covering the current time + h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow()]); + + await h.heartbeatJob(monitor); + + // Monitor status should be set to maintenance + const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1"); + expect(storedMonitor.status).toBe("maintenance"); + + // No network request should have been made + expect(h.networkService.requestStatus).not.toHaveBeenCalled(); + + // No check should have been buffered + expect(h.bufferStub.addToBuffer).not.toHaveBeenCalled(); + + // No incident should have been created + expect(h.incidentsRepo.getAll()).toHaveLength(0); + + // No notification should have been sent + expect(h.notificationsService.handleNotifications).not.toHaveBeenCalled(); + }); + + it("does not update status if monitor is already in maintenance", async () => { + const monitor = makeMonitor({ status: "maintenance" }); + h.monitorsRepo.seed(monitor); + + h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow()]); + + await h.heartbeatJob(monitor); + + // findById reads the stored monitor; updateById should not have been called + // since status was already "maintenance" + const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1"); + expect(storedMonitor.status).toBe("maintenance"); + expect(h.networkService.requestStatus).not.toHaveBeenCalled(); + }); + + it("resumes normal checks when maintenance window expires", async () => { + const monitor = makeMonitor(); + h.monitorsRepo.seed(monitor); + + // First heartbeat: inside maintenance window + h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow()]); + await h.heartbeatJob(monitor); + + let storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1"); + expect(storedMonitor.status).toBe("maintenance"); + + // Second heartbeat: maintenance window expired (no windows returned) + h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([]); + h.setNextResponse(true, 200); + await h.heartbeatJob(monitor); + + // Network request should have been made this time + expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1); + + // Check should have been buffered + expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1); + }); + + it("skips checks when window is inactive (active: false)", async () => { + const monitor = makeMonitor(); + h.monitorsRepo.seed(monitor); + + // Inactive maintenance window — should be ignored + h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow({ active: false })]); + + h.setNextResponse(true, 200); + await h.heartbeatJob(monitor); + + // Normal check should proceed since window is inactive + expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1); + expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1); + }); + + it("skips checks when window is in the past (expired)", async () => { + const monitor = makeMonitor(); + h.monitorsRepo.seed(monitor); + + // Window ended an hour ago + const pastEnd = new Date(Date.now() - 60 * 60 * 1000); + const pastStart = new Date(pastEnd.getTime() - 2 * 60 * 60 * 1000); + h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([ + makeMaintenanceWindow({ start: pastStart.toISOString(), end: pastEnd.toISOString() }), + ]); + + h.setNextResponse(true, 200); + await h.heartbeatJob(monitor); + + // Window is in the past — normal check should proceed + expect(h.networkService.requestStatus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/test/integration/heartbeatJobNotificationFailure.test.ts b/server/test/integration/heartbeatJobNotificationFailure.test.ts new file mode 100644 index 000000000..bb231aa71 --- /dev/null +++ b/server/test/integration/heartbeatJobNotificationFailure.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, beforeEach } from "@jest/globals"; +import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts"; + +describe("Heartbeat job: notification failure isolation", () => { + let h: HeartbeatTestHarness; + + beforeEach(() => { + h = createHeartbeatTestHarness(); + }); + + it("creates incident even when notification dispatch throws", async () => { + const monitor = makeMonitor(); + h.monitorsRepo.seed(monitor); + + // Make notifications throw + h.notificationsService.handleNotifications.mockRejectedValue(new Error("SMTP connection refused")); + + // Drive monitor down + h.setNextResponse(false, 503); + for (let i = 0; i < 3; i++) { + await h.heartbeatJob(monitor); + } + + // Incident should still have been created + const incidents = h.incidentsRepo.getAll(); + expect(incidents).toHaveLength(1); + expect(incidents[0].status).toBe(true); + expect(incidents[0].statusCode).toBe(503); + + // Monitor should still be marked down + const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1"); + expect(storedMonitor.status).toBe("down"); + }); + + it("auto-resolves incident even when recovery notification throws", async () => { + const monitor = makeMonitor(); + h.monitorsRepo.seed(monitor); + + // Drive monitor down (notifications succeed here) + h.setNextResponse(false, 503); + for (let i = 0; i < 3; i++) { + await h.heartbeatJob(monitor); + } + expect(h.incidentsRepo.getAll()[0].status).toBe(true); + + // Now make notifications throw for the recovery + h.notificationsService.handleNotifications.mockRejectedValue(new Error("Slack API timeout")); + + // Recover + h.setNextResponse(true, 200); + for (let i = 0; i < 5; i++) { + await h.heartbeatJob(monitor); + } + + // Incident should still be resolved despite notification failure + const incidents = h.incidentsRepo.getAll(); + expect(incidents).toHaveLength(1); + expect(incidents[0].status).toBe(false); + expect(incidents[0].resolutionType).toBe("automatic"); + }); +}); diff --git a/server/test/integration/heartbeatJobRecovery.test.ts b/server/test/integration/heartbeatJobRecovery.test.ts new file mode 100644 index 000000000..10b6a32ca --- /dev/null +++ b/server/test/integration/heartbeatJobRecovery.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, beforeEach } from "@jest/globals"; +import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts"; + +describe("Heartbeat job: recovery", () => { + let h: HeartbeatTestHarness; + + beforeEach(() => { + h = createHeartbeatTestHarness(); + }); + + it("recovers monitor and auto-resolves incident when checks pass", async () => { + const monitor = makeMonitor(); + h.monitorsRepo.seed(monitor); + + // 3 failures to trigger down (60% threshold) + h.setNextResponse(false, 503); + for (let i = 0; i < 3; i++) { + await h.heartbeatJob(monitor); + } + expect(h.incidentsRepo.getAll()).toHaveLength(1); + expect(h.incidentsRepo.getAll()[0].status).toBe(true); + + // Send passing checks to push failure rate below threshold + // Window after 3 fails: [false, false, false, false, false] (prior trues shifted out) + // Need enough passes to drop below 60% + h.setNextResponse(true, 200); + for (let i = 0; i < 5; i++) { + await h.heartbeatJob(monitor); + } + + // Monitor should be back up + const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1"); + expect(storedMonitor.status).toBe("up"); + + // Incident should be auto-resolved + const incidents = h.incidentsRepo.getAll(); + expect(incidents).toHaveLength(1); + expect(incidents[0].status).toBe(false); + expect(incidents[0].resolutionType).toBe("automatic"); + expect(incidents[0].endTime).not.toBeNull(); + }); + + it("creates a new incident after recovery if monitor goes down again", async () => { + const monitor = makeMonitor(); + h.monitorsRepo.seed(monitor); + + // First outage: 3 failures to trigger (window shifts from all-true) + h.setNextResponse(false, 503); + for (let i = 0; i < 3; i++) { + await h.heartbeatJob(monitor); + } + + // Recovery: enough passes to drop below threshold + h.setNextResponse(true, 200); + for (let i = 0; i < 5; i++) { + await h.heartbeatJob(monitor); + } + + // Second outage: 3 more failures to trigger again + h.setNextResponse(false, 502); + for (let i = 0; i < 3; i++) { + await h.heartbeatJob(monitor); + } + + const incidents = h.incidentsRepo.getAll(); + expect(incidents).toHaveLength(2); + + // First incident resolved + expect(incidents[0].status).toBe(false); + expect(incidents[0].resolutionType).toBe("automatic"); + expect(incidents[0].statusCode).toBe(503); + + // Second incident active + expect(incidents[1].status).toBe(true); + expect(incidents[1].statusCode).toBe(502); + }); +}); diff --git a/server/test/integration/heartbeatJobThresholdBreach.test.ts b/server/test/integration/heartbeatJobThresholdBreach.test.ts new file mode 100644 index 000000000..ccb4065fa --- /dev/null +++ b/server/test/integration/heartbeatJobThresholdBreach.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, beforeEach } from "@jest/globals"; +import { createHeartbeatTestHarness, makeMonitor, type HeartbeatTestHarness } from "../helpers/heartbeatTestHarness.ts"; +import type { MonitorStatusResponse } from "../../src/types/network.ts"; +import type { HardwareStatusPayload } from "../../src/types/network.ts"; + +const makeHardwareResponse = (cpuUsage: number): MonitorStatusResponse => ({ + monitorId: "mon-1", + teamId: "team-1", + type: "hardware", + status: true, + code: 200, + message: "OK", + responseTime: 50, + payload: { + data: { + cpu: { usage_percent: cpuUsage, temperature: [] }, + memory: { usage_percent: 0.3 }, + disk: [{ usage_percent: 0.2 }], + }, + }, +}); + +describe("Heartbeat job: hardware threshold breach", () => { + let h: HeartbeatTestHarness; + + beforeEach(() => { + h = createHeartbeatTestHarness(); + }); + + it("transitions to breached and creates incident after alert counter hits zero", async () => { + // Hardware monitor with CPU threshold at 80% (stored as 80, compared as 80/100 = 0.8) + // Alert counters start at 5, decrement each breach, trigger at 0 + const monitor = makeMonitor({ + type: "hardware", + status: "up", + cpuAlertThreshold: 80, + cpuAlertCounter: 5, + memoryAlertThreshold: 100, + memoryAlertCounter: 5, + diskAlertThreshold: 100, + diskAlertCounter: 5, + tempAlertThreshold: 100, + tempAlertCounter: 5, + selectedDisks: [], + }); + h.monitorsRepo.seed(monitor); + + // CPU at 90% (0.9) exceeds threshold of 80% (0.8) + h.setNextResponseFull(makeHardwareResponse(0.9)); + + // Configure message builder for when incident is created + h.messageBuilder.extractThresholdBreaches.mockReturnValue([{ metric: "cpu", formattedValue: "90%", threshold: 80, unit: "%" }]); + + // 4 heartbeats: counter goes 5→4→3→2→1, status still "up" + for (let i = 0; i < 4; i++) { + await h.heartbeatJob(monitor); + } + + let storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1"); + expect(storedMonitor.status).toBe("up"); + expect(storedMonitor.cpuAlertCounter).toBe(1); + expect(h.incidentsRepo.getAll()).toHaveLength(0); + + // 5th heartbeat: counter hits 0, status transitions to "breached" + await h.heartbeatJob(monitor); + + storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1"); + expect(storedMonitor.status).toBe("breached"); + expect(storedMonitor.cpuAlertCounter).toBe(0); + + // Incident created with statusCode 9999 and threshold message + const incidents = h.incidentsRepo.getAll(); + expect(incidents).toHaveLength(1); + expect(incidents[0].statusCode).toBe(9999); + expect(incidents[0].message).toBe("CPU: 90% (threshold: 80%)"); + expect(incidents[0].status).toBe(true); + + // Notification should have been triggered + expect(h.notificationsService.handleNotifications).toHaveBeenCalled(); + }); + + it("recovers from breached to up when metrics return to normal", async () => { + // Start with a monitor already in breached state with counter at 0 + const monitor = makeMonitor({ + type: "hardware", + status: "breached", + cpuAlertThreshold: 80, + cpuAlertCounter: 0, + memoryAlertThreshold: 100, + memoryAlertCounter: 5, + diskAlertThreshold: 100, + diskAlertCounter: 5, + tempAlertThreshold: 100, + tempAlertCounter: 5, + selectedDisks: [], + }); + h.monitorsRepo.seed(monitor); + + // Also seed an active incident for this monitor + await h.incidentsRepo.create({ + monitorId: "mon-1", + teamId: "team-1", + status: true, + statusCode: 9999, + message: "CPU: 90% (threshold: 80%)", + }); + + // CPU at 50% (0.5) — below threshold of 80% (0.8) + h.setNextResponseFull(makeHardwareResponse(0.5)); + + await h.heartbeatJob(monitor); + + // Monitor should recover to "up" + const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1"); + expect(storedMonitor.status).toBe("up"); + // Counter resets to 5 when not breaching + expect(storedMonitor.cpuAlertCounter).toBe(5); + + // Incident should be auto-resolved + const incidents = h.incidentsRepo.getAll(); + expect(incidents).toHaveLength(1); + expect(incidents[0].status).toBe(false); + expect(incidents[0].resolutionType).toBe("automatic"); + }); + + it("transitions breached hardware monitor to down when reachability also fails", async () => { + // A hardware monitor currently in "breached" state starts failing its reachability + // checks enough to cross the statusWindow threshold. "down" must take precedence + // over "breached" — when both conditions are true, the user wants the escalated + // alert. Unit-covered in statusService.test.ts via computeReachability; this is + // the end-to-end counterpart that exercises the full heartbeat path including + // incident-lifecycle handling. + const monitor = makeMonitor({ + type: "hardware", + status: "breached", + cpuAlertThreshold: 80, + cpuAlertCounter: 0, + memoryAlertThreshold: 100, + memoryAlertCounter: 5, + diskAlertThreshold: 100, + diskAlertCounter: 5, + tempAlertThreshold: 100, + tempAlertCounter: 5, + selectedDisks: [], + // Window is fully healthy — the monitor is breached from hardware metrics, + // not from reachability. The test drives the window from clean → failing. + statusWindow: [true, true, true, true, true], + }); + h.monitorsRepo.seed(monitor); + + // Seed an active breached incident matching the initial state. + await h.incidentsRepo.create({ + monitorId: "mon-1", + teamId: "team-1", + status: true, + statusCode: 9999, + message: "CPU: 90% (threshold: 80%)", + }); + + // Drive reachability failure: 3 consecutive failing heartbeats. + // Window evolves: [t,t,t,t,t] → [t,t,t,t,f] (20%) → [t,t,t,f,f] (40%) → [t,t,f,f,f] (60%) + // At 60% >= threshold, and current status "breached" !== "down", reachability fires. + // Reachability sends HTTP-shaped responses with no hardware payload, so the + // hardware block is correctly skipped for these checks. + h.setNextResponse(false, 503); + for (let i = 0; i < 3; i++) { + await h.heartbeatJob(monitor); + } + + const stored = await h.monitorsRepo.findById("mon-1", "team-1"); + expect(stored.status).toBe("down"); + + // Notification dispatch must have fired for the transition. + expect(h.notificationsService.handleNotifications).toHaveBeenCalled(); + }); + + it("does not create duplicate incidents while remaining breached", async () => { + const monitor = makeMonitor({ + type: "hardware", + status: "up", + cpuAlertThreshold: 80, + cpuAlertCounter: 5, + memoryAlertThreshold: 100, + memoryAlertCounter: 5, + diskAlertThreshold: 100, + diskAlertCounter: 5, + tempAlertThreshold: 100, + tempAlertCounter: 5, + selectedDisks: [], + }); + h.monitorsRepo.seed(monitor); + + h.setNextResponseFull(makeHardwareResponse(0.9)); + h.messageBuilder.extractThresholdBreaches.mockReturnValue([{ metric: "cpu", formattedValue: "90%", threshold: 80, unit: "%" }]); + + // 5 heartbeats to trigger breach, then 5 more while still breaching + for (let i = 0; i < 10; i++) { + await h.heartbeatJob(monitor); + } + + // Only one incident despite 10 heartbeats + expect(h.incidentsRepo.getAll()).toHaveLength(1); + }); +}); diff --git a/server/test/integration/incidentLifecycle.test.ts b/server/test/integration/incidentLifecycle.test.ts new file mode 100644 index 000000000..d2429a812 --- /dev/null +++ b/server/test/integration/incidentLifecycle.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { IncidentService } from "../../src/service/business/incidentService.ts"; +import { InMemoryIncidentsRepository } from "../helpers/InMemoryIncidentsRepository.ts"; +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"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + name: "Test Monitor", + type: "http", + url: "https://example.com", + status: "down", + ...overrides, + }) as Monitor; + +const makeDecision = (overrides?: Partial): MonitorActionDecision => ({ + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: false, + incidentReason: null, + notificationReason: null, + ...overrides, +}); + +// ── Test suite ─────────────────────────────────────────────────────────────── + +describe("Incident lifecycle (integration)", () => { + let repo: InMemoryIncidentsRepository; + let service: IncidentService; + let monitorsRepo: jest.Mocked; + let usersRepo: jest.Mocked; + let messageBuilder: jest.Mocked; + + beforeEach(() => { + repo = new InMemoryIncidentsRepository(); + monitorsRepo = { findById: jest.fn() } as unknown as jest.Mocked; + usersRepo = { findById: jest.fn() } as unknown as jest.Mocked; + messageBuilder = { extractThresholdBreaches: jest.fn() } as unknown as jest.Mocked; + service = new IncidentService(createMockLogger() as any, repo, monitorsRepo, usersRepo, messageBuilder); + }); + + // ── Creation ───────────────────────────────────────────────────────────── + + it("creates an incident when monitor goes down", async () => { + const monitor = makeMonitor({ status: "down" }); + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }); + + const incident = await service.handleIncident(monitor, 503, decision); + + expect(incident).not.toBeNull(); + expect(incident!.monitorId).toBe("mon-1"); + expect(incident!.teamId).toBe("team-1"); + expect(incident!.status).toBe(true); + expect(incident!.statusCode).toBe(503); + expect(incident!.endTime).toBeNull(); + expect(incident!.resolutionType).toBeNull(); + + const stored = repo.getAll(); + expect(stored).toHaveLength(1); + expect(stored[0].id).toBe(incident!.id); + }); + + // ── Idempotency ────────────────────────────────────────────────────────── + + it("does not create a duplicate incident for the same monitor", async () => { + const monitor = makeMonitor({ status: "down" }); + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }); + + const first = await service.handleIncident(monitor, 503, decision); + const second = await service.handleIncident(monitor, 503, decision); + + expect(second!.id).toBe(first!.id); + expect(repo.getAll()).toHaveLength(1); + }); + + // ── Auto-resolve ───────────────────────────────────────────────────────── + + it("auto-resolves an incident when monitor recovers", async () => { + const monitor = makeMonitor({ status: "down" }); + const createDecision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }); + const created = await service.handleIncident(monitor, 503, createDecision); + + const resolveDecision = makeDecision({ shouldResolveIncident: true }); + const resolved = await service.handleIncident(monitor, 200, resolveDecision); + + expect(resolved).not.toBeNull(); + expect(resolved!.id).toBe(created!.id); + expect(resolved!.status).toBe(false); + expect(resolved!.endTime).toBeDefined(); + expect(resolved!.resolutionType).toBe("automatic"); + }); + + // ── Resolve with nothing active ────────────────────────────────────────── + + it("returns null when resolving with no active incident", async () => { + const monitor = makeMonitor({ status: "up" }); + const decision = makeDecision({ shouldResolveIncident: true }); + + const result = await service.handleIncident(monitor, 200, decision); + + expect(result).toBeNull(); + }); + + // ── Manual resolution ──────────────────────────────────────────────────── + + it("manually resolves an active incident with comment", async () => { + const monitor = makeMonitor({ status: "down" }); + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }); + const created = await service.handleIncident(monitor, 500, decision); + + const resolved = await service.resolveIncident(created!.id, "user-1", "team-1", "Root cause identified", "user@test.com"); + + expect(resolved.status).toBe(false); + expect(resolved.resolutionType).toBe("manual"); + expect(resolved.resolvedBy).toBe("user-1"); + expect(resolved.resolvedByEmail).toBe("user@test.com"); + expect(resolved.comment).toBe("Root cause identified"); + expect(resolved.endTime).toBeDefined(); + }); + + it("throws when manually resolving an already-resolved incident", async () => { + const monitor = makeMonitor({ status: "down" }); + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }); + const created = await service.handleIncident(monitor, 500, decision); + + await service.resolveIncident(created!.id, "user-1", "team-1"); + + await expect(service.resolveIncident(created!.id, "user-1", "team-1")).rejects.toThrow("Incident not found"); + }); + + // ── Threshold breach ───────────────────────────────────────────────────── + + it("creates a threshold breach incident with statusCode 9999 and descriptive message", async () => { + const monitor = makeMonitor({ status: "breached", type: "hardware" }); + (messageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([ + { metric: "cpu", formattedValue: "92%", threshold: 80, unit: "%" }, + { metric: "memory", formattedValue: "88%", threshold: 85, unit: "%" }, + ]); + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" }); + + const incident = await service.handleIncident(monitor, 200, decision, { monitorId: "mon-1" } as any); + + expect(incident!.statusCode).toBe(9999); + expect(incident!.message).toBe("CPU: 92% (threshold: 80%), MEMORY: 88% (threshold: 85%)"); + }); + + // ── No action ──────────────────────────────────────────────────────────── + + it("returns null when no action is needed", async () => { + const monitor = makeMonitor({ status: "up" }); + const decision = makeDecision(); + + const result = await service.handleIncident(monitor, 200, decision); + + expect(result).toBeNull(); + expect(repo.getAll()).toHaveLength(0); + }); + + // ── Full lifecycle ─────────────────────────────────────────────────────── + + it("handles a complete lifecycle: create -> manual resolve -> new incident -> auto-resolve", async () => { + const monitor = makeMonitor({ status: "down" }); + + // First outage + const first = await service.handleIncident(monitor, 503, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" })); + expect(first!.status).toBe(true); + + // Manually resolved + await service.resolveIncident(first!.id, "user-1", "team-1", "Restarted server"); + + // Second outage — new incident since previous was resolved + const second = await service.handleIncident(monitor, 502, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" })); + expect(second!.id).not.toBe(first!.id); + expect(second!.statusCode).toBe(502); + + // Auto-resolve + const resolved = await service.handleIncident(monitor, 200, makeDecision({ shouldResolveIncident: true })); + expect(resolved!.id).toBe(second!.id); + expect(resolved!.status).toBe(false); + expect(resolved!.resolutionType).toBe("automatic"); + + // Both incidents stored, both resolved + const all = repo.getAll(); + expect(all).toHaveLength(2); + expect(all.every((i) => i.status === false)).toBe(true); + }); + + // ── Cross-monitor isolation ────────────────────────────────────────────── + + it("incidents for different monitors do not interfere", async () => { + const monitorA = makeMonitor({ id: "mon-a", status: "down" }); + const monitorB = makeMonitor({ id: "mon-b", status: "down" }); + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" }); + + const incidentA = await service.handleIncident(monitorA, 500, decision); + const incidentB = await service.handleIncident(monitorB, 502, decision); + + expect(incidentA!.id).not.toBe(incidentB!.id); + expect(repo.getAll()).toHaveLength(2); + + // Resolving monitor A does not affect monitor B + const resolved = await service.handleIncident(monitorA, 200, makeDecision({ shouldResolveIncident: true })); + expect(resolved!.id).toBe(incidentA!.id); + + const bStillActive = await repo.findActiveByMonitorId("mon-b", "team-1"); + expect(bStillActive).not.toBeNull(); + expect(bStillActive!.status).toBe(true); + }); +}); diff --git a/server/test/monitorService.test.ts b/server/test/monitorService.test.ts deleted file mode 100644 index 32708a42e..000000000 --- a/server/test/monitorService.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { jest } from "@jest/globals"; -import { MonitorService } from "../src/service/business/monitorService.ts"; -import type { IChecksRepository, IMonitorStatsRepository, IMonitorsRepository, IStatusPagesRepository } from "../src/repositories/index.ts"; - -const createMonitorsRepositoryMock = () => - ({ - findMonitorCountByTeamIdAndType: jest.fn(), - findByTeamId: jest.fn(), - findById: jest.fn(), - findMonitorsSummaryByTeamId: jest.fn(), - findGroupsByTeamId: jest.fn(), - create: jest.fn(), - createBulkMonitors: jest.fn(), - deleteByTeamId: jest.fn(), - }) as unknown as IMonitorsRepository; - -const createChecksRepositoryMock = () => - ({ - findLatestChecksByMonitorIds: jest.fn(), - findDateRangeChecksByMonitor: jest.fn(), - }) as unknown as IChecksRepository; - -const createMonitorStatsRepositoryMock = () => - ({ - findByMonitorId: jest.fn(), - deleteByMonitorId: jest.fn(), - }) as unknown as IMonitorStatsRepository; - -const createStatusPagesRepositoryMock = () => - ({ - removeMonitorFromStatusPages: jest.fn(), - }) as unknown as IStatusPagesRepository; - -const createService = ({ - monitorsRepository = createMonitorsRepositoryMock(), - checksRepository = createChecksRepositoryMock(), - monitorStatsRepository = createMonitorStatsRepositoryMock(), - statusPagesRepository = createStatusPagesRepositoryMock(), -}: { - monitorsRepository?: IMonitorsRepository; - checksRepository?: IChecksRepository; - monitorStatsRepository?: IMonitorStatsRepository; - statusPagesRepository?: IStatusPagesRepository; -} = {}) => { - return new MonitorService({ - db: { - checkModule: { deleteChecks: jest.fn() }, - statusPageModule: { deleteStatusPagesByMonitorId: jest.fn() }, - pageSpeedCheckModule: { deletePageSpeedChecksByMonitorId: jest.fn() }, - notificationModule: { deleteNotificationsByMonitorId: jest.fn() }, - }, - jobQueue: { - addJob: jest.fn(), - updateJob: jest.fn(), - resumeJob: jest.fn(), - pauseJob: jest.fn(), - deleteJob: jest.fn(), - }, - stringService: {}, - emailService: { buildEmail: jest.fn(), sendEmail: jest.fn() }, - papaparse: { parse: jest.fn(), unparse: jest.fn() }, - logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, - errorService: { - createAuthorizationError: jest.fn(() => new Error("unauthorized")), - createServerError: jest.fn(() => new Error("server")), - createBadRequestError: jest.fn(() => new Error("bad request")), - createNotFoundError: jest.fn(() => new Error("not found")), - }, - games: [], - monitorsRepository, - checksRepository, - monitorStatsRepository, - statusPagesRepository, - }); -}; - -describe("MonitorService", () => { - describe("getMonitorsWithChecksByTeamId", () => { - it("returns monitors enriched with normalized checks", async () => { - const monitorsRepository = createMonitorsRepositoryMock(); - (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(2); - (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([ - { id: "m1", name: "Monitor 1", interval: 60000 }, - { id: "m2", name: "Monitor 2", interval: 60000 }, - ]); - - const checksRepository = createChecksRepositoryMock(); - (checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({ - m1: [ - { responseTime: 10, status: true, message: "OK" }, - { responseTime: 20, status: true, message: "OK" }, - ], - m2: [{ responseTime: 50, status: true, message: "OK" }], - }); - - const service = createService({ monitorsRepository, checksRepository }); - const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" }); - - expect(result).toMatchObject({ count: 2 }); - expect(result.monitors).toHaveLength(2); - expect(result.monitors[0]).toHaveProperty("checks"); - expect(result.monitors[0].checks.length).toBeGreaterThan(0); - expect(result.monitors[0].checks[0]).toEqual( - expect.objectContaining({ - responseTime: expect.any(Number), - status: expect.any(Boolean), - message: expect.any(String), - }) - ); - }); - }); - - describe("getMonitorsByTeamId", () => { - it("returns monitors array from repository", async () => { - const monitorsRepository = createMonitorsRepositoryMock(); - (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([ - { id: "m1", name: "Monitor 1" }, - { id: "m2", name: "Monitor 2" }, - ]); - const service = createService({ monitorsRepository }); - const result = await service.getMonitorsByTeamId({ teamId: "team" } as any); - expect(result).toHaveLength(2); - expect(result[0]).toHaveProperty("id", "m1"); - }); - }); - - describe("getMonitorsWithChecksByTeamId summary", () => { - it("includes summary and monitors with checks", async () => { - const monitorsRepository = createMonitorsRepositoryMock(); - (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); - (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([{ id: "m1", type: "http" }]); - (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ - totalMonitors: 1, - upMonitors: 1, - downMonitors: 0, - pausedMonitors: 0, - }); - - const checksRepository = createChecksRepositoryMock(); - (checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({ m1: [] }); - - const service = createService({ monitorsRepository, checksRepository }); - const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" }); - expect(result).toEqual({ - summary: { totalMonitors: 1, upMonitors: 1, downMonitors: 0, pausedMonitors: 0 }, - count: 1, - monitors: [{ id: "m1", type: "http", checks: [] }], - }); - }); - }); - - describe("getUptimeDetailsById", () => { - it("returns monitorData and monitorStats with expected shape", async () => { - const TEAM_ID = "team"; - const monitor = { - id: "monitor-1", - teamId: TEAM_ID, - name: "Hardware monitor", - interval: 60000, - statusWindow: [], - statusWindowSize: 5, - statusWindowThreshold: 60, - type: "http", - ignoreTlsErrors: false, - url: "https://example.com", - isActive: true, - alertThreshold: 5, - cpuAlertThreshold: 5, - memoryAlertThreshold: 5, - diskAlertThreshold: 5, - tempAlertThreshold: 5, - selectedDisks: [], - notifications: [], - group: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - const monitorsRepository = createMonitorsRepositoryMock(); - (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); - const checksRepository = createChecksRepositoryMock(); - (checksRepository.findDateRangeChecksByMonitor as jest.Mock).mockResolvedValue({ - monitorType: "http", - groupedChecks: [{ _id: "2024-01-01", avgResponseTime: 100, totalChecks: 2 }], - groupedUpChecks: [{ _id: "2024-01-01", totalChecks: 2, avgResponseTime: 90 }], - groupedDownChecks: [{ _id: "2024-01-01", totalChecks: 0, avgResponseTime: 0 }], - uptimePercentage: 0.99, - avgResponseTime: 95, - }); - - const monitorStatsRepository = createMonitorStatsRepositoryMock(); - (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue({ - id: "stats-1", - monitorId: monitor.id, - avgResponseTime: 90, - totalChecks: 10, - totalUpChecks: 9, - totalDownChecks: 1, - uptimePercentage: 0.9, - lastCheckTimestamp: 123456789, - lastResponseTime: 80, - timeOfLastFailure: 123456700, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - - const service = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); - const result = await service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: "monitor-1", dateRange: "recent" }); - - expect(result).toHaveProperty("monitorData"); - expect(result.monitorData.monitor).toMatchObject({ id: monitor.id, name: monitor.name }); - expect(result.monitorData.groupedChecks[0]).toEqual( - expect.objectContaining({ - _id: expect.any(String), - avgResponseTime: expect.any(Number), - totalChecks: expect.any(Number), - }) - ); - expect(result.monitorStats).toEqual( - expect.objectContaining({ - monitorId: monitor.id, - avgResponseTime: 90, - totalChecks: 10, - totalUpChecks: 9, - totalDownChecks: 1, - }) - ); - }); - }); -}); diff --git a/server/test/superSimpleQueueHelper.test.ts b/server/test/superSimpleQueueHelper.test.ts deleted file mode 100644 index 3b179bd91..000000000 --- a/server/test/superSimpleQueueHelper.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it, jest } from "@jest/globals"; -import SuperSimpleQueueHelper from "../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; -import type { Monitor } from "../src/types/monitor.ts"; - -const createLogger = () => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }); - -const createHelper = (overrides?: Partial[0]>) => { - const maintenanceWindowsRepository = { - findByMonitorId: jest.fn().mockResolvedValue([]), - }; - const statusServiceMock = { - updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1" }, statusChanged: true, prevStatus: false }), - }; - const helper = new SuperSimpleQueueHelper({ - logger: createLogger(), - networkService: { requestStatus: jest.fn() }, - statusService: statusServiceMock, - notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) }, - checkService: { buildCheck: jest.fn().mockResolvedValue({}) }, - buffer: { addToBuffer: jest.fn() }, - incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) }, - maintenanceWindowsRepository, - ...overrides, - }); - return { helper, maintenanceWindowsRepository }; -}; - -describe("SuperSimpleQueueHelper", () => { - describe("getMonitorJob", () => { - it("skips execution when monitor is in maintenance window", async () => { - const { helper } = createHelper(); - const spy = jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); - const job = helper.getMonitorJob(); - await job({ id: "m1", teamId: "team", interval: 60000 } as Monitor); - expect(helper["networkService"].requestStatus).not.toHaveBeenCalled(); - expect(helper["logger"].debug).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining("Monitor m1 is in maintenance window") }) - ); - spy.mockRestore(); - }); - - it("processes monitor status and notifications when active", async () => { - const networkResponse = { monitor: { id: "m1" }, status: true }; - const updatedMonitor = { id: "m1", status: true }; - const { helper } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - statusService: { - updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: updatedMonitor, statusChanged: true, prevStatus: false, code: 200 }), - }, - notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) }, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getMonitorJob(); - const monitor = { id: "m1", teamId: "team" } as Monitor; - await job(monitor); - expect(helper["networkService"].requestStatus).toHaveBeenCalledWith(monitor); - }); - - it("throws when monitor id is missing", async () => { - const { helper } = createHelper(); - const job = helper.getMonitorJob(); - await expect(job({} as Monitor)).rejects.toThrow("No monitor id"); - expect(helper["logger"].warn).toHaveBeenCalled(); - }); - }); - - describe("isInMaintenanceWindow", () => { - it("returns true when an active window spans now", async () => { - const now = new Date(); - const { helper, maintenanceWindowsRepository } = createHelper(); - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now.getTime() - 1000).toISOString(), - end: new Date(now.getTime() + 1000).toISOString(), - repeat: 0, - }, - ]); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); - }); - - it("returns true when repeat interval advances window into current time", async () => { - const now = Date.now(); - const { helper, maintenanceWindowsRepository } = createHelper(); - maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ - { - active: true, - start: new Date(now - 7200000).toISOString(), - end: new Date(now - 6600000).toISOString(), - repeat: 3600000, - }, - ]); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); - }); - - it("returns false when no active windows exist", async () => { - const { helper } = createHelper(); - await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); - }); - }); -}); diff --git a/server/test/unit/controllers/statusPageThemeFlag.test.ts b/server/test/unit/controllers/statusPageThemeFlag.test.ts new file mode 100644 index 000000000..b4f9435a9 --- /dev/null +++ b/server/test/unit/controllers/statusPageThemeFlag.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "@jest/globals"; +import { applyDefaultTheme, withoutThemeFields } from "../../../src/controllers/statusPageThemeFlag.ts"; +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/types/statusPage.ts"; +import { makeStatusPage } from "../../helpers/makeStatusPage.ts"; + +describe("statusPageThemeFlag", () => { + describe("withoutThemeFields", () => { + it("removes theme and themeMode from a body that has them", () => { + const body = { companyName: "Co", theme: "modern", themeMode: "dark" }; + const result = withoutThemeFields(body); + expect(result).toEqual({ companyName: "Co" }); + }); + + it("is a no-op when neither field is present", () => { + const body = { companyName: "Co", url: "page" }; + const result = withoutThemeFields(body); + expect(result).toEqual({ companyName: "Co", url: "page" }); + }); + + it("removes only the theme fields when one is present", () => { + const body = { companyName: "Co", theme: "bold" }; + const result = withoutThemeFields(body); + expect(result).toEqual({ companyName: "Co" }); + }); + + it("does not mutate the input body", () => { + const body = { companyName: "Co", theme: "modern", themeMode: "dark" }; + withoutThemeFields(body); + expect(body).toEqual({ companyName: "Co", theme: "modern", themeMode: "dark" }); + }); + + it("preserves all other fields verbatim", () => { + const body = { + companyName: "Co", + monitors: ["m-1", "m-2"], + isPublished: true, + showCharts: false, + color: "#13715B", + theme: "editorial", + themeMode: "light", + }; + const result = withoutThemeFields(body); + expect(result).toEqual({ + companyName: "Co", + monitors: ["m-1", "m-2"], + isPublished: true, + showCharts: false, + color: "#13715B", + }); + }); + }); + + describe("applyDefaultTheme", () => { + it("overwrites theme and themeMode with the canonical defaults", () => { + const sp = makeStatusPage({ theme: "modern", themeMode: "dark" }); + const result = applyDefaultTheme(sp); + expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + }); + + it("leaves all other fields untouched", () => { + const sp = makeStatusPage({ + companyName: "Test Co", + url: "my-page", + monitors: ["mon-1", "mon-2"], + isPublished: true, + color: "#13715B", + }); + const result = applyDefaultTheme(sp); + expect(result.companyName).toBe("Test Co"); + expect(result.url).toBe("my-page"); + expect(result.monitors).toEqual(["mon-1", "mon-2"]); + expect(result.isPublished).toBe(true); + expect(result.color).toBe("#13715B"); + }); + + it("does not mutate the input status page", () => { + const sp = makeStatusPage({ theme: "modern", themeMode: "dark" }); + applyDefaultTheme(sp); + expect(sp.theme).toBe("modern"); + expect(sp.themeMode).toBe("dark"); + }); + + it("works on a status page that has no theme set yet", () => { + const sp = makeStatusPage({ theme: undefined, themeMode: undefined }); + const result = applyDefaultTheme(sp); + expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + }); + }); +}); diff --git a/server/test/unit/providers/network/advancedMatcher.test.ts b/server/test/unit/providers/network/advancedMatcher.test.ts new file mode 100644 index 000000000..e0b2c773c --- /dev/null +++ b/server/test/unit/providers/network/advancedMatcher.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { AdvancedMatcher } from "../../../../src/service/infrastructure/network/AdvancedMatcher.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createMockJmespath = () => ({ + search: jest.fn((data: any, expr: string) => { + // Simple dot-path evaluation for tests + return expr.split(".").reduce((acc: any, key: string) => acc?.[key], data); + }), +}); + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + useAdvancedMatching: false, + jsonPath: undefined, + matchMethod: undefined, + expectedValue: undefined, + ...overrides, + }) as Monitor; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("AdvancedMatcher", () => { + describe("validate", () => { + it("returns ok when advanced matching is disabled", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: false })); + + expect(result).toEqual({ ok: true, message: "Success" }); + }); + + // ── jsonPath extraction ────────────────────────────────────────── + + describe("jsonPath extraction", () => { + it("extracts value using jsonPath", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate({ status: "healthy" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" })); + + expect(result).toEqual({ ok: true, message: "Success", extracted: "healthy" }); + }); + + it("returns error when jsonPath evaluation throws", () => { + const jmespath = { + search: jest.fn().mockImplementation(() => { + throw new Error("bad expression"); + }), + }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "invalid[" })); + + expect(result).toEqual({ ok: false, message: "Error evaluating JSON path" }); + }); + }); + + // ── expectedValue matching ─────────────────────────────────────── + + describe("expectedValue with matchMethod", () => { + it("matches with 'equal' method", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { status: "ok" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" }) + ); + + expect(result.ok).toBe(true); + }); + + it("fails with 'equal' method on mismatch", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { status: "error" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" }) + ); + + expect(result.ok).toBe(false); + expect(result.message).toBe("Expected value did not match"); + }); + + it("matches with 'include' method", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { msg: "server is healthy" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" }) + ); + + expect(result.ok).toBe(true); + }); + + it("fails with 'include' method when substring not found", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { msg: "server is down" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" }) + ); + + expect(result.ok).toBe(false); + }); + + it("matches with 'regex' method", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { code: "200" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" }) + ); + + expect(result.ok).toBe(true); + }); + + it("fails with 'regex' method on mismatch", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { code: "500" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" }) + ); + + expect(result.ok).toBe(false); + }); + + it("defaults to 'equal' comparison when matchMethod is undefined", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate({ val: "exact" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "val", expectedValue: "exact" })); + + expect(result.ok).toBe(true); + }); + + it("defaults to 'equal' comparison for unknown matchMethod", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { val: "exact" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "val", matchMethod: "unknown" as any, expectedValue: "exact" }) + ); + + expect(result.ok).toBe(true); + }); + + it("compares entire payload when jsonPath is not set", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + "hello", + makeMonitor({ useAdvancedMatching: true, jsonPath: undefined, matchMethod: "equal", expectedValue: "hello" }) + ); + + expect(result.ok).toBe(true); + expect(result.extracted).toBe("hello"); + }); + + it("includes extracted value in result", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { nested: { value: "42" } }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "nested.value", matchMethod: "equal", expectedValue: "42" }) + ); + + expect(result.extracted).toBe("42"); + }); + }); + + // ── falsy value check (no expectedValue) ──────────────────────── + + describe("falsy value check (no expectedValue)", () => { + it("returns ok for truthy extracted value", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate({ status: "up" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" })); + + expect(result.ok).toBe(true); + expect(result.extracted).toBe("up"); + }); + + it("returns not ok for false", () => { + const jmespath = { search: jest.fn().mockReturnValue(false) }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "missing" })); + + expect(result.ok).toBe(false); + expect(result.message).toBe("Extracted value is falsy"); + }); + + it("returns not ok for string 'false'", () => { + const jmespath = { search: jest.fn().mockReturnValue("false") }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(false); + }); + + it("returns not ok for undefined", () => { + const jmespath = { search: jest.fn().mockReturnValue(undefined) }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(false); + }); + + it("returns not ok for null", () => { + const jmespath = { search: jest.fn().mockReturnValue(null) }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(false); + }); + + it("returns ok for zero (not in falsy list)", () => { + const jmespath = { search: jest.fn().mockReturnValue(0) }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(true); + }); + + it("returns ok for empty string (not in falsy list)", () => { + const jmespath = { search: jest.fn().mockReturnValue("") }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(true); + }); + }); + }); +}); 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/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts new file mode 100644 index 000000000..0f3dd68f6 --- /dev/null +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { DockerProvider } from "../../../../src/service/infrastructure/network/DockerProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "docker", + url: "my-container", + ...overrides, + }) as Monitor; + +const makeContainer = (overrides?: Record) => ({ + Id: "abc123def456abc123def456abc123def456abc123def456abc123def456abcd", + Names: ["/my-container"], + State: "running", + ...overrides, +}); + +const createMockDocker = (containers: any[] = [makeContainer()], inspectResult?: any) => { + const inspect = jest.fn().mockResolvedValue(inspectResult ?? { State: { Status: "running" } }); + const getContainer = jest.fn().mockReturnValue({ inspect }); + + const instance = { + listContainers: jest.fn().mockResolvedValue(containers), + getContainer, + }; + + const DockerLib = jest.fn().mockReturnValue(instance) as any; + + return { DockerLib, instance, inspect, getContainer }; +}; + +const createProvider = (opts?: { containers?: any[]; inspectResult?: any }) => { + const logger = createMockLogger(); + const { DockerLib, instance, inspect, getContainer } = createMockDocker(opts?.containers, opts?.inspectResult); + const provider = new DockerProvider(logger as any, DockerLib); + return { provider, logger, docker: instance, inspect, getContainer }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("DockerProvider", { + create: () => createProvider().provider, + supportedType: "docker", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("DockerProvider", () => { + // ── Container matching ─────────────────────────────────────────────── + + describe("container matching", () => { + it("matches by exact container name", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "my-container" })); + + expect(result.status).toBe(true); + expect(result.code).toBe(200); + }); + + it("matches by exact full ID (64 chars)", async () => { + const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd"; + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: fullId })); + + expect(result.status).toBe(true); + }); + + it("matches by partial ID prefix", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "abc123" })); + + expect(result.status).toBe(true); + }); + + it("strips leading slashes from input", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "///my-container" })); + + expect(result.status).toBe(true); + }); + + it("matches case-insensitively", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "MY-CONTAINER" })); + + expect(result.status).toBe(true); + }); + + it("returns 404 when no container matches", async () => { + const { provider, logger } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "nonexistent" })); + + expect(result.status).toBe(false); + expect(result.code).toBe(404); + expect(result.message).toBe("Docker container not found"); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("No container found"), + }) + ); + }); + }); + + // ── Ambiguity detection ────────────────────────────────────────────── + + describe("ambiguity detection", () => { + it("returns error when input matches both exact name and partial ID", async () => { + // Container whose name matches the input AND whose ID starts with the input + const container = makeContainer({ + Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd", + Names: ["/my-container"], + }); + const { provider, logger } = createProvider({ containers: [container] }); + + const result = await provider.handle(makeMonitor({ url: "my-container" })); + + expect(result.status).toBe(false); + expect(result.code).toBe(NETWORK_ERROR); + expect(result.message).toContain("Ambiguous"); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Ambiguous"), + }) + ); + }); + + it("reports 'exact ID' in ambiguity message when exact ID and name both match", async () => { + const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd"; + const container = makeContainer({ + Id: fullId, + Names: ["/" + fullId], + }); + const { provider } = createProvider({ containers: [container] }); + + const result = await provider.handle(makeMonitor({ url: fullId })); + + expect(result.message).toContain("Using exact ID"); + }); + + it("reports 'exact name' in ambiguity message when name and partial ID match", async () => { + const container = makeContainer({ + Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd", + Names: ["/my-container"], + }); + const { provider } = createProvider({ containers: [container] }); + + const result = await provider.handle(makeMonitor({ url: "my-container" })); + + expect(result.message).toContain("Using exact name"); + }); + }); + + // ── Container inspection ───────────────────────────────────────────── + + describe("container inspection", () => { + it("returns running status for running container", async () => { + const { provider } = createProvider({ inspectResult: { State: { Status: "running" } } }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(true); + expect(result.message).toBe("Docker container status fetched successfully"); + }); + + it("returns not-running status for stopped container", async () => { + const { provider } = createProvider({ inspectResult: { State: { Status: "exited" } } }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + }); + + it("returns NETWORK_ERROR when inspect response is null", async () => { + const logger = createMockLogger(); + const inspect = jest.fn().mockResolvedValue(null); + const getContainer = jest.fn().mockReturnValue({ inspect }); + const instance = { listContainers: jest.fn().mockResolvedValue([makeContainer()]), getContainer }; + const DockerLib = jest.fn().mockReturnValue(instance) as any; + const provider = new DockerProvider(logger as any, DockerLib); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.code).toBe(NETWORK_ERROR); + expect(result.message).toBe("No response from Docker container inspect"); + }); + + it("returns false status when inspect response has no State", async () => { + const { provider } = createProvider({ inspectResult: {} }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + }); + + it("returns false status when inspect response has State but no Status", async () => { + const { provider } = createProvider({ inspectResult: { State: {} } }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + }); + + it("handles inspect error with Docker-specific error", async () => { + const { provider } = createProvider(); + // Make inspect throw a Docker error + const dockerErr = Object.assign(new Error("container not found"), { + statusCode: 404, + reason: "no such container", + json: { message: "Container not found" }, + }); + provider["docker"] = { + listContainers: jest.fn().mockResolvedValue([makeContainer()]), + getContainer: jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }), + } as any; + + // Need to re-assign; simpler to use the createProvider mock + const { provider: p2 } = createProvider(); + // Override getContainer to return failing inspect + p2["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }); + + const result = await p2.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.code).toBe(404); + expect(result.message).toBe("Container not found"); + }); + + it("handles inspect error with Docker error using reason fallback", async () => { + const { provider } = createProvider(); + const dockerErr = Object.assign(new Error("error"), { + statusCode: 500, + reason: "internal error", + json: {}, + }); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("internal error"); + }); + + it("handles inspect error with Docker error using Error message fallback", async () => { + const { provider } = createProvider(); + const dockerErr = Object.assign(new Error("base error"), { + statusCode: 503, + reason: undefined, + json: undefined, + }); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("base error"); + }); + + it("handles inspect error with NETWORK_ERROR when no statusCode", async () => { + const { provider } = createProvider(); + const dockerErr = Object.assign(new Error("unknown"), { + statusCode: undefined, + }); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("handles inspect error with standard Error (not Docker error)", async () => { + const { provider } = createProvider(); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(new Error("ECONNREFUSED")), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("ECONNREFUSED"); + }); + + it("handles inspect error with non-Error non-Docker thrown value", async () => { + const { provider } = createProvider(); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue("string error"), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("Failed to fetch Docker container information"); + }); + }); + + // ── Outer catch ────────────────────────────────────────────────────── + + describe("outer error handling", () => { + it("throws AppError when containerInput is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Container name or ID is required for Docker monitor"); + }); + + it("throws AppError when listContainers throws", async () => { + const { provider } = createProvider(); + provider["docker"].listContainers = jest.fn().mockRejectedValue(new Error("Docker daemon unavailable")); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Docker daemon unavailable"); + }); + + it("throws AppError with default message for non-Error thrown values", async () => { + const { provider } = createProvider(); + provider["docker"].listContainers = jest.fn().mockRejectedValue(null); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Docker request"); + }); + }); +}); diff --git a/server/test/unit/providers/network/gameProvider.test.ts b/server/test/unit/providers/network/gameProvider.test.ts new file mode 100644 index 000000000..d34e99621 --- /dev/null +++ b/server/test/unit/providers/network/gameProvider.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { GameProvider } from "../../../../src/service/infrastructure/network/GameProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "game", + url: "play.example.com", + port: 25565, + gameId: "minecraft", + ...overrides, + }) as Monitor; + +const createMockGameDig = (state?: Record | null) => ({ + query: jest.fn().mockResolvedValue( + state ?? { + name: "My Server", + map: "world", + players: [], + ping: 45, + } + ), +}); + +const createProvider = (gameDig?: any) => { + const logger = createMockLogger(); + const dig = gameDig ?? createMockGameDig(); + const provider = new GameProvider(logger as any, dig as any); + return { provider, logger, gameDig: dig }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("GameProvider", { + create: () => createProvider().provider, + supportedType: "game", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("GameProvider", () => { + it("returns success with server state", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "game", + status: true, + code: 200, + message: "Success", + responseTime: 45, + }) + ); + }); + + it("queries with correct host, port, and gameId", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ url: "https://play.example.com/path", port: 27015, gameId: "csgo" })); + + expect(gameDig.query).toHaveBeenCalledWith({ + type: "csgo", + host: "play.example.com", + port: 27015, + }); + }); + + it("strips protocol and path from url for host", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ url: "http://game.server.com:8080/info" })); + + expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "game.server.com" })); + }); + + it("defaults gameId to 'unknown' when not set", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ gameId: undefined })); + + expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ type: "unknown" })); + }); + + it("defaults port to 0 when not set", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ port: undefined })); + + expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ port: 0 })); + }); + + it("defaults responseTime to 0 when ping is undefined", async () => { + const { provider } = createProvider(createMockGameDig({ ping: undefined })); + + const result = await provider.handle(makeMonitor()); + + expect(result.responseTime).toBe(0); + }); + + it("returns failure when query resolves to undefined (caught error)", async () => { + const gameDig = { query: jest.fn().mockRejectedValue(new Error("Server offline")) }; + // GameProvider catches the error internally via .catch() + const { provider, logger } = createProvider(gameDig); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "No response from game server", + responseTime: 0, + }) + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Server offline", + method: "handle", + }) + ); + }); + + it("logs non-Error thrown values from query", async () => { + const gameDig = { query: jest.fn().mockRejectedValue("string error") }; + const { provider, logger } = createProvider(gameDig); + + await provider.handle(makeMonitor()); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "string error" })); + }); + + it("uses empty string as host when url is undefined", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ url: undefined })); + + expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "" })); + }); + + it("throws AppError with default message when error stringifies to empty", async () => { + const gameDig = { + query: jest.fn().mockReturnValue({ + catch: () => { + throw new Error(""); + }, + }), + }; + const { provider } = createProvider(gameDig); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check"); + }); + + it("throws AppError with default message for non-Error that stringifies to empty", async () => { + const gameDig = { + query: jest.fn().mockReturnValue({ + catch: () => { + throw ""; + }, + }), + }; + const { provider } = createProvider(gameDig); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check"); + }); + + it("throws AppError with Error message in outer catch", async () => { + const gameDig = { + query: jest.fn().mockReturnValue({ + catch: () => { + throw new Error("unexpected"); + }, + }), + }; + const { provider } = createProvider(gameDig); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("unexpected"); + }); +}); diff --git a/server/test/unit/providers/network/grpcProvider.test.ts b/server/test/unit/providers/network/grpcProvider.test.ts new file mode 100644 index 000000000..efa2e7992 --- /dev/null +++ b/server/test/unit/providers/network/grpcProvider.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { GrpcProvider } from "../../../../src/service/infrastructure/network/GrpcProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "grpc", + url: "grpc.example.com", + port: 50051, + grpcServiceName: "my.Service", + ignoreTlsErrors: false, + ...overrides, + }) as Monitor; + +const createMockClient = (behavior: "serving" | "not-serving" | "error" | "grpc-error" = "serving") => { + const close = jest.fn(); + const Check = jest.fn((_req: any, _opts: any, cb: Function) => { + if (behavior === "serving") { + cb(null, { status: "SERVING" }); + } else if (behavior === "not-serving") { + cb(null, { status: "NOT_SERVING" }); + } else if (behavior === "error") { + cb({ code: 14, details: "Connection refused", message: "unavailable" }); + } else if (behavior === "grpc-error") { + cb({ code: 2, message: "Unknown error" }); + } + }); + return { Check, close }; +}; + +const createMockGrpc = (client?: ReturnType) => { + const c = client ?? createMockClient(); + const Health = jest.fn().mockReturnValue(c); + + return { + grpc: { + loadPackageDefinition: jest.fn().mockReturnValue({ + grpc: { health: { v1: { Health } } }, + }), + credentials: { + createInsecure: jest.fn().mockReturnValue("insecure-creds"), + createSsl: jest.fn().mockReturnValue("ssl-creds"), + }, + }, + protoLoader: { + loadSync: jest.fn().mockReturnValue({}), + }, + Health, + client: c, + }; +}; + +const createProvider = (opts?: { behavior?: "serving" | "not-serving" | "error" | "grpc-error" }) => { + const client = createMockClient(opts?.behavior ?? "serving"); + const mocks = createMockGrpc(client); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + return { provider, ...mocks, client }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("GrpcProvider", { + create: () => createProvider().provider, + supportedType: "grpc", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("GrpcProvider", () => { + // ── Success paths ──────────────────────────────────────────────────── + + describe("success responses", () => { + it("returns healthy status for SERVING response", async () => { + const { provider } = createProvider({ behavior: "serving" }); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "grpc", + status: true, + code: 200, + message: expect.stringContaining("SERVING"), + }) + ); + expect(result.payload).toEqual( + expect.objectContaining({ + grpcStatusCode: 0, + grpcStatusName: "OK", + serviceName: "my.Service", + servingStatus: "SERVING", + }) + ); + }); + + it("returns unhealthy status for NOT_SERVING response", async () => { + const { provider } = createProvider({ behavior: "not-serving" }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.code).toBe(5000); + expect(result.message).toContain("NOT_SERVING"); + }); + + it("defaults to UNKNOWN when response has no status", async () => { + const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, {})), close: jest.fn() }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" })); + }); + + it("defaults to UNKNOWN when response is undefined", async () => { + const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, undefined)), close: jest.fn() }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" })); + }); + }); + + // ── gRPC errors ────────────────────────────────────────────────────── + + describe("gRPC errors", () => { + it("returns failure with gRPC error details", async () => { + const { provider } = createProvider({ behavior: "error" }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.code).toBe(14); + expect(result.message).toBe("Connection refused"); + expect(result.payload).toEqual( + expect.objectContaining({ + grpcStatusCode: 14, + grpcStatusName: "UNAVAILABLE", + servingStatus: "UNKNOWN", + }) + ); + }); + + it("falls back to message when details is missing", async () => { + const client = { + Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code: 2, message: "Unknown error" })), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("Unknown error"); + }); + + it("uses default message and code when error has no code/details/message", async () => { + const client = { + Check: jest.fn((_r: any, _o: any, cb: Function) => cb({})), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(5000); + expect(result.message).toBe("gRPC error"); + expect(result.payload).toEqual(expect.objectContaining({ grpcStatusCode: -1, grpcStatusName: "UNKNOWN" })); + }); + + it("defaults grpcCode to 5000 when error has no grpcCode", async () => { + const client = { + Check: jest.fn((_r: any, _o: any, cb: Function) => { + const err = new Error("test") as any; + // no grpcPayload or grpcCode + cb(err); + }), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(5000); + }); + + it("includes grpcPayload in error result", async () => { + const { provider } = createProvider({ behavior: "error" }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual( + expect.objectContaining({ + grpcStatusCode: 14, + grpcStatusName: "UNAVAILABLE", + }) + ); + }); + + it("defaults code, message, and payload when error lacks gRPC properties", async () => { + // client.Check throws synchronously — timeRequest catches a plain object with no gRPC props + const client = { + Check: jest.fn(() => { + throw { unexpected: true }; + }), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(5000); + expect(result.message).toBe("gRPC health check failed"); + expect(result.payload).toBeNull(); + }); + }); + + // ── Credentials ────────────────────────────────────────────────────── + + describe("credentials", () => { + it("uses insecure credentials by default", async () => { + const { provider, grpc } = createProvider(); + + await provider.handle(makeMonitor({ ignoreTlsErrors: false })); + + expect(grpc.credentials.createInsecure).toHaveBeenCalled(); + }); + + it("uses SSL credentials with checkServerIdentity override when ignoreTlsErrors is true", async () => { + const { provider, grpc } = createProvider(); + + await provider.handle(makeMonitor({ ignoreTlsErrors: true })); + + expect(grpc.credentials.createSsl).toHaveBeenCalledWith(null, null, null, { + checkServerIdentity: expect.any(Function), + }); + + // Verify checkServerIdentity returns undefined + const opts = (grpc.credentials.createSsl as jest.Mock).mock.calls[0][3]; + expect(opts.checkServerIdentity()).toBeUndefined(); + }); + }); + + // ── URL parsing ────────────────────────────────────────────────────── + + describe("URL and target construction", () => { + it("strips protocol from URL", async () => { + const { provider, Health } = createProvider(); + + await provider.handle(makeMonitor({ url: "https://grpc.example.com", port: 443 })); + + expect(Health).toHaveBeenCalledWith("grpc.example.com:443", expect.anything()); + }); + + it("uses grpcServiceName in Check request", async () => { + const { provider, client } = createProvider(); + + await provider.handle(makeMonitor({ grpcServiceName: "health.v1" })); + + expect(client.Check).toHaveBeenCalledWith( + { service: "health.v1" }, + expect.objectContaining({ deadline: expect.any(Date) }), + expect.any(Function) + ); + }); + + it("defaults grpcServiceName to empty string when not set", async () => { + const { provider, client } = createProvider(); + + await provider.handle(makeMonitor({ grpcServiceName: undefined })); + + expect(client.Check).toHaveBeenCalledWith({ service: "" }, expect.anything(), expect.any(Function)); + }); + }); + + // ── Validation ─────────────────────────────────────────────────────── + + describe("validation", () => { + it("throws AppError when url is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Monitor host is required"); + }); + + it("throws AppError when port is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("Monitor port is required"); + }); + }); + + // ── Outer catch ────────────────────────────────────────────────────── + + describe("outer error handling", () => { + it("throws AppError when protoLoader.loadSync throws", async () => { + const mocks = createMockGrpc(); + mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => { + throw new Error("Proto file not found"); + }); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Proto file not found"); + }); + + it("throws AppError with default message when Error has empty message", async () => { + const mocks = createMockGrpc(); + mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => { + throw new Error(""); + }); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing gRPC health check"); + }); + + it("throws AppError with stringified message for non-Error thrown values", async () => { + const mocks = createMockGrpc(); + mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => { + throw "proto load failed"; + }); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("proto load failed"); + }); + }); + + // ── getGrpcStatusName ──────────────────────────────────────────────── + + describe("gRPC status name mapping", () => { + const statusCodes: [number, string][] = [ + [0, "OK"], + [1, "CANCELLED"], + [2, "UNKNOWN"], + [3, "INVALID_ARGUMENT"], + [4, "DEADLINE_EXCEEDED"], + [5, "NOT_FOUND"], + [6, "ALREADY_EXISTS"], + [7, "PERMISSION_DENIED"], + [8, "RESOURCE_EXHAUSTED"], + [9, "FAILED_PRECONDITION"], + [10, "ABORTED"], + [11, "OUT_OF_RANGE"], + [12, "UNIMPLEMENTED"], + [13, "INTERNAL"], + [14, "UNAVAILABLE"], + [15, "DATA_LOSS"], + [16, "UNAUTHENTICATED"], + [99, "UNKNOWN"], + ]; + + it.each(statusCodes)("maps gRPC code %i to %s", async (code, expectedName) => { + const client = { + Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code, details: "test" })), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual(expect.objectContaining({ grpcStatusName: expectedName })); + }); + }); +}); diff --git a/server/test/unit/providers/network/hardwareProvider.test.ts b/server/test/unit/providers/network/hardwareProvider.test.ts new file mode 100644 index 000000000..2f51454a0 --- /dev/null +++ b/server/test/unit/providers/network/hardwareProvider.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { HardwareProvider } from "../../../../src/service/infrastructure/network/HardwareProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "hardware", + url: "https://capture.example.com", + ...overrides, + }) as Monitor; + +const createMockHttpProvider = () => + ({ + handle: jest.fn().mockResolvedValue({ + monitorId: "mon-1", + teamId: "team-1", + type: "hardware", + status: true, + code: 200, + message: "OK", + responseTime: 50, + payload: { data: { cpu: { usage_percent: 0.5 } } }, + }), + }) as unknown as jest.Mocked; + +const createProvider = () => { + const httpProvider = createMockHttpProvider(); + const provider = new HardwareProvider(httpProvider); + return { provider, httpProvider }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("HardwareProvider", { + create: () => createProvider().provider, + supportedType: "hardware", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("HardwareProvider", () => { + it("delegates to httpProvider.handle", async () => { + const { provider, httpProvider } = createProvider(); + const monitor = makeMonitor(); + + await provider.handle(monitor); + + expect(httpProvider.handle).toHaveBeenCalledWith(monitor); + }); + + it("returns the response from httpProvider", async () => { + const { provider } = createProvider(); + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + status: true, + code: 200, + }) + ); + }); + + it("throws AppError when url is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for Hardware monitor"); + }); + + it("throws AppError when httpProvider.handle throws", async () => { + const { provider, httpProvider } = createProvider(); + (httpProvider.handle as jest.Mock).mockRejectedValue(new Error("connection failed")); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("connection failed"); + }); + + it("throws AppError with default message for non-Error thrown values", async () => { + const { provider, httpProvider } = createProvider(); + (httpProvider.handle as jest.Mock).mockRejectedValue("string error"); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Hardware request"); + }); +}); diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts new file mode 100644 index 000000000..1b814d7b2 --- /dev/null +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -0,0 +1,348 @@ +import { 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"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +jest.unstable_mockModule("cacheable-lookup", () => ({ + default: jest.fn().mockImplementation(() => ({})), +})); + +const mockGot = jest.fn(); +// got instance returned by got.extend() +mockGot.mockImplementation(() => Promise.resolve()); +(mockGot as any).extend = jest.fn().mockReturnValue(mockGot); + +jest.unstable_mockModule("got", () => ({ + type: { Got: {} }, + HTTPError: class HTTPError extends Error { + response: any; + timings: any; + constructor(msg: string, response?: any, timings?: any) { + super(msg); + this.name = "HTTPError"; + this.response = response; + this.timings = timings; + } + }, + RequestError: class RequestError extends Error { + response: any; + timings: any; + constructor(msg: string, response?: any, timings?: any) { + super(msg); + this.name = "RequestError"; + this.response = response; + this.timings = timings; + } + }, +})); + +const { HttpProvider } = await import("../../../../src/service/infrastructure/network/HttpProvider.ts"); +const gotModule = await import("got"); +const { HTTPError, RequestError } = gotModule; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "http", + url: "https://example.com", + secret: undefined, + jsonPath: undefined, + ignoreTlsErrors: false, + useAdvancedMatching: false, + matchMethod: undefined, + expectedValue: undefined, + ...overrides, + }) as Monitor; + +const createMockMatcher = (result?: { ok: boolean; message: string; extracted?: unknown }) => ({ + validate: jest.fn().mockReturnValue(result ?? { ok: true, message: "Success" }), +}); + +const makeGotResponse = (overrides?: Record) => ({ + ok: true, + statusCode: 200, + statusMessage: "OK", + headers: { "content-type": "text/html" }, + body: "", + timings: { phases: { total: 100 } }, + ...overrides, +}); + +const createProvider = (matcher?: any) => { + const advancedMatcher = matcher ?? createMockMatcher(); + const provider = new HttpProvider(mockGot as any, advancedMatcher); + return { provider, advancedMatcher }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("HttpProvider", { + create: () => { + mockGot.mockResolvedValue(makeGotResponse()); + return createProvider().provider; + }, + supportedType: "http", + unsupportedType: "ping", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("HttpProvider", () => { + // ── Success paths ──────────────────────────────────────────────────── + + describe("success responses", () => { + it("returns success for a standard HTML response", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "http", + status: true, + code: 200, + message: "OK", + responseTime: 100, + }) + ); + }); + + it("parses JSON body when content-type is application/json", async () => { + mockGot.mockResolvedValue( + makeGotResponse({ + headers: { "content-type": "application/json" }, + body: '{"status":"ok"}', + }) + ); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual({ status: "ok" }); + }); + + it("returns raw body when JSON parsing fails", async () => { + mockGot.mockResolvedValue( + makeGotResponse({ + headers: { "content-type": "application/json" }, + body: "not-json", + }) + ); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toBe("not-json"); + }); + + it("returns raw body for non-JSON content", async () => { + mockGot.mockResolvedValue(makeGotResponse({ body: "test" })); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toBe("test"); + }); + + it("passes Authorization header when secret is set", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor({ secret: "my-token" })); + + expect(mockGot).toHaveBeenCalledWith( + "https://example.com", + expect.objectContaining({ + headers: { Authorization: "Bearer my-token" }, + }) + ); + }); + + it("passes undefined headers when no secret", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor({ secret: undefined })); + + expect(mockGot).toHaveBeenCalledWith( + "https://example.com", + expect.objectContaining({ + headers: undefined, + }) + ); + }); + + it("defaults responseTime to 0 when timings.phases.total is undefined", async () => { + mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { total: undefined } } })); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.responseTime).toBe(0); + }); + + it("defaults statusMessage to 'OK' when undefined", async () => { + mockGot.mockResolvedValue(makeGotResponse({ statusMessage: undefined })); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("OK"); + }); + + it("uses empty string for content-type when header is missing", async () => { + mockGot.mockResolvedValue(makeGotResponse({ headers: {} })); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + // Should treat as non-JSON + expect(result.payload).toBe(""); + }); + }); + + // ── jsonPath + non-JSON response ───────────────────────────────────── + + describe("jsonPath validation", () => { + it("returns failure when jsonPath is set but response is not JSON", async () => { + mockGot.mockResolvedValue(makeGotResponse({ headers: { "content-type": "text/html" } })); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ jsonPath: "status" })); + + expect(result.status).toBe(false); + expect(result.message).toBe("Response is not JSON"); + }); + + it("defaults responseTime to 0 in non-JSON jsonPath response when total is undefined", async () => { + mockGot.mockResolvedValue( + makeGotResponse({ + headers: { "content-type": "text/html" }, + timings: { phases: { total: undefined } }, + }) + ); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ jsonPath: "status" })); + + expect(result.responseTime).toBe(0); + }); + }); + + // ── AdvancedMatcher integration ────────────────────────────────────── + + describe("advanced matching", () => { + it("uses matcher result for status and message", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const matcher = createMockMatcher({ ok: false, message: "Mismatch", extracted: "value" }); + const { provider } = createProvider(matcher); + + const result = await provider.handle(makeMonitor({ useAdvancedMatching: true })); + + expect(result.status).toBe(false); + expect(result.message).toBe("Mismatch"); + expect(result.extracted).toBe("value"); + }); + + it("sets status to false when response.ok is false even if matcher passes", async () => { + mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 })); + const matcher = createMockMatcher({ ok: true, message: "Success" }); + const { provider } = createProvider(matcher); + + const result = await provider.handle(makeMonitor()); + + // status = response.ok && matchResult.ok + expect(result.status).toBe(false); + }); + }); + + // ── Error handling ─────────────────────────────────────────────────── + + describe("error handling", () => { + it("handles HTTPError with response and timings", async () => { + const err = new HTTPError("Not Found"); + (err as any).response = { statusCode: 404 }; + (err as any).timings = { phases: { total: 50 } }; + mockGot.mockRejectedValue(err); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: 404, + message: "Not Found", + responseTime: 50, + }) + ); + }); + + it("handles RequestError with response and timings", async () => { + const err = new RequestError("ECONNREFUSED"); + (err as any).response = { statusCode: undefined }; + (err as any).timings = { phases: { total: undefined } }; + mockGot.mockRejectedValue(err); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(NETWORK_ERROR); + expect(result.responseTime).toBe(0); + }); + + it("handles HTTPError without response (defaults to NETWORK_ERROR)", async () => { + const err = new HTTPError("Timeout"); + (err as any).response = undefined; + (err as any).timings = undefined; + mockGot.mockRejectedValue(err); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(NETWORK_ERROR); + expect(result.responseTime).toBe(0); + }); + + it("handles generic Error (non-HTTPError/RequestError)", async () => { + mockGot.mockRejectedValue(new Error("DNS lookup failed")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "DNS lookup failed", + responseTime: 0, + }) + ); + }); + + it("handles non-Error thrown values", async () => { + mockGot.mockRejectedValue("string error"); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("string error"); + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("throws when url is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for HTTP monitor"); + }); + }); +}); diff --git a/server/test/unit/providers/network/pageSpeedProvider.test.ts b/server/test/unit/providers/network/pageSpeedProvider.test.ts new file mode 100644 index 000000000..c461b0ad2 --- /dev/null +++ b/server/test/unit/providers/network/pageSpeedProvider.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { PageSpeedProvider } from "../../../../src/service/infrastructure/network/PageSpeedProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts"; +import type { ISettingsService } from "../../../../src/service/system/settingsService.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "pagespeed", + url: "https://example.com", + ...overrides, + }) as Monitor; + +const createMockHttpProvider = () => + ({ + handle: jest.fn().mockResolvedValue({ + monitorId: "mon-1", + teamId: "team-1", + type: "pagespeed", + status: true, + code: 200, + message: "OK", + responseTime: 2000, + payload: { lighthouseResult: {} }, + }), + }) as unknown as jest.Mocked; + +const createMockSettingsService = (apiKey?: string) => + ({ + getDBSettings: jest.fn().mockResolvedValue({ pagespeedApiKey: apiKey }), + }) as unknown as jest.Mocked; + +const createProvider = (opts?: { apiKey?: string }) => { + const logger = createMockLogger(); + const httpProvider = createMockHttpProvider(); + const settingsService = createMockSettingsService(opts?.apiKey); + const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any); + return { provider, httpProvider, settingsService, logger }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("PageSpeedProvider", { + create: () => createProvider({ apiKey: "test-key" }).provider, + supportedType: "pagespeed", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("PageSpeedProvider", () => { + it("delegates to httpProvider with PageSpeed API URL", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "my-key" }); + + await provider.handle(makeMonitor({ url: "https://example.com" })); + + expect(httpProvider.handle).toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining("pagespeedonline.googleapis.com"), + }) + ); + }); + + it("includes API key in URL when available", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "my-key" }); + + await provider.handle(makeMonitor()); + + const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url; + expect(calledUrl).toContain("key=my-key"); + }); + + it("omits API key and logs warning when not configured", async () => { + const { provider, httpProvider, logger } = createProvider({ apiKey: undefined }); + + await provider.handle(makeMonitor()); + + const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url; + expect(calledUrl).not.toContain("key="); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("API key not found"), + service: "PageSpeedProvider", + }) + ); + }); + + it("encodes the monitor URL in the PageSpeed API URL", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + + await provider.handle(makeMonitor({ url: "https://example.com/path?q=1" })); + + const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url; + expect(calledUrl).toContain(encodeURIComponent("https://example.com/path?q=1")); + }); + + it("includes all PageSpeed categories in the URL", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + + await provider.handle(makeMonitor()); + + const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url; + expect(calledUrl).toContain("category=seo"); + expect(calledUrl).toContain("category=accessibility"); + expect(calledUrl).toContain("category=best-practices"); + expect(calledUrl).toContain("category=performance"); + }); + + it("passes all monitor properties through to httpProvider", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + const monitor = makeMonitor({ id: "mon-5", teamId: "team-9" }); + + await provider.handle(monitor); + + expect(httpProvider.handle).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-5", teamId: "team-9" })); + }); + + it("throws AppError when url is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for PageSpeed monitor"); + }); + + it("throws AppError when httpProvider.handle throws", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + (httpProvider.handle as jest.Mock).mockRejectedValue(new Error("timeout")); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("timeout"); + }); + + it("throws AppError with default message for non-Error thrown values", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + (httpProvider.handle as jest.Mock).mockRejectedValue("string error"); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing PageSpeed request"); + }); + + it("handles null apiKey from DB settings", async () => { + const { provider, logger } = createProvider({ apiKey: null as any }); + + await provider.handle(makeMonitor()); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") })); + }); + + it("handles null dbSettings response", async () => { + const logger = createMockLogger(); + const httpProvider = createMockHttpProvider(); + const settingsService = { + getDBSettings: jest.fn().mockResolvedValue(null), + } as unknown as jest.Mocked; + const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any); + + await provider.handle(makeMonitor()); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") })); + }); +}); diff --git a/server/test/unit/providers/network/pingProvider.test.ts b/server/test/unit/providers/network/pingProvider.test.ts new file mode 100644 index 000000000..2f38eef00 --- /dev/null +++ b/server/test/unit/providers/network/pingProvider.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { PingProvider } from "../../../../src/service/infrastructure/network/PingProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "ping", + url: "example.com", + ...overrides, + }) as Monitor; + +const createMockPing = (result?: Partial<{ alive: boolean; time: number | string }>) => ({ + promise: { + probe: jest.fn().mockResolvedValue({ + alive: true, + time: 25, + ...result, + }), + }, +}); + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("PingProvider", { + create: () => new PingProvider(createMockPing() as any), + supportedType: "ping", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("PingProvider", () => { + it("returns success when host is alive", async () => { + const provider = new PingProvider(createMockPing({ alive: true, time: 30 }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "ping", + status: true, + code: 200, + message: "Success", + responseTime: 30, + }) + ); + }); + + it("returns failure when host is not alive", async () => { + const provider = new PingProvider(createMockPing({ alive: false, time: 0 }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: 5000, + message: "Ping failed", + }) + ); + }); + + it("parses string time values", async () => { + const provider = new PingProvider(createMockPing({ alive: true, time: "42.5" }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.responseTime).toBe(42.5); + }); + + it("defaults responseTime to 0 for non-numeric time", async () => { + const provider = new PingProvider(createMockPing({ alive: true, time: "unknown" }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.responseTime).toBe(0); + }); + + it("defaults status to false when alive is undefined", async () => { + const provider = new PingProvider(createMockPing({ alive: undefined as any }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + }); + + it("sanitizes URL by stripping protocol and path", async () => { + const mockPing = createMockPing(); + const provider = new PingProvider(mockPing as any); + + await provider.handle(makeMonitor({ url: "https://example.com/path?query=1" })); + + expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com"); + }); + + it("sanitizes URL by stripping port", async () => { + const mockPing = createMockPing(); + const provider = new PingProvider(mockPing as any); + + await provider.handle(makeMonitor({ url: "example.com:8080" })); + + expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com"); + }); + + it("throws AppError when url is missing", async () => { + const provider = new PingProvider(createMockPing() as any); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for ping monitor"); + }); + + it("throws AppError when ping.probe rejects", async () => { + const mockPing = { promise: { probe: jest.fn().mockRejectedValue(new Error("Host unreachable")) } }; + const provider = new PingProvider(mockPing as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Host unreachable"); + }); + + it("throws AppError when probe returns no response", async () => { + const mockPing = { promise: { probe: jest.fn().mockResolvedValue(null) } }; + const provider = new PingProvider(mockPing as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("No response from ping"); + }); + + it("throws AppError with stringified message for non-Error throws", async () => { + const mockPing = { promise: { probe: jest.fn().mockRejectedValue(42) } }; + const provider = new PingProvider(mockPing as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("42"); + }); +}); diff --git a/server/test/unit/providers/network/portProvider.test.ts b/server/test/unit/providers/network/portProvider.test.ts new file mode 100644 index 000000000..cbf64af16 --- /dev/null +++ b/server/test/unit/providers/network/portProvider.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { PortProvider } from "../../../../src/service/infrastructure/network/PortProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "port", + url: "example.com", + port: 443, + ...overrides, + }) as Monitor; + +const createMockNet = (behavior: "connect" | "timeout" | "error" = "connect") => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }; + + const createConnection = jest.fn((opts: any, onConnect: () => void) => { + if (behavior === "connect") { + // Simulate immediate connection + process.nextTick(onConnect); + } else if (behavior === "timeout") { + process.nextTick(() => { + const timeoutCb = socket.on.mock.calls.find((c: any) => c[0] === "timeout"); + // Simulate via setTimeout handler + const setTimeoutCb = socket.setTimeout.mock.calls[0]; + if (setTimeoutCb) { + // The timeout event handler is registered via socket.on("timeout", ...) + } + }); + } else if (behavior === "error") { + process.nextTick(() => { + const errorCb = socket.on.mock.calls.find((c: any) => c[0] === "error"); + if (errorCb) { + errorCb[1](new Error("ECONNREFUSED")); + } + }); + } + return socket; + }); + + return { createConnection, __socket: socket } as any; +}; + +const createSuccessNet = () => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }; + return { + createConnection: jest.fn((_opts: any, onConnect: () => void) => { + process.nextTick(onConnect); + return socket; + }), + } as any; +}; + +const createErrorNet = (error: Error = new Error("ECONNREFUSED")) => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn((event: string, cb: Function) => { + if (event === "error") { + process.nextTick(() => cb(error)); + } + }), + end: jest.fn(), + destroy: jest.fn(), + }; + return { + createConnection: jest.fn(() => socket), + } as any; +}; + +const createTimeoutNet = () => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn((event: string, cb: Function) => { + if (event === "timeout") { + process.nextTick(() => cb()); + } + }), + end: jest.fn(), + destroy: jest.fn(), + }; + return { + createConnection: jest.fn(() => socket), + } as any; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("PortProvider", { + create: () => new PortProvider(createSuccessNet()), + supportedType: "port", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("PortProvider", () => { + it("returns success when port connection succeeds", async () => { + const provider = new PortProvider(createSuccessNet()); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "port", + status: true, + code: 200, + message: "Port check successful", + payload: { success: true }, + }) + ); + expect(result.responseTime).toBeGreaterThanOrEqual(0); + }); + + it("returns failure when connection errors", async () => { + const provider = new PortProvider(createErrorNet()); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "ECONNREFUSED", + payload: { success: false }, + }) + ); + }); + + it("returns failure with generic message for non-Error connection error", async () => { + const net = createErrorNet(); + // Override the on handler to throw a non-Error + const socket = { + setTimeout: jest.fn(), + on: jest.fn((event: string, cb: Function) => { + if (event === "error") { + process.nextTick(() => cb("string error")); + } + }), + end: jest.fn(), + destroy: jest.fn(), + }; + net.createConnection = jest.fn(() => socket); + + const provider = new PortProvider(net); + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("Port check failed"); + }); + + it("returns failure when connection times out", async () => { + const provider = new PortProvider(createTimeoutNet()); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "Connection timeout", + }) + ); + }); + + it("throws AppError when url is missing", async () => { + const provider = new PortProvider(createSuccessNet()); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL and port are required for port monitoring"); + }); + + it("throws AppError when port is missing", async () => { + const provider = new PortProvider(createSuccessNet()); + + await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("URL and port are required for port monitoring"); + }); + + it("returns failure with generic message when timeRequest catches non-Error", async () => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn((event: string, cb: Function) => { + if (event === "error") { + process.nextTick(() => cb({ notAnError: true })); + } + }), + end: jest.fn(), + destroy: jest.fn(), + }; + const net = { createConnection: jest.fn(() => socket) } as any; + const provider = new PortProvider(net); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("Port check failed"); + }); + + it("throws AppError wrapping the original Error message", async () => { + const provider = new PortProvider(createSuccessNet()); + + try { + await provider.handle(makeMonitor({ url: "" })); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.service).toBe("PortProvider"); + expect(err.method).toBe("handle"); + } + }); + + it("throws AppError when an unexpected error occurs in outer try", async () => { + const provider = new PortProvider(createSuccessNet()); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw new Error("getter exploded"); + return "example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("getter exploded"); + }); + + it("throws AppError with fallback message when Error has empty message", async () => { + const provider = new PortProvider(createSuccessNet()); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw new Error(""); + return "example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("Error performing port check"); + }); + + it("throws AppError with stringified message for non-Error thrown values", async () => { + const provider = new PortProvider(createSuccessNet()); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw 42; + return "example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("42"); + }); +}); diff --git a/server/test/unit/providers/network/utils.test.ts b/server/test/unit/providers/network/utils.test.ts new file mode 100644 index 000000000..56d8e7f9c --- /dev/null +++ b/server/test/unit/providers/network/utils.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "@jest/globals"; +import { timeRequest, NETWORK_ERROR, PING_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; + +describe("network utils", () => { + describe("timeRequest", () => { + it("returns response and responseTime on success", async () => { + const result = await timeRequest(async () => "hello"); + + expect(result.response).toBe("hello"); + expect(result.responseTime).toBeGreaterThanOrEqual(0); + expect(result.error).toBeNull(); + }); + + it("returns error and responseTime on failure", async () => { + const err = new Error("boom"); + const result = await timeRequest(async () => { + throw err; + }); + + expect(result.response).toBeNull(); + expect(result.responseTime).toBeGreaterThanOrEqual(0); + expect(result.error).toBe(err); + }); + + it("measures elapsed time", async () => { + const result = await timeRequest(async () => { + await new Promise((r) => setTimeout(r, 50)); + return "done"; + }); + + expect(result.responseTime).toBeGreaterThanOrEqual(40); + }); + }); + + describe("constants", () => { + it("NETWORK_ERROR is 5000", () => { + expect(NETWORK_ERROR).toBe(5000); + }); + + it("PING_ERROR is 5001", () => { + expect(PING_ERROR).toBe(5001); + }); + }); +}); diff --git a/server/test/unit/providers/network/webSocketProvider.test.ts b/server/test/unit/providers/network/webSocketProvider.test.ts new file mode 100644 index 000000000..73e4416a1 --- /dev/null +++ b/server/test/unit/providers/network/webSocketProvider.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; +import { WebSocketProvider } from "../../../../src/service/infrastructure/network/WebSocketProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "websocket", + url: "wss://example.com/ws", + ignoreTlsErrors: false, + ...overrides, + }) as Monitor; + +const createMockWS = (behavior: "open" | "error" = "open") => { + return jest.fn().mockImplementation((_url: string, _opts: any) => { + const handlers: Record = {}; + const ws = { + on: jest.fn((event: string, cb: Function) => { + handlers[event] = cb; + if (behavior === "open" && event === "open") { + process.nextTick(() => cb()); + } + if (behavior === "error" && event === "error") { + process.nextTick(() => cb(new Error("Connection failed"))); + } + }), + close: jest.fn(), + }; + return ws; + }) as any; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("WebSocketProvider", { + create: () => new WebSocketProvider(createMockWS("open")), + supportedType: "websocket", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("WebSocketProvider", () => { + it("returns success when connection opens", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "websocket", + status: true, + code: 200, + message: "WebSocket check successful", + payload: { connected: true }, + }) + ); + }); + + it("returns failure when connection errors", async () => { + const provider = new WebSocketProvider(createMockWS("error")); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "Connection failed", + payload: { connected: false }, + }) + ); + }); + + it("returns failure with generic message for non-Error connection error", async () => { + const WS = jest.fn().mockImplementation(() => ({ + on: jest.fn((event: string, cb: Function) => { + if (event === "error") { + process.nextTick(() => cb("string error")); + } + }), + close: jest.fn(), + })) as any; + + const provider = new WebSocketProvider(WS); + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("WebSocket check failed"); + }); + + it("passes rejectUnauthorized: false when ignoreTlsErrors is true", async () => { + const WS = jest.fn().mockImplementation((_url: string, opts: any) => { + expect(opts.rejectUnauthorized).toBe(false); + return { + on: jest.fn((event: string, cb: Function) => { + if (event === "open") process.nextTick(() => cb()); + }), + close: jest.fn(), + }; + }) as any; + + const provider = new WebSocketProvider(WS); + await provider.handle(makeMonitor({ ignoreTlsErrors: true })); + + expect(WS).toHaveBeenCalled(); + }); + + it("throws AppError when url is missing", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for WebSocket monitoring"); + }); + + it("throws AppError when an unexpected error occurs in outer try", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw new Error("getter failed"); + return "wss://example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("getter failed"); + }); + + it("throws AppError with fallback message when Error has empty message", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw new Error(""); + return "wss://example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("Error performing WebSocket check"); + }); + + it("throws AppError with stringified message for non-Error thrown values", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw 99; + return "wss://example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("99"); + }); + + describe("connection timeout", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it("returns failure on connection timeout", async () => { + // WS that never fires open or error + const WS = jest.fn().mockImplementation(() => ({ + on: jest.fn(), + close: jest.fn(), + })) as any; + + const provider = new WebSocketProvider(WS); + const promise = provider.handle(makeMonitor()); + + await jest.advanceTimersByTimeAsync(10000); + + const result = await promise; + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "WebSocket connection timeout", + }) + ); + }); + }); +}); diff --git a/server/test/unit/providers/notifications/discordProvider.test.ts b/server/test/unit/providers/notifications/discordProvider.test.ts new file mode 100644 index 000000000..28c30ed22 --- /dev/null +++ b/server/test/unit/providers/notifications/discordProvider.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, jest } 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 { DiscordProvider } = await import("../../../../src/service/infrastructure/notificationProviders/discord.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + const provider = new DiscordProvider(logger as any); + return { provider, logger }; +}; + +testNotificationProviderContract("DiscordProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNotification(), +}); + +describe("DiscordProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("sends test message and returns true", async () => { + const { provider } = createProvider(); + const result = await provider.sendTestAlert(makeNotification()); + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://hooks.example.com/webhook", + expect.objectContaining({ json: expect.objectContaining({ content: expect.any(String) }) }) + ); + }); + + it("returns false when address is missing", async () => { + const { provider } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("network")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert" })); + }); + + it("handles undefined thrown values", async () => { + mockGotPost.mockRejectedValue(undefined); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + }); + + describe("sendMessage", () => { + it("sends embed and returns true", async () => { + const { provider } = createProvider(); + const result = await provider.sendMessage(makeNotification() as any, makeMessage()); + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://hooks.example.com/webhook", + expect.objectContaining({ json: expect.objectContaining({ embeds: expect.any(Array) }) }) + ); + }); + + it("returns false when address is missing", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeNotification({ address: "" }) 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(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" })); + }); + + it("handles undefined thrown values in sendMessage", async () => { + mockGotPost.mockRejectedValue(undefined); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + + it("includes threshold breaches in embed fields", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds()); + const payload = mockGotPost.mock.calls[0][1].json; + const fields = payload.embeds[0].fields; + expect(fields.some((f: any) => f.name === "Threshold Breaches")).toBe(true); + }); + + it("includes details in embed fields", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage()); + const fields = mockGotPost.mock.calls[0][1].json.embeds[0].fields; + expect(fields.some((f: any) => f.name === "Details")).toBe(true); + }); + + it("maps all severity levels to colors", async () => { + const { provider } = createProvider(); + for (const severity of ["critical", "warning", "success", "info"] as const) { + mockGotPost.mockClear(); + await provider.sendMessage(makeNotification() as any, makeMessage({ severity })); + const color = mockGotPost.mock.calls[0][1].json.embeds[0].color; + expect(color).toBeGreaterThan(0); + } + }); + + it("uses info color for unknown severity", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any })); + const color = mockGotPost.mock.calls[0][1].json.embeds[0].color; + expect(color).toBe(0x3b82f6); + }); + + it("omits threshold fields when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + msg.content.details = undefined; + await provider.sendMessage(makeNotification() as any, msg); + const fields = mockGotPost.mock.calls[0][1].json.embeds[0].fields; + expect(fields.some((f: any) => f.name === "Threshold Breaches")).toBe(false); + expect(fields.some((f: any) => f.name === "Details")).toBe(false); + }); + }); +}); diff --git a/server/test/unit/providers/notifications/emailProvider.test.ts b/server/test/unit/providers/notifications/emailProvider.test.ts new file mode 100644 index 000000000..e0dc8b676 --- /dev/null +++ b/server/test/unit/providers/notifications/emailProvider.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { EmailProvider } from "../../../../src/service/infrastructure/notificationProviders/email.ts"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeNotification, makeMessage } from "../../../helpers/notificationMessage.ts"; +import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; +import type { IEmailService } from "../../../../src/service/infrastructure/emailService.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createMockEmailService = () => + ({ + buildEmail: jest.fn().mockResolvedValue("email"), + sendEmail: jest.fn().mockResolvedValue("msg-123"), + }) as unknown as jest.Mocked; + +const createProvider = () => { + const logger = createMockLogger(); + const emailService = createMockEmailService(); + const provider = new EmailProvider(emailService, logger as any); + return { provider, logger, emailService }; +}; + +testNotificationProviderContract("EmailProvider", { + create: () => createProvider().provider, + makeNotification: () => makeNotification(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("EmailProvider", () => { + describe("sendTestAlert", () => { + it("builds test email and sends it", async () => { + const { provider, emailService } = createProvider(); + const result = await provider.sendTestAlert(makeNotification()); + expect(result).toBe(true); + expect(emailService.buildEmail).toHaveBeenCalledWith("testEmailTemplate", { testName: "Monitoring System" }); + expect(emailService.sendEmail).toHaveBeenCalledWith("https://hooks.example.com/webhook", "Test notification", "email"); + }); + + it("returns false when address is missing", async () => { + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Missing address" })); + }); + + it("returns false when buildEmail returns undefined", async () => { + const { provider, emailService, logger } = createProvider(); + (emailService.buildEmail as jest.Mock).mockResolvedValue(undefined); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build test email content" })); + }); + + it("returns false when sendEmail returns falsy", async () => { + const { provider, emailService, logger } = createProvider(); + (emailService.sendEmail as jest.Mock).mockResolvedValue(false); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email test alert failed" })); + }); + }); + + describe("sendMessage", () => { + it("builds email from message and sends it", async () => { + const { provider, emailService } = createProvider(); + const result = await provider.sendMessage(makeNotification() as any, makeMessage()); + expect(result).toBe(true); + expect(emailService.buildEmail).toHaveBeenCalledWith( + "unifiedNotificationTemplate", + expect.objectContaining({ title: "Monitor Down: Test Monitor" }) + ); + expect(emailService.sendEmail).toHaveBeenCalled(); + }); + + it("returns false when address is missing", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false when buildEmail returns undefined", async () => { + const { provider, emailService, logger } = createProvider(); + (emailService.buildEmail as jest.Mock).mockResolvedValue(undefined); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build email content" })); + }); + + it("returns false when sendEmail returns falsy", async () => { + const { provider, emailService, logger } = createProvider(); + (emailService.sendEmail as jest.Mock).mockResolvedValue(undefined); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email notification failed" })); + }); + + it("builds correct subject for monitor_down", async () => { + const { provider, emailService } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_down" })); + expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor is down", expect.anything()); + }); + + it("builds correct subject for monitor_up", async () => { + const { provider, emailService } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_up" })); + expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor is back up", expect.anything()); + }); + + it("builds correct subject for threshold_breach", async () => { + const { provider, emailService } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_breach" })); + expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor threshold exceeded", expect.anything()); + }); + + it("builds correct subject for threshold_resolved", async () => { + const { provider, emailService } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_resolved" })); + expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor thresholds resolved", expect.anything()); + }); + + it("builds default subject for unknown type", async () => { + const { provider, emailService } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ type: "test" })); + expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Alert: Test Monitor", expect.anything()); + }); + + it("passes incident url in context when present", async () => { + const { provider, emailService } = createProvider(); + const msg = makeMessage(); + msg.content.incident = { id: "inc-1", url: "https://app.example.com/incidents/inc-1", createdAt: new Date() }; + await provider.sendMessage(makeNotification() as any, msg); + expect(emailService.buildEmail).toHaveBeenCalledWith( + "unifiedNotificationTemplate", + expect.objectContaining({ incidentUrl: "https://app.example.com/incidents/inc-1" }) + ); + }); + + it("maps all severity levels to colors", async () => { + const { provider, emailService } = createProvider(); + const severityColors: Record = { critical: "red", warning: "#f59e0b", info: "#3b82f6", success: "green" }; + for (const [severity, color] of Object.entries(severityColors)) { + (emailService.buildEmail as jest.Mock).mockClear(); + await provider.sendMessage(makeNotification() as any, makeMessage({ severity: severity as any })); + expect(emailService.buildEmail).toHaveBeenCalledWith("unifiedNotificationTemplate", expect.objectContaining({ headerColor: color })); + } + }); + + it("uses default color for unknown severity", async () => { + const { provider, emailService } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any })); + expect(emailService.buildEmail).toHaveBeenCalledWith("unifiedNotificationTemplate", expect.objectContaining({ headerColor: "#3b82f6" })); + }); + }); +}); diff --git a/server/test/unit/providers/notifications/matrixProvider.test.ts b/server/test/unit/providers/notifications/matrixProvider.test.ts new file mode 100644 index 000000000..f6ad36692 --- /dev/null +++ b/server/test/unit/providers/notifications/matrixProvider.test.ts @@ -0,0 +1,144 @@ +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 mockGotPut = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule("got", () => ({ default: { put: mockGotPut } })); +jest.unstable_mockModule("crypto", () => ({ randomUUID: () => "test-uuid-1234" })); + +const { MatrixProvider } = await import("../../../../src/service/infrastructure/notificationProviders/matrix.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + return { provider: new MatrixProvider(logger as any), logger }; +}; + +testNotificationProviderContract("MatrixProvider", { + create: () => { + mockGotPut.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNotification(), +}); + +describe("MatrixProvider", () => { + beforeEach(() => mockGotPut.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("sends to Matrix API and returns true", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true); + expect(mockGotPut).toHaveBeenCalledWith( + expect.stringContaining("matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/test-uuid-1234"), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer token-abc" }), + }) + ); + }); + + it("returns false when homeserverUrl is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification({ homeserverUrl: undefined }))).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false); + }); + + it("returns false when roomId is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification({ roomId: undefined }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPut.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("handles non-Error thrown values", async () => { + mockGotPut.mockRejectedValue({ message: "custom obj" }); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + }); + + describe("sendMessage", () => { + it("sends formatted HTML and plain text", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true); + const body = mockGotPut.mock.calls[0][1].json; + expect(body.msgtype).toBe("m.text"); + expect(body.format).toBe("org.matrix.custom.html"); + expect(body.body).toContain("Monitor Down"); + expect(body.formatted_body).toContain(" { + expect(await createProvider().provider.sendMessage(makeNotification({ homeserverUrl: undefined }) as any, makeMessage())).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPut.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("includes threshold breaches", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds()); + const body = mockGotPut.mock.calls[0][1].json; + expect(body.body).toContain("CPU"); + expect(body.formatted_body).toContain("CPU"); + }); + + it("includes incident link", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithIncident()); + const body = mockGotPut.mock.calls[0][1].json; + expect(body.body).toContain("View Incident"); + expect(body.formatted_body).toContain("View Incident"); + }); + + it("omits optional sections when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + msg.content.details = undefined; + msg.content.incident = undefined; + await provider.sendMessage(makeNotification() as any, msg); + const body = mockGotPut.mock.calls[0][1].json; + expect(body.body).not.toContain("Threshold"); + expect(body.body).not.toContain("Additional"); + expect(body.body).not.toContain("View Incident"); + }); + + it("escapes HTML special characters", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.monitor.name = ''; + await provider.sendMessage(makeNotification() as any, msg); + const html = mockGotPut.mock.calls[0][1].json.formatted_body; + expect(html).not.toContain("Monitor Down"); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendMessage(makeNotification({ accessToken: undefined }) 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(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" })); + }); + + it("handles non-Error thrown values in sendMessage", async () => { + mockGotPost.mockRejectedValue(42); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } })); + }); + + it("includes thresholds in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds()); + expect(mockGotPost.mock.calls[0][1].json.text).toContain("CPU"); + }); + + it("includes incident link in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithIncident()); + expect(mockGotPost.mock.calls[0][1].json.text).toContain("View Incident"); + }); + + it("omits optional sections when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + msg.content.details = undefined; + msg.content.incident = undefined; + await provider.sendMessage(makeNotification() as any, msg); + const text = mockGotPost.mock.calls[0][1].json.text; + expect(text).not.toContain("Threshold"); + expect(text).not.toContain("Additional"); + expect(text).not.toContain("View Incident"); + }); + }); +}); diff --git a/server/test/unit/providers/notifications/twilioProvider.test.ts b/server/test/unit/providers/notifications/twilioProvider.test.ts new file mode 100644 index 000000000..88bfcdf7e --- /dev/null +++ b/server/test/unit/providers/notifications/twilioProvider.test.ts @@ -0,0 +1,136 @@ +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 { TwilioProvider } = await import("../../../../src/service/infrastructure/notificationProviders/twilio.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + return { provider: new TwilioProvider(logger as any), logger }; +}; + +const makeTwilioNotification = (overrides?: Record) => + makeNotification({ + phone: "+15559876543", + twilioPhoneNumber: "+15551234567", + accountSid: "ACtest123", + ...overrides, + }); + +testNotificationProviderContract("TwilioProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeTwilioNotification(), +}); + +describe("TwilioProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("sends to Twilio API and returns true", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification())).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + expect.stringContaining("api.twilio.com/2010-04-01/Accounts/"), + expect.objectContaining({ + form: expect.objectContaining({ To: "+15559876543", From: "+15551234567" }), + username: expect.any(String), + password: expect.any(String), + }) + ); + }); + + it("returns false when accountSid is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ accountSid: "" }))).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ accessToken: undefined }))).toBe(false); + }); + + it("returns false when phone is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ phone: "" }))).toBe(false); + }); + + it("returns false when twilioPhoneNumber (from number) is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ twilioPhoneNumber: "" }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeTwilioNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } })); + }); + + it("handles non-Error thrown values in sendTestAlert", async () => { + mockGotPost.mockRejectedValue("string error"); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeTwilioNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } })); + }); + }); + + describe("sendMessage", () => { + it("sends SMS and returns true", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(true); + const form = mockGotPost.mock.calls[0][1].form; + expect(form.Body).toContain("Monitor Down"); + expect(form.To).toBe("+15559876543"); + expect(form.From).toBe("+15551234567"); + }); + + it("returns false when accountSid is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ accountSid: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false); + }); + + it("returns false when phone is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ phone: "" }) 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(makeTwilioNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" })); + }); + + it("handles non-Error thrown values in sendMessage", async () => { + mockGotPost.mockRejectedValue(42); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } })); + }); + + it("includes thresholds in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeTwilioNotification() as any, makeMessageWithThresholds()); + expect(mockGotPost.mock.calls[0][1].form.Body).toContain("CPU"); + }); + + it("includes monitor URL in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeTwilioNotification() as any, makeMessage()); + expect(mockGotPost.mock.calls[0][1].form.Body).toContain("https://example.com"); + }); + + it("omits thresholds when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + await provider.sendMessage(makeTwilioNotification() as any, msg); + const text = mockGotPost.mock.calls[0][1].form.Body; + expect(text).not.toContain("CPU"); + }); + }); +}); diff --git a/server/test/unit/providers/notifications/webhookProvider.test.ts b/server/test/unit/providers/notifications/webhookProvider.test.ts new file mode 100644 index 000000000..caf17851b --- /dev/null +++ b/server/test/unit/providers/notifications/webhookProvider.test.ts @@ -0,0 +1,102 @@ +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 { WebhookProvider } = await import("../../../../src/service/infrastructure/notificationProviders/webhook.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + return { provider: new WebhookProvider(logger as any), logger }; +}; + +testNotificationProviderContract("WebhookProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNotification(), +}); + +describe("WebhookProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("returns true on success", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("handles non-Error thrown values", async () => { + mockGotPost.mockRejectedValue(null); + const { provider } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + }); + }); + + describe("sendMessage", () => { + it("sends payload with text and structured data", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true); + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.text).toContain("Monitor Down"); + expect(payload.severity).toBe("critical"); + expect(payload.monitor.id).toBe("mon-1"); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) 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(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("handles non-Error thrown values in sendMessage", async () => { + mockGotPost.mockRejectedValue(null); + const { provider } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + }); + + it("includes threshold breaches in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds()); + expect(mockGotPost.mock.calls[0][1].json.text).toContain("CPU"); + }); + + it("includes incident link in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithIncident()); + expect(mockGotPost.mock.calls[0][1].json.text).toContain("View Incident"); + }); + + it("omits threshold and incident sections when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + msg.content.details = undefined; + msg.content.incident = undefined; + await provider.sendMessage(makeNotification() as any, msg); + const text = mockGotPost.mock.calls[0][1].json.text; + expect(text).not.toContain("Threshold"); + expect(text).not.toContain("Additional Information"); + expect(text).not.toContain("View Incident"); + }); + }); +}); diff --git a/server/test/unit/services/bufferService.test.ts b/server/test/unit/services/bufferService.test.ts new file mode 100644 index 000000000..7a5ee5b88 --- /dev/null +++ b/server/test/unit/services/bufferService.test.ts @@ -0,0 +1,454 @@ +import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; +import { BufferService } from "../../../src/service/infrastructure/bufferService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { ICheckService } from "../../../src/service/business/checkService.ts"; +import type { IGeoChecksService } from "../../../src/service/business/geoChecksService.ts"; +import type { ISettingsService } from "../../../src/service/system/settingsService.ts"; +import type { Check, GeoCheck } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createMockCheckService = () => + ({ + createChecks: jest.fn().mockResolvedValue([]), + }) as unknown as jest.Mocked; + +const createMockGeoChecksService = () => + ({ + createGeoChecks: jest.fn().mockResolvedValue([]), + }) as unknown as jest.Mocked; + +const createMockSettingsService = (nodeEnv: string = "development") => + ({ + getSettings: jest.fn().mockReturnValue({ nodeEnv }), + }) as unknown as jest.Mocked; + +const makeCheck = (overrides?: Partial): Check => + ({ + id: "check-1", + metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" }, + status: true, + statusCode: 200, + responseTime: 100, + message: "OK", + ...overrides, + }) as Check; + +const makeGeoCheck = (overrides?: Partial): GeoCheck => + ({ + id: "geo-1", + monitorId: "mon-1", + ...overrides, + }) as GeoCheck; + +const createService = (nodeEnv: string = "development") => { + const logger = createMockLogger(); + const checkService = createMockCheckService(); + const geoChecksService = createMockGeoChecksService(); + const settingsService = createMockSettingsService(nodeEnv); + const service = new BufferService(logger as any, checkService, geoChecksService, settingsService); + return { service, logger, checkService, geoChecksService }; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("BufferService", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // ── Static / instance properties ───────────────────────────────────── + + describe("serviceName", () => { + it("returns BufferService from static property", () => { + expect(BufferService.SERVICE_NAME).toBe("BufferService"); + }); + + it("returns BufferService from instance getter", () => { + const { service } = createService(); + expect(service.serviceName).toBe("BufferService"); + }); + }); + + // ── constructor ────────────────────────────────────────────────────── + + describe("constructor", () => { + it("logs initialization with development timeout", () => { + const { logger } = createService("development"); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("0.01s"), + service: "BufferService", + method: "constructor", + }) + ); + }); + + it("uses 60s timeout in non-development environment", () => { + const { logger } = createService("production"); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("60s"), + }) + ); + }); + + it("schedules a flush on construction", () => { + createService(); + expect(jest.getTimerCount()).toBeGreaterThanOrEqual(1); + }); + }); + + // ── addToBuffer ────────────────────────────────────────────────────── + + describe("addToBuffer", () => { + it("adds a check to the buffer", async () => { + const { service, checkService } = createService(); + const check = makeCheck(); + + service.addToBuffer(check); + await service.flushBuffer(); + + expect(checkService.createChecks).toHaveBeenCalledWith([check]); + }); + + it("adds multiple checks to the buffer", async () => { + const { service, checkService } = createService(); + const check1 = makeCheck({ id: "c1" }); + const check2 = makeCheck({ id: "c2" }); + + service.addToBuffer(check1); + service.addToBuffer(check2); + await service.flushBuffer(); + + expect(checkService.createChecks).toHaveBeenCalledWith([check1, check2]); + }); + + it("logs error if push throws", () => { + const { service, logger } = createService(); + // Force buffer.push to throw by making buffer non-extensible + Object.defineProperty(service, "buffer", { value: Object.freeze([]) }); + + service.addToBuffer(makeCheck()); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + service: "BufferService", + method: "addToBuffer", + }) + ); + }); + + it("logs 'Unknown error' for non-Error thrown values", () => { + const { service, logger } = createService(); + Object.defineProperty(service, "buffer", { + value: { + push: () => { + throw "string error"; + }, + }, + }); + + service.addToBuffer(makeCheck()); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + stack: undefined, + }) + ); + }); + }); + + // ── addGeoCheckToBuffer ────────────────────────────────────────────── + + describe("addGeoCheckToBuffer", () => { + it("adds a geo check to the buffer", async () => { + const { service, geoChecksService } = createService(); + const geoCheck = makeGeoCheck(); + + service.addGeoCheckToBuffer(geoCheck); + await service.flushGeoBuffer(); + + expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]); + }); + + it("logs error if push throws", () => { + const { service, logger } = createService(); + Object.defineProperty(service, "geoBuffer", { value: Object.freeze([]) }); + + service.addGeoCheckToBuffer(makeGeoCheck()); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + service: "BufferService", + method: "addGeoCheckToBuffer", + }) + ); + }); + + it("logs 'Unknown error' for non-Error thrown values", () => { + const { service, logger } = createService(); + Object.defineProperty(service, "geoBuffer", { + value: { + push: () => { + throw 42; + }, + }, + }); + + service.addGeoCheckToBuffer(makeGeoCheck()); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + stack: undefined, + }) + ); + }); + }); + + // ── scheduleNextFlush ──────────────────────────────────────────────── + + describe("scheduleNextFlush", () => { + it("clears existing timer and sets a new one", () => { + const { service } = createService(); + // Constructor already scheduled one flush + const initialTimerCount = jest.getTimerCount(); + + service.scheduleNextFlush(); + + // Should still have timers (cleared old, set new) + expect(jest.getTimerCount()).toBe(initialTimerCount); + }); + + it("flushes buffer and geo buffer when timer fires", async () => { + const { service, checkService, geoChecksService } = createService(); + service.addToBuffer(makeCheck()); + service.addGeoCheckToBuffer(makeGeoCheck()); + + await jest.advanceTimersByTimeAsync(10); + + expect(checkService.createChecks).toHaveBeenCalled(); + expect(geoChecksService.createGeoChecks).toHaveBeenCalled(); + }); + + it("reschedules after flush completes", async () => { + const { service, checkService } = createService(); + (checkService.createChecks as jest.Mock).mockResolvedValue([]); + + service.addToBuffer(makeCheck()); + await jest.advanceTimersByTimeAsync(10); + + // Add another check and advance again to confirm rescheduling + service.addToBuffer(makeCheck({ id: "c2" })); + await jest.advanceTimersByTimeAsync(10); + + expect(checkService.createChecks).toHaveBeenCalledTimes(2); + }); + + it("reschedules even when flush throws", async () => { + const { service, checkService, logger } = createService(); + (checkService.createChecks as jest.Mock).mockRejectedValueOnce(new Error("DB down")); + service.addToBuffer(makeCheck()); + + await jest.advanceTimersByTimeAsync(10); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "DB down", + method: "flushBuffer", + }) + ); + + // Should still reschedule — add another check and flush + (checkService.createChecks as jest.Mock).mockResolvedValue([]); + service.addToBuffer(makeCheck({ id: "c2" })); + await jest.advanceTimersByTimeAsync(10); + + expect(checkService.createChecks).toHaveBeenCalledTimes(2); + }); + + it("logs error and reschedules when flush throws past its own catch", async () => { + const { service, logger } = createService(); + // Override flushBuffer to throw past its own try/catch + service.flushBuffer = jest.fn<() => Promise>().mockRejectedValueOnce(new Error("unexpected")); + + await jest.advanceTimersByTimeAsync(10); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "unexpected", + method: "scheduleNextFlush", + }) + ); + }); + + it("logs 'Unknown error' when flush throws non-Error past its own catch", async () => { + const { service, logger } = createService(); + service.flushBuffer = jest.fn<() => Promise>().mockRejectedValueOnce("string error"); + + await jest.advanceTimersByTimeAsync(10); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + method: "scheduleNextFlush", + stack: undefined, + }) + ); + }); + }); + + // ── flushBuffer ────────────────────────────────────────────────────── + + describe("flushBuffer", () => { + it("does nothing when buffer is empty", async () => { + const { service, checkService } = createService(); + + await service.flushBuffer(); + + expect(checkService.createChecks).not.toHaveBeenCalled(); + }); + + it("flushes checks to checksService and clears buffer", async () => { + const { service, checkService } = createService(); + const check = makeCheck(); + service.addToBuffer(check); + + await service.flushBuffer(); + + expect(checkService.createChecks).toHaveBeenCalledWith([check]); + // Buffer should be empty now + await service.flushBuffer(); + expect(checkService.createChecks).toHaveBeenCalledTimes(1); + }); + + it("logs debug message before flushing", async () => { + const { service, logger } = createService(); + service.addToBuffer(makeCheck()); + + await service.flushBuffer(); + + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Flushing 1 checks to database", + service: "BufferService", + method: "flushBuffer", + }) + ); + }); + + it("clears buffer even on error to prevent infinite retries", async () => { + const { service, checkService, logger } = createService(); + (checkService.createChecks as jest.Mock).mockRejectedValue(new Error("DB write failed")); + service.addToBuffer(makeCheck()); + + await service.flushBuffer(); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "DB write failed", + method: "flushBuffer", + }) + ); + // Buffer should be cleared + await service.flushBuffer(); + expect(checkService.createChecks).toHaveBeenCalledTimes(1); + }); + + it("logs 'Unknown error' for non-Error thrown values", async () => { + const { service, checkService, logger } = createService(); + (checkService.createChecks as jest.Mock).mockRejectedValue(null); + service.addToBuffer(makeCheck()); + + await service.flushBuffer(); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + method: "flushBuffer", + stack: undefined, + }) + ); + }); + }); + + // ── flushGeoBuffer ─────────────────────────────────────────────────── + + describe("flushGeoBuffer", () => { + it("does nothing when geo buffer is empty", async () => { + const { service, geoChecksService } = createService(); + + await service.flushGeoBuffer(); + + expect(geoChecksService.createGeoChecks).not.toHaveBeenCalled(); + }); + + it("flushes geo checks and clears buffer", async () => { + const { service, geoChecksService } = createService(); + const geoCheck = makeGeoCheck(); + service.addGeoCheckToBuffer(geoCheck); + + await service.flushGeoBuffer(); + + expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]); + // Buffer should be empty now + await service.flushGeoBuffer(); + expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1); + }); + + it("logs debug message before flushing", async () => { + const { service, logger } = createService(); + service.addGeoCheckToBuffer(makeGeoCheck()); + + await service.flushGeoBuffer(); + + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Flushing 1 geo checks to database", + service: "BufferService", + method: "flushGeoBuffer", + }) + ); + }); + + it("clears geo buffer even on error", async () => { + const { service, geoChecksService, logger } = createService(); + (geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(new Error("DB error")); + service.addGeoCheckToBuffer(makeGeoCheck()); + + await service.flushGeoBuffer(); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "DB error", + method: "flushGeoBuffer", + }) + ); + // Buffer should be cleared + await service.flushGeoBuffer(); + expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1); + }); + + it("logs 'Unknown error' for non-Error thrown values", async () => { + const { service, geoChecksService, logger } = createService(); + (geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(undefined); + service.addGeoCheckToBuffer(makeGeoCheck()); + + await service.flushGeoBuffer(); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + method: "flushGeoBuffer", + stack: undefined, + }) + ); + }); + }); +}); diff --git a/server/test/unit/services/checkService.test.ts b/server/test/unit/services/checkService.test.ts new file mode 100644 index 000000000..93919df5f --- /dev/null +++ b/server/test/unit/services/checkService.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { CheckService } from "../../../src/service/business/checkService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { IChecksRepository, IMonitorsRepository } from "../../../src/repositories/index.ts"; +import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createMonitorsRepo = () => + ({ + findById: jest.fn().mockResolvedValue({}), + }) as unknown as jest.Mocked; + +const createChecksRepo = () => + ({ + createChecks: jest.fn().mockImplementation((checks: unknown) => Promise.resolve(checks)), + findByMonitorId: jest.fn().mockResolvedValue({ checks: [], count: 0 }), + findByTeamId: jest.fn().mockResolvedValue({ checks: [], count: 0 }), + findSummaryByTeamId: jest.fn().mockResolvedValue({}), + deleteByMonitorId: jest.fn().mockResolvedValue(5), + deleteByTeamId: jest.fn().mockResolvedValue(10), + deleteOlderThan: jest.fn().mockResolvedValue(3), + }) as unknown as jest.Mocked; + +const createService = () => { + const logger = createMockLogger(); + const monitorsRepository = createMonitorsRepo(); + const checksRepository = createChecksRepo(); + const service = new CheckService(monitorsRepository, logger as any, checksRepository); + return { service, logger, monitorsRepository, checksRepository }; +}; + +const makeStatusResponse = (overrides?: Partial): MonitorStatusResponse => + ({ + monitorId: "mon-1", + teamId: "team-1", + type: "http", + status: true, + code: 200, + message: "OK", + responseTime: 100, + ...overrides, + }) as MonitorStatusResponse; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("CheckService", () => { + describe("serviceName", () => { + it("returns checkService", () => { + const { service } = createService(); + expect(service.serviceName).toBe("checkService"); + }); + }); + + // ── buildCheck ─────────────────────────────────────────────────────────── + + describe("buildCheck", () => { + it("builds a basic HTTP check with correct fields", () => { + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse()); + + expect(check).toBeDefined(); + expect(check!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" }); + expect(check!.status).toBe(true); + expect(check!.statusCode).toBe(200); + expect(check!.responseTime).toBe(100); + expect(check!.message).toBe("OK"); + expect(check!.id).toBeDefined(); + expect(check!.createdAt).toBeDefined(); + }); + + it("defaults responseTime to 0 when falsy", () => { + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ responseTime: undefined })); + expect(check!.responseTime).toBe(0); + }); + + it("includes timings when provided", () => { + const timings = { start: 0, socket: 10, lookup: 20 }; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ timings } as any)); + expect(check!.timings).toBe(timings); + }); + + // ── PageSpeed ──────────────────────────────────────────────────────── + + describe("pagespeed type", () => { + it("extracts category scores and audits from lighthouse result", () => { + const payload: PageSpeedStatusPayload = { + lighthouseResult: { + categories: { + accessibility: { score: 0.9 }, + "best-practices": { score: 0.8 }, + seo: { score: 0.95 }, + performance: { score: 0.7 }, + }, + audits: { + "cumulative-layout-shift": { id: "cls", title: "CLS", score: 0.1, displayValue: "0.1", numericValue: 0.1, numericUnit: "unitless" }, + "speed-index": { id: "si", title: "SI", score: 0.5, displayValue: "3.0s", numericValue: 3000, numericUnit: "millisecond" }, + "first-contentful-paint": { id: "fcp", title: "FCP", score: 0.8 }, + "largest-contentful-paint": { id: "lcp", title: "LCP", score: 0.6 }, + "total-blocking-time": { id: "tbt", title: "TBT", score: 0.9 }, + }, + }, + } as any; + + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + + expect(check!.accessibility).toBe(90); + expect(check!.bestPractices).toBe(80); + expect(check!.seo).toBe(95); + expect(check!.performance).toBe(70); + expect(check!.audits!.cls).toEqual(expect.objectContaining({ id: "cls", score: 0.1, numericValue: 0.1 })); + expect(check!.audits!.si).toEqual(expect.objectContaining({ id: "si", numericValue: 3000 })); + expect(check!.audits!.fcp).toEqual(expect.objectContaining({ id: "fcp" })); + }); + + it("returns undefined when pagespeed payload is missing", () => { + const { service, logger } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: undefined } as any)); + + expect(check).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build check" })); + }); + + it("defaults categories and audits to empty when lighthouseResult is missing", () => { + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: {} } as any)); + + expect(check!.accessibility).toBe(0); + expect(check!.bestPractices).toBe(0); + expect(check!.seo).toBe(0); + expect(check!.performance).toBe(0); + expect(check!.audits!.cls).toBeUndefined(); + }); + + it("handles audit with non-number score (string)", () => { + const payload = { + lighthouseResult: { + categories: {}, + audits: { + "cumulative-layout-shift": { id: "cls", title: "CLS", score: "informative" }, + }, + }, + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + + expect(check!.audits!.cls!.score).toBe("informative"); + }); + + it("handles audit with null score", () => { + const payload = { + lighthouseResult: { + categories: {}, + audits: { + "cumulative-layout-shift": { id: "cls", title: "CLS", score: null }, + }, + }, + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + + expect(check!.audits!.cls!.score).toBeNull(); + }); + + it("handles audit with non-number numericValue", () => { + const payload = { + lighthouseResult: { + categories: {}, + audits: { + "speed-index": { id: "si", title: "SI", score: 0.5, numericValue: "n/a" }, + }, + }, + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + + expect(check!.audits!.si!.numericValue).toBeUndefined(); + }); + + it("returns undefined for non-object audit", () => { + const payload = { + lighthouseResult: { + categories: {}, + audits: { + "cumulative-layout-shift": null, + "speed-index": "not an object", + }, + }, + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + + expect(check!.audits!.cls).toBeUndefined(); + expect(check!.audits!.si).toBeUndefined(); + }); + }); + + // ── Hardware ───────────────────────────────────────────────────────── + + describe("hardware type", () => { + it("extracts cpu, memory, disk, host, net, and capture from payload", () => { + const payload: HardwareStatusPayload = { + data: { + cpu: { usage_percent: 0.5 }, + memory: { usage_percent: 0.6 }, + disk: [{ device: "/dev/sda", usage_percent: 0.7 }], + host: { os: "linux" }, + net: [{ name: "eth0" }], + }, + capture: { screenshot: "base64data" }, + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any)); + + expect(check!.cpu).toEqual({ usage_percent: 0.5 }); + expect(check!.memory).toEqual({ usage_percent: 0.6 }); + expect(check!.disk).toHaveLength(1); + expect(check!.host).toEqual({ os: "linux" }); + expect(check!.net).toHaveLength(1); + expect(check!.capture).toEqual({ screenshot: "base64data" }); + }); + + it("extracts errors from array format", () => { + const payload = { + data: {}, + errors: [{ message: "timeout" }], + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any)); + + expect(check!.errors).toEqual([{ message: "timeout" }]); + }); + + it("extracts errors from nested object format", () => { + const payload = { + data: {}, + errors: { errors: [{ message: "nested error" }] }, + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any)); + + expect(check!.errors).toEqual([{ message: "nested error" }]); + }); + + it("handles undefined payload gracefully", () => { + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload: undefined } as any)); + + expect(check).toBeDefined(); + expect(check!.cpu).toBeUndefined(); + expect(check!.memory).toBeUndefined(); + expect(check!.disk).toBeUndefined(); + expect(check!.errors).toBeUndefined(); + }); + }); + }); + + // ── createChecks ───────────────────────────────────────────────────────── + + describe("createChecks", () => { + it("delegates to repository", async () => { + const { service, checksRepository } = createService(); + const checks = [{ id: "c1" }] as any; + await service.createChecks(checks); + expect(checksRepository.createChecks).toHaveBeenCalledWith(checks); + }); + }); + + // ── getChecksByMonitor ─────────────────────────────────────────────────── + + describe("getChecksByMonitor", () => { + it("returns checks from repository", async () => { + const expected = { checks: [{ id: "c1" }], count: 1 }; + const { service, checksRepository } = createService(); + (checksRepository.findByMonitorId as jest.Mock).mockResolvedValue(expected); + + const result = await service.getChecksByMonitor({ + monitorId: "mon-1", + teamId: "team-1", + sortOrder: "desc", + dateRange: "day", + page: 0, + rowsPerPage: 10, + }); + + expect(result).toBe(expected); + }); + + it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => { + const { service, checksRepository } = createService(); + + await service.getChecksByMonitor({ + monitorId: "mon-1", + teamId: "team-1", + sortOrder: "desc", + dateRange: "day", + page: null as any, + rowsPerPage: null as any, + }); + + expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", undefined, 0, 5, undefined); + }); + + it("passes filter and status parameters", async () => { + const { service, checksRepository } = createService(); + + await service.getChecksByMonitor({ + monitorId: "mon-1", + teamId: "team-1", + sortOrder: "asc", + dateRange: "week", + page: 2, + rowsPerPage: 20, + filter: "error", + status: false, + }); + + expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "asc", "week", "error", 2, 20, false); + }); + + it("throws when monitorId is missing", async () => { + const { service } = createService(); + await expect( + service.getChecksByMonitor({ monitorId: "", teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 }) + ).rejects.toThrow("No monitor ID in request"); + }); + + it("throws when teamId is missing", async () => { + const { service } = createService(); + await expect( + service.getChecksByMonitor({ monitorId: "mon-1", teamId: "", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 }) + ).rejects.toThrow("No team ID in request"); + }); + + it("verifies monitor belongs to team via repository", async () => { + const { service, monitorsRepository } = createService(); + + await service.getChecksByMonitor({ + monitorId: "mon-1", + teamId: "team-1", + sortOrder: "desc", + dateRange: "day", + page: 0, + rowsPerPage: 10, + }); + + expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1"); + }); + }); + + // ── getChecksByTeam ────────────────────────────────────────────────────── + + describe("getChecksByTeam", () => { + it("returns checks from repository", async () => { + const expected = { checks: [], count: 0 }; + const { service, checksRepository } = createService(); + (checksRepository.findByTeamId as jest.Mock).mockResolvedValue(expected); + + const result = await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 }); + + expect(result).toBe(expected); + }); + + it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => { + const { service, checksRepository } = createService(); + + await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: null as any, rowsPerPage: null as any }); + + expect(checksRepository.findByTeamId).toHaveBeenCalledWith("desc", "day", undefined, 0, 5, "team-1"); + }); + }); + + // ── getChecksSummaryByTeamId ────────────────────────────────────────────── + + describe("getChecksSummaryByTeamId", () => { + it("delegates to repository", async () => { + const summary = { total: 5, up: 4, down: 1 }; + const { service, checksRepository } = createService(); + (checksRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary); + + const result = await service.getChecksSummaryByTeamId({ teamId: "team-1", dateRange: "day" }); + + expect(result).toBe(summary); + expect(checksRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", "day"); + }); + }); + + // ── deleteChecks ───────────────────────────────────────────────────────── + + describe("deleteChecks", () => { + it("verifies monitor ownership and deletes", async () => { + const { service, monitorsRepository, checksRepository } = createService(); + + const result = await service.deleteChecks({ monitorId: "mon-1", teamId: "team-1" }); + + expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1"); + expect(checksRepository.deleteByMonitorId).toHaveBeenCalledWith("mon-1"); + expect(result).toBe(5); + }); + }); + + describe("deleteChecksByTeamId", () => { + it("delegates to repository", async () => { + const { service, checksRepository } = createService(); + + const result = await service.deleteChecksByTeamId({ teamId: "team-1" }); + + expect(checksRepository.deleteByTeamId).toHaveBeenCalledWith("team-1"); + expect(result).toBe(10); + }); + }); + + describe("deleteOlderThan", () => { + it("delegates to repository", async () => { + const date = new Date(); + const { service, checksRepository } = createService(); + + const result = await service.deleteOlderThan(date); + + expect(checksRepository.deleteOlderThan).toHaveBeenCalledWith(date); + expect(result).toBe(3); + }); + }); +}); diff --git a/server/test/unit/services/diagnosticService.test.ts b/server/test/unit/services/diagnosticService.test.ts new file mode 100644 index 000000000..a76f28893 --- /dev/null +++ b/server/test/unit/services/diagnosticService.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; +import { DiagnosticService } from "../../../src/service/business/diagnosticService.ts"; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("DiagnosticService", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe("serviceName", () => { + it("returns diagnosticService from static property", () => { + expect(DiagnosticService.SERVICE_NAME).toBe("diagnosticService"); + }); + + it("returns diagnosticService from instance getter", () => { + const service = new DiagnosticService(); + expect(service.serviceName).toBe("diagnosticService"); + }); + }); + + // ── constructor ───────────────────────────────────────────────────────── + + describe("constructor", () => { + it("sets up PerformanceObserver that clears marks on callback", () => { + let capturedCallback: ((list: PerformanceObserverEntryList) => void) | null = null; + const originalObserver = globalThis.PerformanceObserver; + const mockObserve = jest.fn(); + + globalThis.PerformanceObserver = jest.fn().mockImplementation((cb: any) => { + capturedCallback = cb; + return { observe: mockObserve }; + }) as any; + + const clearMarksSpy = jest.spyOn(performance, "clearMarks"); + const service = new DiagnosticService(); + + 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); + + expect(mockEntryList.getEntries).toHaveBeenCalled(); + expect(clearMarksSpy).toHaveBeenCalled(); + + clearMarksSpy.mockRestore(); + globalThis.PerformanceObserver = originalObserver; + expect(service).toBeDefined(); + }); + }); + + // ── 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(); + const promise = service.getSystemStats(); + // getCPUUsage has 1s timeout, then event loop delay has 0ms timeout + 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(typeof result.eventLoopDelayMs).toBe("number"); + expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0); + expect(typeof result.uptimeMs).toBe("number"); + expect(result.uptimeMs).toBeGreaterThan(0); + }); + + 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 result = await service.getSystemStats(); + + expect(typeof result.eventLoopDelayMs).toBe("number"); + expect(result.eventLoopDelayMs).toBeGreaterThanOrEqual(0); + jest.useFakeTimers(); + }); + + it.each([ + { 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 originalGetEntries = performance.getEntriesByName; + const originalMeasure = performance.measure; + const originalMark = performance.mark; + + performance.mark = jest.fn() as any; + performance.measure = jest.fn() as any; + performance.getEntriesByName = jest.fn().mockReturnValue(entries) as any; + + const promise = service.getSystemStats(); + jest.advanceTimersByTime(1000); + await jest.advanceTimersByTimeAsync(10); + const result = await promise; + + expect(result.eventLoopDelayMs).toBe(0); + expect(performance.getEntriesByName).toHaveBeenCalledWith("eventLoopDelay"); + + performance.getEntriesByName = originalGetEntries; + performance.measure = originalMeasure; + performance.mark = originalMark; + }); + }); +}); diff --git a/server/test/unit/services/emailService.test.ts b/server/test/unit/services/emailService.test.ts new file mode 100644 index 000000000..d4d275ad8 --- /dev/null +++ b/server/test/unit/services/emailService.test.ts @@ -0,0 +1,475 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { EmailService } from "../../../src/service/infrastructure/emailService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { ISettingsService } from "../../../src/service/system/settingsService.ts"; +import type { EmailTransportConfig } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeTransportConfig = (overrides?: Partial): EmailTransportConfig => + ({ + systemEmailHost: "smtp.example.com", + systemEmailPort: 587, + systemEmailSecure: false, + systemEmailPool: false, + systemEmailUser: "user@example.com", + systemEmailAddress: "noreply@example.com", + systemEmailPassword: "password123", + systemEmailConnectionHost: "mail.example.com", + systemEmailTLSServername: "smtp.example.com", + systemEmailIgnoreTLS: false, + systemEmailRequireTLS: true, + systemEmailRejectUnauthorized: true, + ...overrides, + }) as EmailTransportConfig; + +const createMockSettingsService = () => + ({ + getDBSettings: jest.fn().mockResolvedValue(makeTransportConfig()), + }) as unknown as jest.Mocked; + +const createMockFs = (templateContent: string = "") => + ({ + readFileSync: jest.fn().mockReturnValue(templateContent), + }) as any; + +const createMockPath = () => + ({ + join: jest.fn((...args: string[]) => args.join("/")), + dirname: jest.fn().mockReturnValue("/mock/dir"), + }) as any; + +const createMockCompile = (result: string = "compiled") => { + const compiledFn = jest.fn().mockReturnValue(result); + const compile = jest.fn().mockReturnValue(compiledFn) as any; + compile.__compiledFn = compiledFn; + return compile; +}; + +const createMockMjml = (html: string = "rendered") => jest.fn().mockReturnValue({ html }) as any; + +const createMockTransporter = () => ({ + verify: jest.fn().mockResolvedValue(true), + sendMail: jest.fn().mockResolvedValue({ messageId: "msg-123" }), +}); + +const createMockNodemailer = (transporter?: ReturnType) => { + const t = transporter ?? createMockTransporter(); + return { + createTransport: jest.fn().mockReturnValue(t), + __transporter: t, + } as any; +}; + +const createService = (overrides?: { settingsService?: any; fs?: any; path?: any; compile?: any; mjml?: any; nodemailer?: any }) => { + const logger = createMockLogger(); + const settingsService = overrides?.settingsService ?? createMockSettingsService(); + const mockFs = overrides?.fs ?? createMockFs(); + const mockPath = overrides?.path ?? createMockPath(); + const compile = overrides?.compile ?? createMockCompile(); + const mjml = overrides?.mjml ?? createMockMjml(); + const mockNodemailer = overrides?.nodemailer ?? createMockNodemailer(); + + const service = new EmailService(settingsService, mockFs, mockPath, compile, mjml, mockNodemailer, logger as any); + + return { service, logger, settingsService, mockFs, mockPath, compile, mjml, mockNodemailer }; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("EmailService", () => { + describe("SERVICE_NAME", () => { + it("returns EmailService from static property", () => { + expect(EmailService.SERVICE_NAME).toBe("EmailService"); + }); + + it("returns EmailService from instance getter", () => { + const { service } = createService(); + expect(service.serviceName).toBe("EmailService"); + }); + }); + + // ── init / loadTemplate ────────────────────────────────────────────── + + describe("init", () => { + it("loads all expected templates on construction", () => { + const { mockFs } = createService(); + + // 6 templates loaded + expect(mockFs.readFileSync).toHaveBeenCalledTimes(6); + }); + + it("compiles each loaded template", () => { + const { compile } = createService(); + + expect(compile).toHaveBeenCalledTimes(6); + }); + + it("logs error when a template file is not found", () => { + const failFs = { + readFileSync: jest.fn().mockImplementation(() => { + throw new Error("ENOENT: no such file"); + }), + }; + + const { logger } = createService({ fs: failFs }); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "ENOENT: no such file", + service: "EmailService", + method: "loadTemplate", + }) + ); + }); + + it("logs 'Unknown error' for non-Error thrown values in loadTemplate", () => { + const failFs = { + readFileSync: jest.fn().mockImplementation(() => { + throw "string error"; + }), + }; + + const { logger } = createService({ fs: failFs }); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + stack: undefined, + }) + ); + }); + + it("can be called again to reinitialize templates", () => { + const { service, mockFs } = createService(); + const initialCalls = mockFs.readFileSync.mock.calls.length; + + service.init(); + + expect(mockFs.readFileSync).toHaveBeenCalledTimes(initialCalls + 6); + }); + }); + + // ── buildEmail ─────────────────────────────────────────────────────── + + describe("buildEmail", () => { + it("compiles template with context and returns rendered HTML", async () => { + const { service } = createService(); + + const result = await service.buildEmail("welcomeEmailTemplate", { name: "Test" }); + + expect(result).toBe("rendered"); + }); + + it("passes context to the compiled template function", async () => { + const compile = createMockCompile(); + const { service } = createService({ compile }); + const context = { name: "Alex", url: "https://example.com" }; + + await service.buildEmail("welcomeEmailTemplate", context); + + expect(compile.__compiledFn).toHaveBeenCalledWith(context); + }); + + it("returns undefined and logs error when template is not found", async () => { + const { service, logger } = createService(); + + const result = await service.buildEmail("nonExistentTemplate", {}); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Template nonExistentTemplate not found", + service: "EmailService", + method: "buildEmail", + }) + ); + }); + + it("returns undefined and logs error when template function returns falsy", async () => { + const compile = jest.fn().mockReturnValue(jest.fn().mockReturnValue(undefined)) as any; + const { service, logger } = createService({ compile }); + + const result = await service.buildEmail("welcomeEmailTemplate", {}); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Template welcomeEmailTemplate not found", + }) + ); + }); + + it("returns undefined and logs error when mjml throws", async () => { + const mjml = jest.fn().mockImplementation(() => { + throw new Error("MJML parse error"); + }) as any; + const { service, logger } = createService({ mjml }); + + const result = await service.buildEmail("welcomeEmailTemplate", {}); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "MJML parse error", + method: "buildEmail", + }) + ); + }); + + it("logs 'Unknown error' for non-Error thrown values in buildEmail", async () => { + const mjml = jest.fn().mockImplementation(() => { + throw null; + }) as any; + const { service, logger } = createService({ mjml }); + + const result = await service.buildEmail("welcomeEmailTemplate", {}); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + method: "buildEmail", + stack: undefined, + }) + ); + }); + }); + + // ── sendEmail ──────────────────────────────────────────────────────── + + describe("sendEmail", () => { + it("sends email with provided transport config and returns messageId", async () => { + const transporter = createMockTransporter(); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ nodemailer }); + const config = makeTransportConfig(); + + const result = await service.sendEmail("to@example.com", "Subject", "

body

", config); + + expect(result).toBe("msg-123"); + expect(transporter.verify).toHaveBeenCalled(); + expect(transporter.sendMail).toHaveBeenCalledWith({ + to: "to@example.com", + from: "noreply@example.com", + subject: "Subject", + html: "

body

", + }); + }); + + it("falls back to DB settings when no transport config is provided", async () => { + const settingsService = createMockSettingsService(); + const transporter = createMockTransporter(); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ settingsService, nodemailer }); + + await service.sendEmail("to@example.com", "Subject", "

body

"); + + expect(settingsService.getDBSettings).toHaveBeenCalled(); + }); + + it("creates transporter with correct email config", async () => { + const transporter = createMockTransporter(); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ nodemailer }); + const config = makeTransportConfig({ + systemEmailHost: "smtp.test.com", + systemEmailPort: 465, + systemEmailSecure: true, + systemEmailPool: true, + systemEmailUser: "testuser", + systemEmailPassword: "testpass", + systemEmailConnectionHost: "conn.test.com", + systemEmailRejectUnauthorized: false, + systemEmailIgnoreTLS: true, + systemEmailRequireTLS: false, + systemEmailTLSServername: "tls.test.com", + }); + + await service.sendEmail("to@example.com", "Subject", "

body

", config); + + expect(nodemailer.createTransport).toHaveBeenCalledWith({ + host: "smtp.test.com", + port: 465, + secure: true, + auth: { user: "testuser", pass: "testpass" }, + name: "conn.test.com", + connectionTimeout: 5000, + pool: true, + tls: { + rejectUnauthorized: false, + ignoreTLS: true, + requireTLS: false, + servername: "tls.test.com", + }, + }); + }); + + it("uses systemEmailAddress as auth user when systemEmailUser is falsy", async () => { + const transporter = createMockTransporter(); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ nodemailer }); + const config = makeTransportConfig({ systemEmailUser: "", systemEmailAddress: "fallback@example.com" }); + + await service.sendEmail("to@example.com", "Subject", "

body

", config); + + expect(nodemailer.createTransport).toHaveBeenCalledWith( + expect.objectContaining({ + auth: expect.objectContaining({ user: "fallback@example.com" }), + }) + ); + }); + + it("uses 'localhost' as name when systemEmailConnectionHost is falsy", async () => { + const transporter = createMockTransporter(); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ nodemailer }); + const config = makeTransportConfig({ systemEmailConnectionHost: "" }); + + await service.sendEmail("to@example.com", "Subject", "

body

", config); + + expect(nodemailer.createTransport).toHaveBeenCalledWith(expect.objectContaining({ name: "localhost" })); + }); + + it("returns false when transporter verification fails", async () => { + const transporter = createMockTransporter(); + (transporter.verify as jest.Mock).mockRejectedValue(new Error("SMTP auth failed")); + const nodemailer = createMockNodemailer(transporter); + const { service, logger } = createService({ nodemailer }); + + const result = await service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig()); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Email transporter verification failed", + service: "EmailService", + method: "verifyTransporter", + }) + ); + }); + + it("logs stack as undefined when verification fails with non-Error", async () => { + const transporter = createMockTransporter(); + (transporter.verify as jest.Mock).mockRejectedValue("connection refused"); + const nodemailer = createMockNodemailer(transporter); + const { service, logger } = createService({ nodemailer }); + + const result = await service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig()); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + + it("returns undefined and logs error when sendMail throws", async () => { + const transporter = createMockTransporter(); + (transporter.sendMail as jest.Mock).mockRejectedValue(new Error("Recipient rejected")); + const nodemailer = createMockNodemailer(transporter); + const { service, logger } = createService({ nodemailer }); + + const result = await service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig()); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Recipient rejected", + service: "EmailService", + method: "sendEmail", + }) + ); + }); + + it("logs 'Unknown error' for non-Error thrown values in sendMail", async () => { + const transporter = createMockTransporter(); + (transporter.sendMail as jest.Mock).mockRejectedValue(42); + const nodemailer = createMockNodemailer(transporter); + const { service, logger } = createService({ nodemailer }); + + const result = await service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig()); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + method: "sendEmail", + stack: undefined, + }) + ); + }); + + // ── 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/geoChecksService.test.ts b/server/test/unit/services/geoChecksService.test.ts new file mode 100644 index 000000000..ab03d319e --- /dev/null +++ b/server/test/unit/services/geoChecksService.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { GeoChecksService } from "../../../src/service/business/geoChecksService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { Monitor } from "../../../src/types/index.ts"; +import type { GeoCheckResult } from "../../../src/types/geoCheck.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "http", + url: "https://example.com", + geoCheckEnabled: true, + geoCheckLocations: ["NA", "EU"], + ...overrides, + }) as Monitor; + +const makeGeoResult = (overrides?: Partial): GeoCheckResult => ({ + continent: "NA", + country: "US", + city: "New York", + status: "up", + responseTime: 50, + ...overrides, +}); + +const createService = (overrides?: Record) => { + const logger = createMockLogger(); + const geoChecksRepository = { + createGeoChecks: jest.fn().mockResolvedValue([]), + findByMonitorId: jest.fn().mockResolvedValue({ geoChecksCount: 0, geoChecks: [] }), + }; + const globalPingService = { + createMeasurement: jest.fn().mockResolvedValue("measurement-123"), + pollForResults: jest.fn().mockResolvedValue([makeGeoResult()]), + }; + const monitorsRepository = { + findById: jest.fn().mockResolvedValue(makeMonitor()), + }; + + const defaults = { logger, geoChecksRepository, globalPingService, monitorsRepository, ...overrides }; + const service = new GeoChecksService(defaults as any); + return { service, ...defaults }; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("GeoChecksService", () => { + describe("serviceName", () => { + it("returns GeoChecksService from static property", () => { + expect(GeoChecksService.SERVICE_NAME).toBe("GeoChecksService"); + }); + + it("returns GeoChecksService from instance getter", () => { + const { service } = createService(); + expect(service.serviceName).toBe("GeoChecksService"); + }); + }); + + // ── buildGeoCheck ─────────────────────────────────────────────────────── + + describe("buildGeoCheck", () => { + it("returns a geo check document on success", async () => { + const { service } = createService(); + + const result = await service.buildGeoCheck(makeMonitor()); + + expect(result).not.toBeNull(); + expect(result!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" }); + expect(result!.results).toHaveLength(1); + expect(result!.results[0].continent).toBe("NA"); + expect(result!.id).toBeDefined(); + expect(result!.expiry).toBeDefined(); + }); + + it("returns null and warns when monitor has no URL", async () => { + const { service, logger } = createService(); + + const result = await service.buildGeoCheck(makeMonitor({ url: "" })); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing URL for geo check" })); + }); + + it("returns null and warns when geoCheckLocations is empty", async () => { + const { service, logger } = createService(); + + const result = await service.buildGeoCheck(makeMonitor({ geoCheckLocations: [] })); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing geo check locations" })); + }); + + it("returns null and warns when geoCheckLocations is undefined", async () => { + const { service, logger } = createService(); + + const result = await service.buildGeoCheck(makeMonitor({ geoCheckLocations: undefined as any })); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Monitor missing geo check locations" })); + }); + + it("returns null when createMeasurement returns null (API unavailable)", async () => { + const { service, logger } = createService({ + globalPingService: { + createMeasurement: jest.fn().mockResolvedValue(null), + pollForResults: jest.fn(), + }, + }); + + const result = await service.buildGeoCheck(makeMonitor()); + + expect(result).toBeNull(); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Skipping geo check due to API unavailability" })); + }); + + it("returns null when pollForResults returns empty array", async () => { + const { service, logger } = createService({ + globalPingService: { + createMeasurement: jest.fn().mockResolvedValue("measurement-123"), + pollForResults: jest.fn().mockResolvedValue([]), + }, + }); + + const result = await service.buildGeoCheck(makeMonitor()); + + expect(result).toBeNull(); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "No successful geo check results" })); + }); + + it("returns null and logs error when an exception is thrown", async () => { + const { service, logger } = createService({ + globalPingService: { + createMeasurement: jest.fn().mockRejectedValue(new Error("API error")), + pollForResults: jest.fn(), + }, + }); + + const result = await service.buildGeoCheck(makeMonitor()); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Error executing geo check", + details: expect.objectContaining({ error: "API error" }), + }) + ); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { service, logger } = createService({ + globalPingService: { + createMeasurement: jest.fn().mockRejectedValue("string error"), + pollForResults: jest.fn(), + }, + }); + + const result = await service.buildGeoCheck(makeMonitor()); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ details: expect.objectContaining({ error: "Unknown error" }) })); + }); + }); + + // ── createGeoChecks ───────────────────────────────────────────────────── + + describe("createGeoChecks", () => { + it("delegates to repository", async () => { + const { service, geoChecksRepository } = createService(); + const geoChecks = [{ id: "gc-1" }] as any; + + await service.createGeoChecks(geoChecks); + + expect(geoChecksRepository.createGeoChecks).toHaveBeenCalledWith(geoChecks); + }); + }); + + // ── getGeoChecksByMonitor ──────────────────────────────────────────────── + + describe("getGeoChecksByMonitor", () => { + it("returns geo checks from repository", async () => { + const expected = { geoChecksCount: 1, geoChecks: [{ id: "gc-1" }] }; + const { service, geoChecksRepository } = createService(); + (geoChecksRepository.findByMonitorId as jest.Mock).mockResolvedValue(expected); + + const result = await service.getGeoChecksByMonitor({ + monitorId: "mon-1", + teamId: "team-1", + sortOrder: "desc", + dateRange: "day", + continent: "NA" as any, + }); + + expect(result).toEqual(expected); + expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, ["NA"]); + }); + + it("passes array of continents through", async () => { + const { service, geoChecksRepository } = createService(); + + await service.getGeoChecksByMonitor({ + monitorId: "mon-1", + teamId: "team-1", + sortOrder: "desc", + dateRange: "day", + continent: ["NA", "EU"] as any, + }); + + expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, ["NA", "EU"]); + }); + + it("passes undefined continents when not provided", async () => { + const { service, geoChecksRepository } = createService(); + + await service.getGeoChecksByMonitor({ + monitorId: "mon-1", + teamId: "team-1", + sortOrder: "desc", + dateRange: "day", + continent: undefined as any, + }); + + expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", 0, 5, undefined); + }); + + it("uses provided page and rowsPerPage", async () => { + const { service, geoChecksRepository } = createService(); + + await service.getGeoChecksByMonitor({ + monitorId: "mon-1", + teamId: "team-1", + sortOrder: "asc", + dateRange: "week", + page: 3, + rowsPerPage: 20, + continent: "EU" as any, + }); + + expect(geoChecksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "asc", "week", 3, 20, ["EU"]); + }); + + it("throws when monitorId is missing", async () => { + const { service } = createService(); + + await expect( + service.getGeoChecksByMonitor({ monitorId: "", teamId: "team-1", sortOrder: "desc", dateRange: "day", continent: "NA" as any }) + ).rejects.toThrow("No monitor ID in request"); + }); + + it("throws when teamId is missing", async () => { + const { service } = createService(); + + await expect( + service.getGeoChecksByMonitor({ monitorId: "mon-1", teamId: "", sortOrder: "desc", dateRange: "day", continent: "NA" as any }) + ).rejects.toThrow("No team ID in request"); + }); + + it("throws 404 when monitor is not found", async () => { + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + + await expect( + service.getGeoChecksByMonitor({ monitorId: "mon-1", teamId: "team-1", sortOrder: "desc", dateRange: "day", continent: "NA" as any }) + ).rejects.toThrow("Monitor with ID mon-1 not found."); + }); + }); +}); diff --git a/server/test/unit/services/globalPingService.test.ts b/server/test/unit/services/globalPingService.test.ts new file mode 100644 index 000000000..78d195b0f --- /dev/null +++ b/server/test/unit/services/globalPingService.test.ts @@ -0,0 +1,499 @@ +import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { GeoContinent } from "../../../src/types/geoCheck.ts"; + +// ── got mock ───────────────────────────────────────────────────────────────── + +const mockGotPost = jest.fn(); +const mockGotGet = jest.fn(); + +jest.unstable_mockModule("got", () => ({ + default: { + post: mockGotPost, + get: mockGotGet, + }, +})); + +// Dynamic import AFTER mock registration +const { GlobalPingService } = await import("../../../src/service/infrastructure/globalPingService.ts"); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createService = () => { + const logger = createMockLogger(); + const service = new GlobalPingService(logger as any); + return { service, logger }; +}; + +const makeProbeResult = (overrides?: Record) => ({ + probe: { + continent: "NA" as GeoContinent, + region: "Northern America", + country: "US", + state: "CA", + city: "San Francisco", + longitude: -122.4, + latitude: 37.77, + }, + result: { + status: "finished", + statusCode: 200, + statusCodeName: "OK", + timings: { + total: 150, + dns: 10, + tcp: 20, + tls: 30, + firstByte: 50, + download: 40, + }, + }, + ...overrides, +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("GlobalPingService", () => { + beforeEach(() => { + jest.useFakeTimers(); + mockGotPost.mockReset(); + mockGotGet.mockReset(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // ── Static / instance properties ───────────────────────────────────── + + describe("serviceName", () => { + it("returns GlobalPingService from static property", () => { + expect(GlobalPingService.SERVICE_NAME).toBe("GlobalPingService"); + }); + + it("returns GlobalPingService from instance getter", () => { + const { service } = createService(); + expect(service.serviceName).toBe("GlobalPingService"); + }); + }); + + // ── createMeasurement ──────────────────────────────────────────────── + + describe("createMeasurement", () => { + it("creates a measurement and returns the id", async () => { + const { service, logger } = createService(); + mockGotPost.mockResolvedValue({ body: { id: "meas-123" } }); + + const result = await service.createMeasurement("http", "https://example.com", ["NA", "EU"] as GeoContinent[]); + + expect(result).toBe("meas-123"); + expect(mockGotPost).toHaveBeenCalledWith( + "https://api.globalping.io/v1/measurements", + expect.objectContaining({ + json: { + type: "http", + target: "example.com", + locations: [{ continent: "NA" }, { continent: "EU" }], + limit: 2, + }, + responseType: "json", + timeout: { request: 10000 }, + }) + ); + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("meas-123"), + method: "createMeasurement", + }) + ); + }); + + it("strips http:// protocol from target URL", async () => { + const { service } = createService(); + mockGotPost.mockResolvedValue({ body: { id: "meas-1" } }); + + await service.createMeasurement("http", "http://example.com", ["NA"] as GeoContinent[]); + + expect(mockGotPost).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + json: expect.objectContaining({ target: "example.com" }), + }) + ); + }); + + it("strips https:// protocol from target URL", async () => { + const { service } = createService(); + mockGotPost.mockResolvedValue({ body: { id: "meas-1" } }); + + await service.createMeasurement("http", "https://example.com/path", ["EU"] as GeoContinent[]); + + expect(mockGotPost).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + json: expect.objectContaining({ target: "example.com/path" }), + }) + ); + }); + + it("returns null and logs error for unsupported monitor type", async () => { + const { service, logger } = createService(); + + const result = await service.createMeasurement("hardware" as any, "https://example.com", ["NA"] as GeoContinent[]); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "GlobalPing API unavailable, skipping geo check", + method: "createMeasurement", + }) + ); + }); + + it("returns null and logs error when API call fails", async () => { + const { service, logger } = createService(); + mockGotPost.mockRejectedValue(new Error("Network error")); + + const result = await service.createMeasurement("http", "https://example.com", ["NA"] as GeoContinent[]); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "GlobalPing API unavailable, skipping geo check", + method: "createMeasurement", + }) + ); + }); + + it("logs stack as undefined for non-Error thrown values", async () => { + const { service, logger } = createService(); + mockGotPost.mockRejectedValue("string error"); + + await service.createMeasurement("http", "https://example.com", ["NA"] as GeoContinent[]); + + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + + it("supports ping monitor type", async () => { + const { service } = createService(); + mockGotPost.mockResolvedValue({ body: { id: "meas-ping" } }); + + const result = await service.createMeasurement("ping", "example.com", ["NA"] as GeoContinent[]); + + expect(result).toBe("meas-ping"); + }); + }); + + // ── pollForResults ─────────────────────────────────────────────────── + + describe("pollForResults", () => { + it("returns transformed results when measurement is finished", async () => { + const { service, logger } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [makeProbeResult()], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results).toHaveLength(1); + expect(results[0]).toEqual( + expect.objectContaining({ + location: expect.objectContaining({ + continent: "NA", + city: "San Francisco", + }), + status: true, + statusCode: 200, + timings: expect.objectContaining({ + total: 150, + dns: 10, + }), + }) + ); + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("meas-123"), + method: "pollForResults", + }) + ); + }); + + it("defaults results to empty array when finished with no results", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { status: "finished", results: undefined }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results).toEqual([]); + }); + + it("returns empty array when measurement has failed", async () => { + const { service, logger } = createService(); + mockGotGet.mockResolvedValue({ + body: { status: "failed" }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("failed"), + method: "pollForResults", + }) + ); + }); + + it("polls again when status is in-progress, then returns on finished", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValueOnce({ body: { status: "in-progress" } }).mockResolvedValueOnce({ + body: { + status: "finished", + results: [makeProbeResult()], + }, + }); + + const promise = service.pollForResults("meas-123"); + + // Advance past the sleep(2000) + await jest.advanceTimersByTimeAsync(2000); + + const results = await promise; + + expect(mockGotGet).toHaveBeenCalledTimes(2); + expect(results).toHaveLength(1); + }); + + it("returns empty array and logs error when API call throws", async () => { + const { service, logger } = createService(); + mockGotGet.mockRejectedValue(new Error("Connection refused")); + + const results = await service.pollForResults("meas-123"); + + expect(results).toEqual([]); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Error polling GlobalPing API", + method: "pollForResults", + }) + ); + }); + + it("logs stack as undefined for non-Error thrown values in poll", async () => { + const { service, logger } = createService(); + mockGotGet.mockRejectedValue(42); + + await service.pollForResults("meas-123"); + + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + + it("returns empty array and logs warning on timeout", async () => { + const { service, logger } = createService(); + // Always return in-progress so we hit the timeout + mockGotGet.mockImplementation(async () => ({ body: { status: "in-progress" } })); + + const promise = service.pollForResults("meas-123", 100); + + // Advance time well past the timeout + await jest.advanceTimersByTimeAsync(35000); + + const results = await promise; + + expect(results).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("timeout"), + method: "pollForResults", + details: expect.objectContaining({ measurementId: "meas-123", timeoutMs: 100 }), + }) + ); + }); + }); + + // ── transformResults (via pollForResults) ──────────────────────────── + + describe("transformResults", () => { + it("skips probes with non-finished status", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [makeProbeResult({ result: { status: "failed" } }), makeProbeResult({ result: { status: "timeout" } })], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results).toEqual([]); + }); + + it("transforms HTTP results with statusCode and timings", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [makeProbeResult()], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results[0].status).toBe(true); + expect(results[0].statusCode).toBe(200); + expect(results[0].timings.total).toBe(150); + }); + + it("marks HTTP result as failed for non-2xx status codes", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [ + makeProbeResult({ + result: { + status: "finished", + statusCode: 500, + timings: { total: 100, dns: 5, tcp: 10, tls: 15, firstByte: 30, download: 40 }, + }, + }), + ], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results[0].status).toBe(false); + expect(results[0].statusCode).toBe(500); + }); + + it("transforms ping results with stats (no loss)", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [ + makeProbeResult({ + result: { + status: "finished", + stats: { min: 10, max: 30, avg: 20, total: 3, loss: 0, rcv: 3, drop: 0 }, + }, + }), + ], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results[0].status).toBe(true); + expect(results[0].statusCode).toBe(200); + expect(results[0].timings.total).toBe(20); + expect(results[0].timings.dns).toBe(0); + }); + + it("transforms ping results with loss as failed", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [ + makeProbeResult({ + result: { + status: "finished", + stats: { min: 10, max: 30, avg: 20, total: 3, loss: 2, rcv: 1, drop: 2 }, + }, + }), + ], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results[0].status).toBe(false); + expect(results[0].statusCode).toBe(5000); + }); + + it("uses empty string for null state in location", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [ + makeProbeResult({ + probe: { + continent: "EU", + region: "Western Europe", + country: "DE", + state: null, + city: "Berlin", + longitude: 13.4, + latitude: 52.5, + }, + }), + ], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results[0].location.state).toBe(""); + }); + + it("skips probes with no statusCode/timings and no stats", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [ + makeProbeResult({ + result: { status: "finished" }, + }), + ], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results).toEqual([]); + }); + + it("transforms multiple probes from different locations", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [ + makeProbeResult(), + makeProbeResult({ + probe: { + continent: "EU", + region: "Western Europe", + country: "GB", + state: null, + city: "London", + longitude: -0.12, + latitude: 51.5, + }, + result: { + status: "finished", + statusCode: 200, + timings: { total: 80, dns: 5, tcp: 10, tls: 15, firstByte: 30, download: 20 }, + }, + }), + ], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results).toHaveLength(2); + expect(results[0].location.continent).toBe("NA"); + expect(results[1].location.continent).toBe("EU"); + }); + }); +}); diff --git a/server/test/unit/services/incidentService.test.ts b/server/test/unit/services/incidentService.test.ts new file mode 100644 index 000000000..0a17cbd79 --- /dev/null +++ b/server/test/unit/services/incidentService.test.ts @@ -0,0 +1,472 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { IncidentService } from "../../../src/service/business/incidentService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { IIncidentsRepository, 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 { Incident } from "../../../src/types/index.ts"; +import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createIncidentsRepo = () => + ({ + findActiveByMonitorId: jest.fn(), + findActiveByIncidentId: jest.fn(), + create: jest.fn(), + updateById: jest.fn(), + findById: jest.fn(), + findByTeamId: jest.fn(), + countByTeamId: jest.fn(), + findSummaryByTeamId: jest.fn(), + }) as unknown as jest.Mocked; + +const createMonitorsRepo = () => + ({ + findById: jest.fn(), + }) as unknown as jest.Mocked; + +const createUsersRepo = () => + ({ + findById: jest.fn(), + }) as unknown as jest.Mocked; + +const createMessageBuilder = () => + ({ + extractThresholdBreaches: jest.fn(), + }) as unknown as jest.Mocked; + +const createService = (overrides?: { + logger?: ReturnType; + incidentsRepository?: ReturnType; + monitorsRepository?: ReturnType; + usersRepository?: ReturnType; + notificationMessageBuilder?: ReturnType; +}) => { + const logger = overrides?.logger ?? createMockLogger(); + const incidentsRepository = overrides?.incidentsRepository ?? createIncidentsRepo(); + const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo(); + const usersRepository = overrides?.usersRepository ?? createUsersRepo(); + const notificationMessageBuilder = overrides?.notificationMessageBuilder ?? createMessageBuilder(); + + const service = new IncidentService(logger as any, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder); + return { service, logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder }; +}; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + name: "Test Monitor", + type: "http", + url: "https://example.com", + status: "down", + ...overrides, + }) as Monitor; + +const makeIncident = (overrides?: Partial): Incident => + ({ + id: "inc-1", + monitorId: "mon-1", + teamId: "team-1", + startTime: "1700000000000", + status: true, + statusCode: 500, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Incident; + +const makeDecision = (overrides?: Partial): MonitorActionDecision => ({ + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: false, + incidentReason: null, + notificationReason: null, + ...overrides, +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("IncidentService", () => { + describe("serviceName", () => { + it("returns incidentService", () => { + const { service } = createService(); + expect(service.serviceName).toBe("incidentService"); + }); + }); + + // ── handleIncident ─────────────────────────────────────────────────────── + + describe("handleIncident", () => { + it("returns null when neither create nor resolve is requested", async () => { + const { service } = createService(); + const result = await service.handleIncident(makeMonitor(), 200, makeDecision()); + expect(result).toBeNull(); + }); + + it("returns existing active incident when shouldCreateIncident and one already exists", async () => { + const existing = makeIncident(); + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(existing); + + const result = await service.handleIncident(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" })); + + expect(result).toBe(existing); + expect(incidentsRepository.create).not.toHaveBeenCalled(); + }); + + it("creates a new incident when shouldCreateIncident and no active incident exists", async () => { + const created = makeIncident(); + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); + (incidentsRepository.create as jest.Mock).mockResolvedValue(created); + + const result = await service.handleIncident(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" })); + + expect(result).toBe(created); + expect(incidentsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + status: true, + statusCode: 500, + }) + ); + }); + + it("uses status code 9999 and builds message for threshold_breach incidents", async () => { + const created = makeIncident(); + const { service, incidentsRepository, notificationMessageBuilder } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); + (incidentsRepository.create as jest.Mock).mockResolvedValue(created); + (notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([ + { metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" }, + ]); + + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" }); + await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any); + + expect(incidentsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 9999, + message: "CPU: 95% (threshold: 80%)", + }) + ); + }); + + it("uses fallback message when monitorStatusResponse is undefined for threshold_breach", async () => { + const created = makeIncident(); + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); + (incidentsRepository.create as jest.Mock).mockResolvedValue(created); + + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" }); + await service.handleIncident(makeMonitor(), 200, decision, undefined); + + expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" })); + }); + + it("uses fallback message when extractThresholdBreaches returns empty array", async () => { + const created = makeIncident(); + const { service, incidentsRepository, notificationMessageBuilder } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); + (incidentsRepository.create as jest.Mock).mockResolvedValue(created); + (notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([]); + + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" }); + await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any); + + expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" })); + }); + + it("joins multiple threshold breaches with commas", async () => { + const created = makeIncident(); + const { service, incidentsRepository, notificationMessageBuilder } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); + (incidentsRepository.create as jest.Mock).mockResolvedValue(created); + (notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([ + { metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" }, + { metric: "memory", formattedValue: "90%", threshold: 80, unit: "%" }, + ]); + + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" }); + await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any); + + expect(incidentsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ message: "CPU: 95% (threshold: 80%), MEMORY: 90% (threshold: 80%)" }) + ); + }); + + it("resolves active incident when shouldResolveIncident", async () => { + const active = makeIncident(); + const resolved = makeIncident({ status: false, endTime: "123", resolutionType: "automatic" }); + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(active); + (incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved); + + const result = await service.handleIncident(makeMonitor(), 200, makeDecision({ shouldResolveIncident: true })); + + expect(result).toBe(resolved); + expect(incidentsRepository.updateById).toHaveBeenCalledWith( + "inc-1", + "team-1", + expect.objectContaining({ status: false, resolutionType: "automatic" }) + ); + }); + + it("returns null when shouldResolveIncident but no active incident exists", async () => { + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); + + const result = await service.handleIncident(makeMonitor(), 200, makeDecision({ shouldResolveIncident: true })); + + expect(result).toBeNull(); + }); + }); + + // ── resolveIncident ────────────────────────────────────────────────────── + + describe("resolveIncident", () => { + it("resolves incident with manual resolution", async () => { + const active = makeIncident(); + const resolved = makeIncident({ status: false }); + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active); + (incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved); + + const result = await service.resolveIncident("inc-1", "user-1", "team-1", "Fixed it", "user@test.com"); + + expect(result).toBe(resolved); + expect(incidentsRepository.updateById).toHaveBeenCalledWith( + "inc-1", + "team-1", + expect.objectContaining({ + resolutionType: "manual", + status: false, + resolvedBy: "user-1", + resolvedByEmail: "user@test.com", + comment: "Fixed it", + }) + ); + }); + + it("sets resolvedByEmail and comment to null when not provided", async () => { + const active = makeIncident(); + const resolved = makeIncident({ status: false }); + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active); + (incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved); + + await service.resolveIncident("inc-1", "user-1", "team-1"); + + expect(incidentsRepository.updateById).toHaveBeenCalledWith( + "inc-1", + "team-1", + expect.objectContaining({ + resolvedByEmail: null, + comment: null, + }) + ); + }); + + it("throws when incidentId is missing", async () => { + const { service } = createService(); + await expect(service.resolveIncident("", "user-1", "team-1")).rejects.toThrow("No incident ID in request"); + }); + + it("throws when userId is missing", async () => { + const { service } = createService(); + await expect(service.resolveIncident("inc-1", "", "team-1")).rejects.toThrow("No user ID in request"); + }); + + it("throws when teamId is missing", async () => { + const { service } = createService(); + await expect(service.resolveIncident("inc-1", "user-1", "")).rejects.toThrow("No team ID in request"); + }); + + it("throws when incident is not found", async () => { + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(null); + + await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident not found"); + }); + + it("throws when incident is already resolved", async () => { + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(makeIncident({ status: false })); + + await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident is already resolved"); + }); + + it("logs error and rethrows on unexpected failure", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue(new Error("db error")); + + await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("db error"); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ service: "incidentService", method: "resolveIncident", message: "db error" }) + ); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue("string error"); + + await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toBe("string error"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── getIncidentsByTeam ─────────────────────────────────────────────────── + + describe("getIncidentsByTeam", () => { + it("returns incidents and count", async () => { + const incidents = [makeIncident()]; + const { service, incidentsRepository } = createService(); + (incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue(incidents); + (incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(1); + + const result = await service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined); + + expect(result).toEqual({ incidents, count: 1 }); + }); + + it("defaults page to 0 and rowsPerPage to 20 when nullish", async () => { + const { service, incidentsRepository } = createService(); + (incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]); + (incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0); + + await service.getIncidentsByTeam("team-1", "desc", "day", null as any, null as any, undefined, undefined, undefined); + + expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.anything(), 0, 20, "desc", undefined, undefined, undefined); + }); + + it("passes filter parameters through to repository", async () => { + const { service, incidentsRepository } = createService(); + (incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]); + (incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0); + + await service.getIncidentsByTeam("team-1", "asc", "week", 2, 10, true, "mon-1", "manual"); + + expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), 2, 10, "asc", true, "mon-1", "manual"); + expect(incidentsRepository.countByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), true, "mon-1", "manual"); + }); + + it("throws when teamId is missing", async () => { + const { service } = createService(); + await expect(service.getIncidentsByTeam("", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("No team ID in request"); + }); + + it("logs error and rethrows on unexpected failure", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(new Error("db error")); + + await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("db error"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentsByTeam" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(42); + + await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toBe(42); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── getIncidentSummary ─────────────────────────────────────────────────── + + describe("getIncidentSummary", () => { + it("returns summary from repository", async () => { + const summary = { recentIncidents: [], totalIncidents: 0 }; + const { service, incidentsRepository } = createService(); + (incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary); + + const result = await service.getIncidentSummary("team-1", 5); + + expect(result).toBe(summary); + expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 5); + }); + + it("defaults limit to 10 when not provided", async () => { + const { service, incidentsRepository } = createService(); + (incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue({}); + + await service.getIncidentSummary("team-1"); + + expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 10); + }); + + it("throws when teamId is missing", async () => { + const { service } = createService(); + await expect(service.getIncidentSummary("")).rejects.toThrow("No team ID in request"); + }); + + it("logs error and rethrows on unexpected failure", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue(new Error("db error")); + + await expect(service.getIncidentSummary("team-1")).rejects.toThrow("db error"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentSummary" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue("boom"); + + await expect(service.getIncidentSummary("team-1")).rejects.toBe("boom"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── getIncidentById ────────────────────────────────────────────────────── + + describe("getIncidentById", () => { + it("returns incident, monitor, and user when resolvedBy exists", async () => { + const incident = makeIncident({ resolvedBy: "user-1" }); + const monitor = makeMonitor(); + const user = { id: "user-1", email: "user@test.com" }; + const { service, incidentsRepository, monitorsRepository, usersRepository } = createService(); + (incidentsRepository.findById as jest.Mock).mockResolvedValue(incident); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (usersRepository.findById as jest.Mock).mockResolvedValue(user); + + const result = await service.getIncidentById("inc-1", "team-1"); + + expect(result).toEqual({ incident, monitor, user }); + expect(usersRepository.findById).toHaveBeenCalledWith("user-1"); + }); + + it("returns user as null when resolvedBy is not set", async () => { + const incident = makeIncident({ resolvedBy: undefined }); + const monitor = makeMonitor(); + const { service, incidentsRepository, monitorsRepository, usersRepository } = createService(); + (incidentsRepository.findById as jest.Mock).mockResolvedValue(incident); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + + const result = await service.getIncidentById("inc-1", "team-1"); + + expect(result.user).toBeNull(); + expect(usersRepository.findById).not.toHaveBeenCalled(); + }); + + it("logs error and rethrows on unexpected failure", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findById as jest.Mock).mockRejectedValue(new Error("not found")); + + await expect(service.getIncidentById("inc-1", "team-1")).rejects.toThrow("not found"); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ service: "incidentService", method: "getIncidentById", details: { incidentId: "inc-1" } }) + ); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findById as jest.Mock).mockRejectedValue(null); + + await expect(service.getIncidentById("inc-1", "team-1")).rejects.toBeNull(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); +}); diff --git a/server/test/unit/services/inviteService.test.ts b/server/test/unit/services/inviteService.test.ts new file mode 100644 index 000000000..da29d1834 --- /dev/null +++ b/server/test/unit/services/inviteService.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { InviteService } from "../../../src/service/business/inviteService.ts"; +import type { Invite, UserRole } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeInvite = (overrides?: Partial): Invite => ({ + id: "inv-1", + email: "invited@example.com", + teamId: "team-1", + role: ["user"] as UserRole[], + token: "invite-token-123", + expiry: "2026-12-31T00:00:00Z", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, +}); + +const createService = (overrides?: Record) => { + const invitesRepository = { + create: jest.fn().mockResolvedValue(makeInvite()), + findByToken: jest.fn().mockResolvedValue(makeInvite()), + }; + const settingsService = { + getSettings: jest.fn().mockReturnValue({ clientHost: "http://localhost:5173" }), + }; + const emailService = { + buildEmail: jest.fn().mockResolvedValue("Invite"), + sendEmail: jest.fn().mockResolvedValue("msg-id-123"), + }; + + const defaults = { invitesRepository, settingsService, emailService, ...overrides }; + + const service = new InviteService(defaults as any); + return { service, ...defaults }; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("InviteService", () => { + describe("serviceName", () => { + it("returns inviteService from static property", () => { + expect(InviteService.SERVICE_NAME).toBe("inviteService"); + }); + + it("returns inviteService from instance getter", () => { + const { service } = createService(); + expect(service.serviceName).toBe("inviteService"); + }); + }); + + // ── getInviteToken ────────────────────────────────────────────────────── + + describe("getInviteToken", () => { + it("creates an invite with teamId assigned", async () => { + const { service, invitesRepository } = createService(); + const invite: Partial = { email: "new@example.com", role: ["user"] }; + + const result = await service.getInviteToken({ invite, teamId: "team-1", userRoles: ["superadmin"] }); + + expect(invitesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ email: "new@example.com", teamId: "team-1" })); + expect(result).toEqual(makeInvite()); + }); + + it("allows creation when role is undefined (defaults to empty)", async () => { + const { service } = createService(); + + const result = await service.getInviteToken({ invite: { email: "new@example.com" }, teamId: "team-1", userRoles: ["user"] }); + + expect(result).toEqual(makeInvite()); + }); + + it("throws 403 when actor cannot manage the target role", async () => { + const { service } = createService(); + + await expect(service.getInviteToken({ invite: { role: ["superadmin"] }, teamId: "team-1", userRoles: ["admin"] })).rejects.toThrow( + "You do not have permission to create this invite" + ); + }); + }); + + // ── sendInviteEmail ───────────────────────────────────────────────────── + + describe("sendInviteEmail", () => { + it("creates invite, builds email, and sends it", async () => { + const { service, invitesRepository, emailService, settingsService } = createService(); + + await service.sendInviteEmail({ + invite: { email: "new@example.com", role: ["user"] }, + firstName: "Test", + userRoles: ["superadmin"], + }); + + expect(invitesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ email: "new@example.com" })); + expect(settingsService.getSettings).toHaveBeenCalled(); + expect(emailService.buildEmail).toHaveBeenCalledWith("employeeActivationTemplate", { + name: "Test", + link: "http://localhost:5173/register/invite-token-123", + }); + expect(emailService.sendEmail).toHaveBeenCalledWith("new@example.com", "Welcome to Uptime Monitor", "Invite"); + }); + + it("throws 400 when invite email is missing", async () => { + const { service } = createService(); + + await expect(service.sendInviteEmail({ invite: {}, firstName: "Test", userRoles: ["superadmin"] })).rejects.toThrow( + "Invite email is required to send an invite" + ); + }); + + it("throws 403 when actor cannot manage the target role", async () => { + const { service } = createService(); + + await expect( + service.sendInviteEmail({ invite: { email: "new@example.com", role: ["superadmin"] }, firstName: "Test", userRoles: ["admin"] }) + ).rejects.toThrow("You do not have permission to create this invite"); + }); + + it("allows sending when role is undefined (defaults to empty)", async () => { + const { service, emailService } = createService(); + + await service.sendInviteEmail({ invite: { email: "new@example.com" }, firstName: "Test", userRoles: ["user"] }); + + expect(emailService.sendEmail).toHaveBeenCalled(); + }); + + it("throws 500 when buildEmail returns falsy", async () => { + const { service } = createService({ + emailService: { + buildEmail: jest.fn().mockResolvedValue(null), + sendEmail: jest.fn(), + }, + }); + + await expect( + service.sendInviteEmail({ invite: { email: "new@example.com", role: ["user"] }, firstName: "Test", userRoles: ["superadmin"] }) + ).rejects.toThrow("Failed to build invite e-mail"); + }); + + it("throws 500 when sendEmail returns falsy", async () => { + const { service } = createService({ + emailService: { + buildEmail: jest.fn().mockResolvedValue("ok"), + sendEmail: jest.fn().mockResolvedValue(false), + }, + }); + + await expect( + service.sendInviteEmail({ invite: { email: "new@example.com", role: ["user"] }, firstName: "Test", userRoles: ["superadmin"] }) + ).rejects.toThrow("Failed to send invite e-mail"); + }); + }); + + // ── verifyInviteToken ─────────────────────────────────────────────────── + + describe("verifyInviteToken", () => { + it("delegates to repository", async () => { + const { service, invitesRepository } = createService(); + + const result = await service.verifyInviteToken({ inviteToken: "invite-token-123" }); + + expect(invitesRepository.findByToken).toHaveBeenCalledWith("invite-token-123"); + expect(result).toEqual(makeInvite()); + }); + }); +}); diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts new file mode 100644 index 000000000..302ff128c --- /dev/null +++ b/server/test/unit/services/maintenanceWindowService.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { MaintenanceWindowService } from "../../../src/service/business/maintenanceWindowService.ts"; +import type { IMaintenanceWindowsRepository } from "../../../src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts"; +import type { IMonitorsRepository } from "../../../src/repositories/monitors/IMonitorsRepository.ts"; +import type { MaintenanceWindow } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createMaintenanceWindowsRepo = () => + ({ + create: jest.fn().mockResolvedValue(makeWindow()), + findById: jest.fn().mockResolvedValue(makeWindow()), + findByMonitorId: jest.fn().mockResolvedValue([makeWindow()]), + findByTeamId: jest.fn().mockResolvedValue([makeWindow()]), + updateById: jest.fn().mockResolvedValue(makeWindow()), + deleteById: jest.fn().mockResolvedValue(makeWindow()), + countByTeamId: jest.fn().mockResolvedValue(1), + }) as unknown as jest.Mocked; + +const createMonitorsRepo = () => + ({ + findByIds: jest.fn().mockResolvedValue([ + { id: "mon-1", teamId: "team-1" }, + { id: "mon-2", teamId: "team-1" }, + ]), + }) as unknown as jest.Mocked; + +const createService = (overrides?: { + monitorsRepository?: ReturnType; + maintenanceWindowsRepository?: ReturnType; +}) => { + const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo(); + const maintenanceWindowsRepository = overrides?.maintenanceWindowsRepository ?? createMaintenanceWindowsRepo(); + const service = new MaintenanceWindowService({ monitorsRepository, maintenanceWindowsRepository }); + return { service, monitorsRepository, maintenanceWindowsRepository }; +}; + +const makeWindow = (overrides?: Partial): MaintenanceWindow => ({ + id: "mw-1", + monitorId: "mon-1", + teamId: "team-1", + active: true, + name: "Scheduled Maintenance", + duration: 60, + durationUnit: "minutes", + repeat: 0, + start: "2026-04-10T02:00:00Z", + end: "2026-04-10T03:00:00Z", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, +}); + +const defaultCreateParams = { + teamId: "team-1", + monitorIDs: ["mon-1", "mon-2"], + name: "Scheduled Maintenance", + active: true, + duration: 60, + durationUnit: "minutes" as const, + repeat: 0, + start: "2026-04-10T02:00:00Z", + end: "2026-04-10T03:00:00Z", +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("MaintenanceWindowService", () => { + describe("serviceName", () => { + it("returns maintenanceWindowService from static property", () => { + expect(MaintenanceWindowService.SERVICE_NAME).toBe("maintenanceWindowService"); + }); + + it("returns maintenanceWindowService from instance getter", () => { + const { service } = createService(); + expect(service.serviceName).toBe("maintenanceWindowService"); + }); + }); + + // ── createMaintenanceWindow ────────────────────────────────────────────── + + describe("createMaintenanceWindow", () => { + it("creates a maintenance window for each monitor", async () => { + const { service, maintenanceWindowsRepository } = createService(); + + await service.createMaintenanceWindow(defaultCreateParams); + + expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(2); + expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: "team-1", + monitorId: "mon-1", + name: "Scheduled Maintenance", + active: true, + duration: 60, + durationUnit: "minutes", + repeat: 0, + start: "2026-04-10T02:00:00Z", + end: "2026-04-10T03:00:00Z", + }) + ); + expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ monitorId: "mon-2" })); + }); + + it("verifies monitor ownership via findByIds", async () => { + const { service, monitorsRepository } = createService(); + + await service.createMaintenanceWindow(defaultCreateParams); + + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"]); + }); + + it("throws 403 when a 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.createMaintenanceWindow(defaultCreateParams)).rejects.toThrow( + "Unauthorized to create maintenance window for one or more monitors" + ); + expect(maintenanceWindowsRepository.create).not.toHaveBeenCalled(); + }); + + it("does not throw when all monitors belong to the team", async () => { + const { service } = createService(); + + await expect(service.createMaintenanceWindow(defaultCreateParams)).resolves.toBeUndefined(); + }); + + it("creates a single window when monitorIDs has one entry", async () => { + const { service, maintenanceWindowsRepository } = createService(); + + await service.createMaintenanceWindow({ ...defaultCreateParams, monitorIDs: ["mon-1"] }); + + expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1); + }); + }); + + // ── getMaintenanceWindowById ───────────────────────────────────────────── + + describe("getMaintenanceWindowById", () => { + it("delegates to repository with id and teamId", async () => { + const expected = makeWindow(); + const { service, maintenanceWindowsRepository } = createService(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(expected); + + const result = await service.getMaintenanceWindowById({ id: "mw-1", teamId: "team-1" }); + + expect(result).toBe(expected); + expect(maintenanceWindowsRepository.findById).toHaveBeenCalledWith("mw-1", "team-1"); + }); + }); + + // ── getMaintenanceWindowsByTeamId ──────────────────────────────────────── + + describe("getMaintenanceWindowsByTeamId", () => { + it("returns windows and count", async () => { + const windows = [makeWindow()]; + const { service, maintenanceWindowsRepository } = createService(); + (maintenanceWindowsRepository.findByTeamId as jest.Mock).mockResolvedValue(windows); + (maintenanceWindowsRepository.countByTeamId as jest.Mock).mockResolvedValue(1); + + const result = await service.getMaintenanceWindowsByTeamId({ teamId: "team-1" }); + + expect(result).toEqual({ maintenanceWindows: windows, maintenanceWindowCount: 1 }); + }); + + it("defaults page to 0 and rowsPerPage to 10 when not provided", async () => { + const { service, maintenanceWindowsRepository } = createService(); + + await service.getMaintenanceWindowsByTeamId({ teamId: "team-1" }); + + expect(maintenanceWindowsRepository.findByTeamId).toHaveBeenCalledWith("team-1", 0, 10, undefined, undefined, undefined); + }); + + it("passes pagination and filter parameters through", async () => { + const { service, maintenanceWindowsRepository } = createService(); + + await service.getMaintenanceWindowsByTeamId({ + teamId: "team-1", + active: true, + page: 2, + rowsPerPage: 5, + field: "name", + order: "asc", + }); + + expect(maintenanceWindowsRepository.findByTeamId).toHaveBeenCalledWith("team-1", 2, 5, "name", "asc", true); + expect(maintenanceWindowsRepository.countByTeamId).toHaveBeenCalledWith("team-1", true); + }); + }); + + // ── getMaintenanceWindowsByMonitorId ───────────────────────────────────── + + describe("getMaintenanceWindowsByMonitorId", () => { + it("delegates to repository with monitorId and teamId", async () => { + const windows = [makeWindow()]; + const { service, maintenanceWindowsRepository } = createService(); + (maintenanceWindowsRepository.findByMonitorId as jest.Mock).mockResolvedValue(windows); + + const result = await service.getMaintenanceWindowsByMonitorId({ monitorId: "mon-1", teamId: "team-1" }); + + expect(result).toBe(windows); + expect(maintenanceWindowsRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "team-1"); + }); + }); + + // ── deleteMaintenanceWindow ────────────────────────────────────────────── + + describe("deleteMaintenanceWindow", () => { + it("delegates to repository and returns deleted window", async () => { + const deleted = makeWindow(); + const { service, maintenanceWindowsRepository } = createService(); + (maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(deleted); + + const result = await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); + + expect(result).toBe(deleted); + expect(maintenanceWindowsRepository.deleteById).toHaveBeenCalledWith("mw-1", "team-1"); + }); + }); + + // ── editMaintenanceWindow ──────────────────────────────────────────────── + + describe("editMaintenanceWindow", () => { + it("delegates to repository with id, teamId, and body", async () => { + const updated = makeWindow({ name: "Updated Name" }); + const { service, maintenanceWindowsRepository } = createService(); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(updated); + + const result = await service.editMaintenanceWindow({ id: "mw-1", teamId: "team-1", body: { name: "Updated Name" } }); + + expect(result).toBe(updated); + expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { name: "Updated Name" }); + }); + + it("passes partial body fields through", async () => { + const { service, maintenanceWindowsRepository } = createService(); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { active: false, repeat: 3600000 }, + }); + + expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { active: false, repeat: 3600000 }); + }); + }); +}); diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts new file mode 100644 index 000000000..d2ee164bc --- /dev/null +++ b/server/test/unit/services/monitorService.test.ts @@ -0,0 +1,1570 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { MonitorService } from "../../../src/service/business/monitorService.ts"; +import type { + IChecksRepository, + IGeoChecksRepository, + IIncidentsRepository, + IMonitorStatsRepository, + IMonitorsRepository, + IStatusPagesRepository, +} from "../../../src/repositories/index.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const createMonitorsRepositoryMock = () => + ({ + create: jest.fn(), + createMonitors: jest.fn(), + findById: jest.fn(), + findByTeamId: jest.fn(), + findByIds: jest.fn(), + findMonitorCountByTeamIdAndType: jest.fn(), + findMonitorsSummaryByTeamId: jest.fn(), + findGroupsByTeamId: jest.fn(), + updateById: jest.fn(), + updateNotifications: jest.fn(), + togglePauseById: jest.fn(), + bulkTogglePause: jest.fn(), + deleteById: jest.fn(), + deleteByTeamId: jest.fn(), + }) as unknown as IMonitorsRepository; + +const createChecksRepositoryMock = () => + ({ + findByDateRangeAndMonitorId: jest.fn(), + deleteByMonitorId: jest.fn(), + }) as unknown as IChecksRepository; + +const createMonitorStatsRepositoryMock = () => + ({ + findByMonitorId: jest.fn(), + deleteByMonitorId: jest.fn(), + }) as unknown as IMonitorStatsRepository; + +const createStatusPagesRepositoryMock = () => + ({ + removeMonitorFromStatusPages: jest.fn(), + }) as unknown as IStatusPagesRepository; + +const createGeoChecksRepositoryMock = () => + ({ + findGroupedByMonitorIdAndDateRange: jest.fn(), + deleteByMonitorId: jest.fn(), + }) as unknown as IGeoChecksRepository; + +const createIncidentsRepositoryMock = () => + ({ + deleteByMonitorId: jest.fn(), + }) as unknown as IIncidentsRepository; + +const createJobQueueMock = () => ({ + addJob: jest.fn(), + updateJob: jest.fn(), + resumeJob: jest.fn(), + pauseJob: jest.fn(), + deleteJob: jest.fn(), +}); + +const TEAM_ID = "team-1"; +const MONITOR_ID = "monitor-1"; +const USER_ID = "user-1"; + +const makeMonitor = (overrides: Record = {}) => ({ + id: MONITOR_ID, + teamId: TEAM_ID, + userId: USER_ID, + name: "Test Monitor", + type: "http" as const, + url: "https://example.com", + isActive: true, + interval: 60000, + recentChecks: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, +}); + +const createService = ( + overrides: { + monitorsRepository?: IMonitorsRepository; + checksRepository?: IChecksRepository; + monitorStatsRepository?: IMonitorStatsRepository; + statusPagesRepository?: IStatusPagesRepository; + geoChecksRepository?: IGeoChecksRepository; + incidentsRepository?: IIncidentsRepository; + jobQueue?: ReturnType; + logger?: ReturnType; + games?: Record; + } = {} +) => { + const jobQueue = overrides.jobQueue ?? createJobQueueMock(); + const logger = overrides.logger ?? createMockLogger(); + const service = new MonitorService({ + jobQueue: jobQueue as any, + logger: logger as any, + games: (overrides.games ?? { cs2: { name: "Counter-Strike 2" } }) as any, + monitorsRepository: overrides.monitorsRepository ?? createMonitorsRepositoryMock(), + checksRepository: overrides.checksRepository ?? createChecksRepositoryMock(), + geoChecksRepository: overrides.geoChecksRepository ?? createGeoChecksRepositoryMock(), + monitorStatsRepository: overrides.monitorStatsRepository ?? createMonitorStatsRepositoryMock(), + statusPagesRepository: overrides.statusPagesRepository ?? createStatusPagesRepositoryMock(), + incidentsRepository: overrides.incidentsRepository ?? createIncidentsRepositoryMock(), + }); + return { service, jobQueue, logger }; +}; + +describe("MonitorService", () => { + describe("serviceName", () => { + it("returns 'MonitorService'", () => { + const { service } = createService(); + expect(service.serviceName).toBe("MonitorService"); + }); + }); + + describe("createMonitor", () => { + it("creates a monitor and adds a job", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor(); + (monitorsRepository.create as jest.Mock).mockResolvedValue(monitor); + const { service, jobQueue } = createService({ monitorsRepository }); + + await service.createMonitor(TEAM_ID, USER_ID, monitor as any); + + expect(monitorsRepository.create).toHaveBeenCalledWith(monitor, TEAM_ID, USER_ID); + expect(jobQueue.addJob).toHaveBeenCalledWith(MONITOR_ID, monitor); + }); + + it("throws when repository returns null", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.create as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.createMonitor(TEAM_ID, USER_ID, {} as any)).rejects.toThrow("Failed to create monitor"); + }); + }); + + describe("createMonitors", () => { + it("creates multiple monitors and adds jobs for each", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })]; + (monitorsRepository.createMonitors as jest.Mock).mockResolvedValue(monitors); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.createMonitors(monitors as any); + + expect(result).toHaveLength(2); + expect(jobQueue.addJob).toHaveBeenCalledTimes(2); + }); + + it("throws when repository returns empty array", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.createMonitors as jest.Mock).mockResolvedValue([]); + const { service } = createService({ monitorsRepository }); + + await expect(service.createMonitors([makeMonitor()] as any)).rejects.toThrow("Failed to create monitors"); + }); + + it("throws when repository returns null", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.createMonitors as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.createMonitors([makeMonitor()] as any)).rejects.toThrow("Failed to create monitors"); + }); + }); + + describe("addDemoMonitors", () => { + it("creates demo monitors from JSON data and adds jobs", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const demoMonitors = [makeMonitor({ id: "d1", name: "Google" }), makeMonitor({ id: "d2", name: "Facebook" })]; + (monitorsRepository.createMonitors as jest.Mock).mockResolvedValue(demoMonitors); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.addDemoMonitors({ userId: USER_ID, teamId: TEAM_ID }); + + expect(result).toHaveLength(2); + expect(monitorsRepository.createMonitors).toHaveBeenCalledTimes(1); + const callArg = (monitorsRepository.createMonitors as jest.Mock).mock.calls[0]![0] as any[]; + expect(callArg[0]).toMatchObject({ userId: USER_ID, teamId: TEAM_ID, type: "http", interval: 60000 }); + expect(jobQueue.addJob).toHaveBeenCalledTimes(2); + }); + }); + + describe("getUptimeDetailsById", () => { + it("returns monitorData and monitorStats for an http monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "http", + groupedChecks: [{ _id: "2024-01-01", avgResponseTime: 100, totalChecks: 2 }], + groupedUpChecks: [{ _id: "2024-01-01", totalChecks: 2, avgResponseTime: 90 }], + groupedDownChecks: [{ _id: "2024-01-01", totalChecks: 0, avgResponseTime: 0 }], + uptimePercentage: 0.99, + avgResponseTime: 95, + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue({ monitorId: MONITOR_ID }); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + const result = await service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" }); + + expect(result).toHaveProperty("monitorData"); + expect(result).toHaveProperty("monitorStats"); + expect(result.monitorData.monitor).toMatchObject({ id: MONITOR_ID }); + }); + + it("supports all uptime monitor types: ping, docker, port, game, grpc, websocket", async () => { + for (const monitorType of ["ping", "docker", "port", "game", "grpc", "websocket"] as const) { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: monitorType }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType, + groupedChecks: [], + groupedUpChecks: [], + groupedDownChecks: [], + uptimePercentage: 1, + avgResponseTime: 50, + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(null); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + const result = await service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "day" }); + expect(result.monitorData.monitor.type).toBe(monitorType); + } + }); + + it("throws 404 when monitor not found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: "missing", dateRange: "recent" })).rejects.toThrow( + "Monitor with ID missing not found." + ); + }); + + it("throws 400 for unsupported monitor type (hardware)", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: "hardware" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "hardware", + groupedChecks: [], + groupedUpChecks: [], + groupedDownChecks: [], + uptimePercentage: 0, + avgResponseTime: 0, + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(null); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + await expect(service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "monitors are not supported for uptime details" + ); + }); + + it("throws 400 for unsupported monitor type (pagespeed)", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: "pagespeed" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "pagespeed", + groupedChecks: [], + groupedUpChecks: [], + groupedDownChecks: [], + uptimePercentage: 0, + avgResponseTime: 0, + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(null); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + await expect(service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "week" })).rejects.toThrow( + "monitors are not supported for uptime details" + ); + }); + + it("uses different date ranges correctly", async () => { + for (const dateRange of ["recent", "day", "week", "month", "all"] as const) { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "http", + groupedChecks: [], + groupedUpChecks: [], + groupedDownChecks: [], + uptimePercentage: 1, + avgResponseTime: 50, + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(null); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + await service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange }); + expect(checksRepository.findByDateRangeAndMonitorId).toHaveBeenCalled(); + } + }); + }); + + describe("getHardwareDetailsById", () => { + it("returns hardware details for a hardware monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: "hardware" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "hardware", + aggregateData: { totalChecks: 10 }, + upChecks: { totalChecks: 9 }, + checks: [], + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue({ monitorId: MONITOR_ID }); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + const result = await service.getHardwareDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" }); + + expect(result.monitor).toMatchObject({ id: MONITOR_ID }); + expect(result.stats).toHaveProperty("aggregateData"); + expect(result.stats).toHaveProperty("upChecks"); + expect(result.stats).toHaveProperty("checks"); + expect(result.monitorStats).toMatchObject({ monitorId: MONITOR_ID }); + }); + + it("throws 404 when monitor not found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.getHardwareDetailsById({ teamId: TEAM_ID, monitorId: "missing", dateRange: "recent" })).rejects.toThrow( + "Monitor with ID missing not found." + ); + }); + + it("throws 400 when monitor type is not hardware", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ type: "http" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + const { service } = createService({ monitorsRepository }); + + await expect(service.getHardwareDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "monitors are not supported for hardware details" + ); + }); + + it("throws 500 when checksData monitorType is not hardware", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitor = makeMonitor({ type: "hardware" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ monitorType: "http" }); + const { service } = createService({ monitorsRepository, checksRepository }); + + await expect(service.getHardwareDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "Unable to load hardware stats for this monitor" + ); + }); + }); + + describe("getPageSpeedDetailsById", () => { + it("returns pagespeed details for a pagespeed monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: "pagespeed" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "pagespeed", + groupedChecks: [{ _id: "2024-01-01", performance: 95 }], + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue({ monitorId: MONITOR_ID }); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + const result = await service.getPageSpeedDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" }); + + expect(result.monitorData.monitor).toMatchObject({ id: MONITOR_ID }); + expect(result.monitorData.groupedChecks).toHaveLength(1); + expect(result.monitorStats).toMatchObject({ monitorId: MONITOR_ID }); + }); + + it("throws 404 when monitor not found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.getPageSpeedDetailsById({ teamId: TEAM_ID, monitorId: "missing", dateRange: "recent" })).rejects.toThrow( + "Monitor with ID missing not found." + ); + }); + + it("throws 400 when monitor type is not pagespeed", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ type: "http" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + const { service } = createService({ monitorsRepository }); + + await expect(service.getPageSpeedDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "monitors are not supported for pagespeed details" + ); + }); + + it("throws 500 when checksData monitorType is not pagespeed", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitor = makeMonitor({ type: "pagespeed" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ monitorType: "http" }); + const { service } = createService({ monitorsRepository, checksRepository }); + + await expect(service.getPageSpeedDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "Unable to load pagespeed stats for this monitor" + ); + }); + + it("handles pagespeed with different date range", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: "pagespeed" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "pagespeed", + groupedChecks: [], + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + + await service.getPageSpeedDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "week" }); + expect(checksRepository.findByDateRangeAndMonitorId).toHaveBeenCalled(); + }); + }); + + describe("getGeoChecksByMonitorId", () => { + it("throws 404 when monitor not found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.getGeoChecksByMonitorId({ teamId: TEAM_ID, monitorId: "missing", dateRange: "recent" })).rejects.toThrow( + "Monitor with ID missing not found." + ); + }); + + it("returns empty when monitor type does not support geo checks", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ type: "hardware", geoCheckEnabled: true }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + const { service } = createService({ monitorsRepository }); + + const result = await service.getGeoChecksByMonitorId({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" }); + expect(result).toEqual({ groupedGeoChecks: [] }); + }); + + it("returns empty when geoCheckEnabled is false", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ type: "http", geoCheckEnabled: false }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + const { service } = createService({ monitorsRepository }); + + const result = await service.getGeoChecksByMonitorId({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" }); + expect(result).toEqual({ groupedGeoChecks: [] }); + }); + + it("returns empty when geoCheckEnabled is undefined", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ type: "http", geoCheckEnabled: undefined }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + const { service } = createService({ monitorsRepository }); + + const result = await service.getGeoChecksByMonitorId({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" }); + expect(result).toEqual({ groupedGeoChecks: [] }); + }); + + it("returns grouped geo checks when supported and enabled", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + const monitor = makeMonitor({ type: "http", geoCheckEnabled: true }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + const geoData = [{ continent: "NA", avgResponseTime: 50, totalChecks: 5 }]; + (geoChecksRepository.findGroupedByMonitorIdAndDateRange as jest.Mock).mockResolvedValue(geoData); + + const { service } = createService({ monitorsRepository, geoChecksRepository }); + const result = await service.getGeoChecksByMonitorId({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + dateRange: "recent", + continents: ["NA"] as any, + }); + + expect(result.groupedGeoChecks).toEqual(geoData); + expect(geoChecksRepository.findGroupedByMonitorIdAndDateRange).toHaveBeenCalled(); + }); + + it("works with ping monitor type", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + const monitor = makeMonitor({ type: "ping", geoCheckEnabled: true }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (geoChecksRepository.findGroupedByMonitorIdAndDateRange as jest.Mock).mockResolvedValue([]); + + const { service } = createService({ monitorsRepository, geoChecksRepository }); + const result = await service.getGeoChecksByMonitorId({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "day" }); + + expect(result.groupedGeoChecks).toEqual([]); + }); + }); + + describe("getMonitorById", () => { + it("delegates to repository findById", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + const { service } = createService({ monitorsRepository }); + + const result = await service.getMonitorById({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + expect(result).toEqual(monitor); + expect(monitorsRepository.findById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID); + }); + }); + + describe("getMonitorsByTeamId", () => { + it("returns monitors from repository", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })]; + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue(monitors); + const { service } = createService({ monitorsRepository }); + + const result = await service.getMonitorsByTeamId({ teamId: TEAM_ID }); + expect(result).toHaveLength(2); + }); + + it("passes type and filter to repository", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([]); + const { service } = createService({ monitorsRepository }); + + await service.getMonitorsByTeamId({ teamId: TEAM_ID, type: "http", filter: "test" }); + expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith(TEAM_ID, { type: "http", filter: "test" }); + }); + }); + + describe("getMonitorsWithChecksByTeamId", () => { + it("returns summary, count, and monitors with normalized checks", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1, upMonitors: 1 }); + (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([ + makeMonitor({ + recentChecks: [ + { responseTime: 10, status: true, message: "OK" }, + { responseTime: 20, status: true, message: "OK" }, + ], + }), + ]); + + const { service } = createService({ monitorsRepository }); + const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID }); + + expect(result.summary).toMatchObject({ totalMonitors: 1 }); + expect(result.count).toBe(1); + expect(result.monitors).toHaveLength(1); + }); + + it("returns null summary when repository returns null", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue(null); + (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(0); + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue(null); + + const { service } = createService({ monitorsRepository }); + const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID }); + + expect(result.summary).toBeNull(); + expect(result.monitors).toEqual([]); + }); + + it("uses snapshot (first check only) for hardware type monitors", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); + (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([ + makeMonitor({ + type: "hardware", + recentChecks: [ + { responseTime: 10, status: true, message: "OK" }, + { responseTime: 20, status: true, message: "OK" }, + { responseTime: 30, status: true, message: "OK" }, + ], + }), + ]); + + const { service } = createService({ monitorsRepository }); + const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID, type: "hardware" }); + + expect(result.monitors[0].recentChecks).toHaveLength(1); + }); + + it("uses snapshot for hardware type when type is array with hardware", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); + (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([ + makeMonitor({ + type: "hardware", + recentChecks: [ + { responseTime: 10, status: true, message: "OK" }, + { responseTime: 20, status: true, message: "OK" }, + ], + }), + ]); + + const { service } = createService({ monitorsRepository }); + const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID, type: ["hardware"] }); + + expect(result.monitors[0].recentChecks).toHaveLength(1); + }); + + it("normalizes checks for non-snapshot types when type is an array", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); + (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([ + makeMonitor({ + type: "http", + recentChecks: [ + { responseTime: 10, status: true, message: "OK" }, + { responseTime: 20, status: true, message: "OK" }, + ], + }), + ]); + + const { service } = createService({ monitorsRepository }); + const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID, type: ["http", "ping"] }); + + expect(result.monitors[0].recentChecks.length).toBeGreaterThan(0); + }); + + it("normalizes checks when type is undefined (mixed types)", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 2 }); + (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(2); + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([ + makeMonitor({ + id: "m1", + type: "http", + recentChecks: [ + { responseTime: 10, status: true, message: "OK" }, + { responseTime: 20, status: true, message: "OK" }, + ], + }), + makeMonitor({ + id: "m2", + type: "hardware", + recentChecks: [ + { responseTime: 10, status: true, message: "OK" }, + { responseTime: 20, status: true, message: "OK" }, + { responseTime: 30, status: true, message: "OK" }, + ], + }), + ]); + + const { service } = createService({ monitorsRepository }); + const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID }); + + // http monitor gets normalized, hardware gets snapshot (1 check) + expect(result.monitors[1].recentChecks).toHaveLength(1); + expect(result.monitors[0].recentChecks.length).toBeGreaterThan(0); + }); + + it("handles monitors with empty recentChecks", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); + (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: [] })]); + + const { service } = createService({ monitorsRepository }); + const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID }); + + expect(result.monitors[0].recentChecks).toEqual([]); + }); + + it("handles monitors with undefined recentChecks", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); + (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: undefined })]); + + const { service } = createService({ monitorsRepository }); + const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID }); + + expect(result.monitors[0].recentChecks).toEqual([]); + }); + + it("passes all query params to repository", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 0 }); + (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(0); + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([]); + + const { service } = createService({ monitorsRepository }); + await service.getMonitorsWithChecksByTeamId({ + teamId: TEAM_ID, + limit: 10, + type: "http", + page: 1, + rowsPerPage: 25, + filter: "test", + field: "name", + order: "asc", + }); + + expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith(TEAM_ID, { + limit: 10, + type: "http", + page: 1, + rowsPerPage: 25, + filter: "test", + field: "name", + order: "asc", + }); + }); + }); + + describe("getAllGames", () => { + it("returns the games map", () => { + const games = { cs2: { name: "Counter-Strike 2" } }; + const { service } = createService({ games }); + expect(service.getAllGames()).toEqual(games); + }); + }); + + describe("getGroupsByTeamId", () => { + it("delegates to repository", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findGroupsByTeamId as jest.Mock).mockResolvedValue(["group1", "group2"]); + const { service } = createService({ monitorsRepository }); + + const result = await service.getGroupsByTeamId({ teamId: TEAM_ID }); + expect(result).toEqual(["group1", "group2"]); + expect(monitorsRepository.findGroupsByTeamId).toHaveBeenCalledWith(TEAM_ID); + }); + }); + + describe("editMonitor", () => { + it("updates monitor and refreshes job", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const updatedMonitor = makeMonitor({ name: "Updated" }); + (monitorsRepository.updateById as jest.Mock).mockResolvedValue(updatedMonitor); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { name: "Updated" } }); + + expect(result).toMatchObject({ name: "Updated" }); + expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { name: "Updated" }); + expect(jobQueue.updateJob).toHaveBeenCalledWith(updatedMonitor); + }); + }); + + describe("updateNotifications", () => { + it("updates notifications and refreshes jobs when modifiedCount > 0", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })]; + (monitorsRepository.updateNotifications as jest.Mock).mockResolvedValue(2); + (monitorsRepository.findByIds as jest.Mock).mockResolvedValue(monitors); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.updateNotifications({ + teamId: TEAM_ID, + monitorIds: ["m1", "m2"], + notificationIds: ["n1"], + action: "add", + }); + + expect(result).toBe(2); + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["m1", "m2"]); + expect(jobQueue.updateJob).toHaveBeenCalledTimes(2); + }); + + it("skips job refresh when modifiedCount is 0", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.updateNotifications as jest.Mock).mockResolvedValue(0); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.updateNotifications({ + teamId: TEAM_ID, + monitorIds: ["m1"], + notificationIds: ["n1"], + action: "remove", + }); + + expect(result).toBe(0); + expect(monitorsRepository.findByIds).not.toHaveBeenCalled(); + expect(jobQueue.updateJob).not.toHaveBeenCalled(); + }); + }); + + describe("pauseMonitor", () => { + it("resumes job when monitor is active after toggle", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ isActive: true }); + (monitorsRepository.togglePauseById as jest.Mock).mockResolvedValue(monitor); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.pauseMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + + expect(result.isActive).toBe(true); + expect(jobQueue.resumeJob).toHaveBeenCalledWith(monitor); + expect(jobQueue.pauseJob).not.toHaveBeenCalled(); + }); + + it("pauses job when monitor is inactive after toggle", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ isActive: false }); + (monitorsRepository.togglePauseById as jest.Mock).mockResolvedValue(monitor); + const { service, jobQueue } = createService({ monitorsRepository }); + + const result = await service.pauseMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + + expect(result.isActive).toBe(false); + expect(jobQueue.pauseJob).toHaveBeenCalledWith(monitor); + expect(jobQueue.resumeJob).not.toHaveBeenCalled(); + }); + }); + + 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", + }) + ); + }); + }); + + describe("deleteMonitor", () => { + it("deletes monitor and all associated data", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const incidentsRepository = createIncidentsRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitor = makeMonitor(); + (monitorsRepository.deleteById as jest.Mock).mockResolvedValue(monitor); + (monitorStatsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue({}); + (checksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(5); + (statusPagesRepository.removeMonitorFromStatusPages as jest.Mock).mockResolvedValue(1); + (incidentsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(2); + (geoChecksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(3); + + const { service, jobQueue } = createService({ + monitorsRepository, + checksRepository, + monitorStatsRepository, + statusPagesRepository, + incidentsRepository, + geoChecksRepository, + }); + + const result = await service.deleteMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + + expect(result).toEqual(monitor); + expect(monitorStatsRepository.deleteByMonitorId).toHaveBeenCalledWith(MONITOR_ID); + expect(checksRepository.deleteByMonitorId).toHaveBeenCalledWith(MONITOR_ID); + expect(statusPagesRepository.removeMonitorFromStatusPages).toHaveBeenCalledWith(MONITOR_ID); + expect(incidentsRepository.deleteByMonitorId).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID); + expect(geoChecksRepository.deleteByMonitorId).toHaveBeenCalledWith(MONITOR_ID); + expect(jobQueue.deleteJob).toHaveBeenCalledWith(monitor); + }); + + it("logs warning when monitorStats deletion fails with Error", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const incidentsRepository = createIncidentsRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitor = makeMonitor(); + (monitorsRepository.deleteById as jest.Mock).mockResolvedValue(monitor); + (monitorStatsRepository.deleteByMonitorId as jest.Mock).mockRejectedValue(new Error("stats error")); + (checksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (statusPagesRepository.removeMonitorFromStatusPages as jest.Mock).mockResolvedValue(0); + (incidentsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (geoChecksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + + const logger = createMockLogger(); + const { service } = createService({ + monitorsRepository, + checksRepository, + monitorStatsRepository, + statusPagesRepository, + incidentsRepository, + geoChecksRepository, + logger, + }); + + await service.deleteMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Error deleting monitor stats"), + stack: expect.any(String), + }) + ); + }); + + it("logs warning with undefined stack when monitorStats deletion fails with non-Error", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const incidentsRepository = createIncidentsRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitor = makeMonitor(); + (monitorsRepository.deleteById as jest.Mock).mockResolvedValue(monitor); + (monitorStatsRepository.deleteByMonitorId as jest.Mock).mockRejectedValue("string error"); + (checksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (statusPagesRepository.removeMonitorFromStatusPages as jest.Mock).mockResolvedValue(0); + (incidentsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (geoChecksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + + const logger = createMockLogger(); + const { service } = createService({ + monitorsRepository, + checksRepository, + monitorStatsRepository, + statusPagesRepository, + incidentsRepository, + geoChecksRepository, + logger, + }); + + await service.deleteMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Error deleting monitor stats"), + stack: undefined, + }) + ); + }); + + it("logs warning when checks deletion fails with Error", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const incidentsRepository = createIncidentsRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitor = makeMonitor(); + (monitorsRepository.deleteById as jest.Mock).mockResolvedValue(monitor); + (monitorStatsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue({}); + (checksRepository.deleteByMonitorId as jest.Mock).mockRejectedValue(new Error("checks error")); + (statusPagesRepository.removeMonitorFromStatusPages as jest.Mock).mockResolvedValue(0); + (incidentsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (geoChecksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + + const logger = createMockLogger(); + const { service } = createService({ + monitorsRepository, + checksRepository, + monitorStatsRepository, + statusPagesRepository, + incidentsRepository, + geoChecksRepository, + logger, + }); + + await service.deleteMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Error deleting checks"), + stack: expect.any(String), + }) + ); + }); + + it("logs warning when checks deletion fails with non-Error", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const incidentsRepository = createIncidentsRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitor = makeMonitor(); + (monitorsRepository.deleteById as jest.Mock).mockResolvedValue(monitor); + (monitorStatsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue({}); + (checksRepository.deleteByMonitorId as jest.Mock).mockRejectedValue(42); + (statusPagesRepository.removeMonitorFromStatusPages as jest.Mock).mockResolvedValue(0); + (incidentsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (geoChecksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + + const logger = createMockLogger(); + const { service } = createService({ + monitorsRepository, + checksRepository, + monitorStatsRepository, + statusPagesRepository, + incidentsRepository, + geoChecksRepository, + logger, + }); + + await service.deleteMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Error deleting checks"), + stack: undefined, + }) + ); + }); + + it("logs warning when status pages removal fails with Error", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const incidentsRepository = createIncidentsRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitor = makeMonitor(); + (monitorsRepository.deleteById as jest.Mock).mockResolvedValue(monitor); + (monitorStatsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue({}); + (checksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (statusPagesRepository.removeMonitorFromStatusPages as jest.Mock).mockRejectedValue(new Error("status pages error")); + (incidentsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (geoChecksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + + const logger = createMockLogger(); + const { service } = createService({ + monitorsRepository, + checksRepository, + monitorStatsRepository, + statusPagesRepository, + incidentsRepository, + geoChecksRepository, + logger, + }); + + await service.deleteMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Error removing monitor"), + stack: expect.any(String), + }) + ); + }); + + it("logs warning when status pages removal fails with non-Error", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const incidentsRepository = createIncidentsRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitor = makeMonitor(); + (monitorsRepository.deleteById as jest.Mock).mockResolvedValue(monitor); + (monitorStatsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue({}); + (checksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (statusPagesRepository.removeMonitorFromStatusPages as jest.Mock).mockRejectedValue("oops"); + (incidentsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (geoChecksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + + const logger = createMockLogger(); + const { service } = createService({ + monitorsRepository, + checksRepository, + monitorStatsRepository, + statusPagesRepository, + incidentsRepository, + geoChecksRepository, + logger, + }); + + await service.deleteMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Error removing monitor"), + stack: undefined, + }) + ); + }); + + it("logs warning when incidents deletion fails with Error", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const incidentsRepository = createIncidentsRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitor = makeMonitor(); + (monitorsRepository.deleteById as jest.Mock).mockResolvedValue(monitor); + (monitorStatsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue({}); + (checksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (statusPagesRepository.removeMonitorFromStatusPages as jest.Mock).mockResolvedValue(0); + (incidentsRepository.deleteByMonitorId as jest.Mock).mockRejectedValue(new Error("incidents error")); + (geoChecksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + + const logger = createMockLogger(); + const { service } = createService({ + monitorsRepository, + checksRepository, + monitorStatsRepository, + statusPagesRepository, + incidentsRepository, + geoChecksRepository, + logger, + }); + + await service.deleteMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Error deleting incidents"), + stack: expect.any(String), + }) + ); + }); + + it("logs warning when incidents deletion fails with non-Error", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const incidentsRepository = createIncidentsRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitor = makeMonitor(); + (monitorsRepository.deleteById as jest.Mock).mockResolvedValue(monitor); + (monitorStatsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue({}); + (checksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (statusPagesRepository.removeMonitorFromStatusPages as jest.Mock).mockResolvedValue(0); + (incidentsRepository.deleteByMonitorId as jest.Mock).mockRejectedValue(null); + (geoChecksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + + const logger = createMockLogger(); + const { service } = createService({ + monitorsRepository, + checksRepository, + monitorStatsRepository, + statusPagesRepository, + incidentsRepository, + geoChecksRepository, + logger, + }); + + await service.deleteMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Error deleting incidents"), + stack: undefined, + }) + ); + }); + + it("logs warning when geo checks deletion fails with Error", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const incidentsRepository = createIncidentsRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitor = makeMonitor(); + (monitorsRepository.deleteById as jest.Mock).mockResolvedValue(monitor); + (monitorStatsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue({}); + (checksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (statusPagesRepository.removeMonitorFromStatusPages as jest.Mock).mockResolvedValue(0); + (incidentsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (geoChecksRepository.deleteByMonitorId as jest.Mock).mockRejectedValue(new Error("geo error")); + + const logger = createMockLogger(); + const { service } = createService({ + monitorsRepository, + checksRepository, + monitorStatsRepository, + statusPagesRepository, + incidentsRepository, + geoChecksRepository, + logger, + }); + + await service.deleteMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Error deleting geo checks"), + stack: expect.any(String), + }) + ); + }); + + it("logs warning when geo checks deletion fails with non-Error", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const incidentsRepository = createIncidentsRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitor = makeMonitor(); + (monitorsRepository.deleteById as jest.Mock).mockResolvedValue(monitor); + (monitorStatsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue({}); + (checksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (statusPagesRepository.removeMonitorFromStatusPages as jest.Mock).mockResolvedValue(0); + (incidentsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (geoChecksRepository.deleteByMonitorId as jest.Mock).mockRejectedValue({ code: 500 }); + + const logger = createMockLogger(); + const { service } = createService({ + monitorsRepository, + checksRepository, + monitorStatsRepository, + statusPagesRepository, + incidentsRepository, + geoChecksRepository, + logger, + }); + + await service.deleteMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Error deleting geo checks"), + stack: undefined, + }) + ); + }); + }); + + describe("deleteAllMonitors", () => { + it("deletes all monitors and associated data", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })]; + (monitorsRepository.deleteByTeamId as jest.Mock).mockResolvedValue({ monitors, deletedCount: 2 }); + (checksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (geoChecksRepository.deleteByMonitorId as jest.Mock).mockResolvedValue(0); + (statusPagesRepository.removeMonitorFromStatusPages as jest.Mock).mockResolvedValue(0); + (monitorStatsRepository.deleteByMonitorId as jest.Mock).mockResolvedValue({}); + + const { service, jobQueue } = createService({ + monitorsRepository, + checksRepository, + monitorStatsRepository, + statusPagesRepository, + geoChecksRepository, + }); + + const result = await service.deleteAllMonitors({ teamId: TEAM_ID }); + + expect(result).toBe(2); + expect(jobQueue.deleteJob).toHaveBeenCalledTimes(2); + expect(checksRepository.deleteByMonitorId).toHaveBeenCalledTimes(2); + expect(geoChecksRepository.deleteByMonitorId).toHaveBeenCalledTimes(2); + expect(statusPagesRepository.removeMonitorFromStatusPages).toHaveBeenCalledTimes(2); + expect(monitorStatsRepository.deleteByMonitorId).toHaveBeenCalledTimes(2); + }); + + it("logs warning when associated record deletion fails with Error", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitors = [makeMonitor({ id: "m1" })]; + (monitorsRepository.deleteByTeamId as jest.Mock).mockResolvedValue({ monitors, deletedCount: 1 }); + (checksRepository.deleteByMonitorId as jest.Mock).mockRejectedValue(new Error("fail")); + + const logger = createMockLogger(); + const { service } = createService({ + monitorsRepository, + checksRepository, + monitorStatsRepository, + statusPagesRepository, + geoChecksRepository, + logger, + }); + + const result = await service.deleteAllMonitors({ teamId: TEAM_ID }); + + expect(result).toBe(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Error deleting associated records"), + stack: expect.any(String), + }) + ); + }); + + it("logs warning when associated record deletion fails with non-Error", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const statusPagesRepository = createStatusPagesRepositoryMock(); + const geoChecksRepository = createGeoChecksRepositoryMock(); + + const monitors = [makeMonitor({ id: "m1" })]; + (monitorsRepository.deleteByTeamId as jest.Mock).mockResolvedValue({ monitors, deletedCount: 1 }); + // deleteJob throws non-Error + const jobQueue = createJobQueueMock(); + (jobQueue.deleteJob as jest.Mock).mockRejectedValue("string error"); + + const logger = createMockLogger(); + const service = new MonitorService({ + jobQueue: jobQueue as any, + logger: logger as any, + games: {} as any, + monitorsRepository, + checksRepository, + geoChecksRepository, + monitorStatsRepository, + statusPagesRepository, + incidentsRepository: createIncidentsRepositoryMock(), + }); + + const result = await service.deleteAllMonitors({ teamId: TEAM_ID }); + + expect(result).toBe(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Error deleting associated records"), + stack: undefined, + }) + ); + }); + + it("handles empty monitors array", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.deleteByTeamId as jest.Mock).mockResolvedValue({ monitors: [], deletedCount: 0 }); + const { service } = createService({ monitorsRepository }); + + const result = await service.deleteAllMonitors({ teamId: TEAM_ID }); + expect(result).toBe(0); + }); + }); + + describe("exportMonitorsToJSON", () => { + it("returns monitors when found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitors = [makeMonitor({ id: "m1" })]; + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue(monitors); + const { service } = createService({ monitorsRepository }); + + const result = await service.exportMonitorsToJSON({ teamId: TEAM_ID }); + expect(result).toEqual(monitors); + }); + + it("throws when no monitors found (null)", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.exportMonitorsToJSON({ teamId: TEAM_ID })).rejects.toThrow("No monitors found to export."); + }); + + it("throws when monitors array is empty", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([]); + const { service } = createService({ monitorsRepository }); + + await expect(service.exportMonitorsToJSON({ teamId: TEAM_ID })).rejects.toThrow("No monitors found to export."); + }); + }); + + describe("importMonitorsFromJSON", () => { + it("imports monitors and returns count", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const importedMonitors = [ + { name: "Test 1", type: "http", url: "https://example.com", interval: 60000 }, + { name: "Test 2", type: "ping", url: "https://test.com", interval: 30000 }, + ]; + const createdMonitors = [makeMonitor({ id: "new1" }), makeMonitor({ id: "new2" })]; + (monitorsRepository.createMonitors as jest.Mock).mockResolvedValue(createdMonitors); + const { service } = createService({ monitorsRepository }); + + const result = await service.importMonitorsFromJSON({ + teamId: TEAM_ID, + userId: USER_ID, + monitors: importedMonitors as any, + }); + + expect(result.imported).toBe(2); + expect(result.errors).toEqual([]); + }); + + it("cleans monitor fields before creating", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const createdMonitors = [makeMonitor({ id: "new1" })]; + (monitorsRepository.createMonitors as jest.Mock).mockResolvedValue(createdMonitors); + const { service } = createService({ monitorsRepository }); + + await service.importMonitorsFromJSON({ + teamId: TEAM_ID, + userId: USER_ID, + monitors: [{ name: "Test", type: "http", url: "https://example.com", interval: 60000 }] as any, + }); + + const callArg = (monitorsRepository.createMonitors as jest.Mock).mock.calls[0]![0] as any[]; + expect(callArg[0]).toMatchObject({ + teamId: TEAM_ID, + userId: USER_ID, + id: "", + recentChecks: [], + createdAt: "", + updatedAt: "", + }); + }); + }); +}); diff --git a/server/test/unit/services/networkService.test.ts b/server/test/unit/services/networkService.test.ts new file mode 100644 index 000000000..c9f496166 --- /dev/null +++ b/server/test/unit/services/networkService.test.ts @@ -0,0 +1,482 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { NetworkService } from "../../../src/service/infrastructure/networkService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; +import type { IStatusProvider } from "../../../src/service/infrastructure/network/IStatusProvider.ts"; +import type { Monitor, MonitorType } from "../../../src/types/index.ts"; +import type { AxiosStatic } from "axios"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createMockAxios = () => + ({ + post: jest.fn(), + }) as unknown as jest.Mocked; + +const createMockProvider = (type: string, result?: unknown) => { + const provider: jest.Mocked> = { + type, + supports: jest.fn((t: MonitorType) => t === type) as any, + handle: jest.fn().mockResolvedValue( + result ?? { + monitorId: "mon-1", + teamId: "team-1", + type, + status: true, + code: 200, + message: "OK", + } + ), + }; + return provider; +}; + +const createService = (providers: IStatusProvider[] = []) => { + const logger = createMockLogger(); + const axios = createMockAxios(); + const service = new NetworkService(axios, logger as any, providers); + return { service, logger, axios }; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("NetworkService", () => { + // ── Static / instance properties ───────────────────────────────────────── + + describe("serviceName", () => { + it("returns NetworkService from static property", () => { + expect(NetworkService.SERVICE_NAME).toBe("NetworkService"); + }); + + it("returns NetworkService from instance getter", () => { + const { service } = createService(); + expect(service.serviceName).toBe("NetworkService"); + }); + }); + + // ── requestStatus ──────────────────────────────────────────────────────── + + describe("requestStatus", () => { + it("delegates to the matching provider", async () => { + const provider = createMockProvider("http"); + const { service } = createService([provider]); + const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" }; + + const result = await service.requestStatus(monitor); + + expect(provider.supports).toHaveBeenCalledWith("http"); + expect(provider.handle).toHaveBeenCalledWith(monitor); + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + status: true, + code: 200, + }) + ); + }); + + it("selects the correct provider when multiple are registered", async () => { + const httpProvider = createMockProvider("http"); + const pingProvider = createMockProvider("ping"); + const { service } = createService([httpProvider, pingProvider]); + const monitor = { type: "ping", _id: "mon-2" } as unknown as Monitor & { type: "ping" }; + + await service.requestStatus(monitor); + + expect(httpProvider.handle).not.toHaveBeenCalled(); + expect(pingProvider.handle).toHaveBeenCalledWith(monitor); + }); + + it("returns unsupported-type response when no provider matches", async () => { + const { service } = createService([]); + const monitor = { type: "unknown_type" as any, _id: "mon-1" } as unknown as Monitor & { type: any }; + + const result = await service.requestStatus(monitor); + + expect(result).toEqual({ + monitorId: "unknown", + teamId: "unknown", + type: "unknown", + status: false, + code: NETWORK_ERROR, + message: "Unsupported type: unknown_type", + }); + }); + }); + + // ── requestWebhook ─────────────────────────────────────────────────────── + + describe("requestWebhook", () => { + it("sends a POST and returns success response", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ status: 200, data: { ok: true } }); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", { text: "hello" }); + + expect(axios.post).toHaveBeenCalledWith("https://hooks.example.com", { text: "hello" }, { headers: { "Content-Type": "application/json" } }); + expect(result).toEqual({ + type: "webhook", + status: true, + code: 200, + message: "Successfully sent slack notification", + payload: { ok: true }, + }); + }); + + it("returns failure with response data on axios error with response", async () => { + const { service, axios, logger } = createService(); + const axiosError = Object.assign(new Error("Request failed"), { + response: { status: 403, data: { error: "forbidden" } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestWebhook("discord", "https://hooks.example.com", {}); + + expect(result).toEqual({ + type: "webhook", + status: false, + code: 403, + message: "Failed to send discord notification", + payload: { error: "forbidden" }, + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Request failed", + service: "NetworkService", + method: "requestWebhook", + }) + ); + }); + + it("returns failure with NETWORK_ERROR when axios error has no response status", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("timeout"), { + response: { status: undefined, data: undefined }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", {}); + + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("returns failure with NETWORK_ERROR on non-axios error (no response property)", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("DNS failure")); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", {}); + + expect(result).toEqual({ + type: "webhook", + status: false, + code: NETWORK_ERROR, + message: "Failed to send slack notification", + }); + }); + + it("handles non-Error thrown values", async () => { + const { service, axios, logger } = createService(); + (axios.post as jest.Mock).mockRejectedValue("string error"); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", {}); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "string error" })); + expect(result).toEqual({ + type: "webhook", + status: false, + code: NETWORK_ERROR, + message: "Failed to send slack notification", + }); + }); + + it("returns NETWORK_ERROR when error has response key but response is undefined", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("fail"), { response: undefined }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", {}); + + expect(result.code).toBe(NETWORK_ERROR); + expect(result.payload).toBeUndefined(); + }); + }); + + // ── requestPagerDuty ───────────────────────────────────────────────────── + + describe("requestPagerDuty", () => { + const args = { + message: "Server down", + routingKey: "routing-key-123", + monitorUrl: "https://monitor.example.com", + }; + + it("sends correct payload and returns true on success", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ data: { status: "success" } }); + + const result = await service.requestPagerDuty(args); + + expect(result).toBe(true); + expect(axios.post).toHaveBeenCalledWith( + "https://events.pagerduty.com/v2/enqueue", + expect.objectContaining({ + routing_key: "routing-key-123", + event_action: "trigger", + payload: expect.objectContaining({ + summary: "Server down", + severity: "critical", + source: "https://monitor.example.com", + }), + }) + ); + }); + + it("returns false when response status is not success", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ data: { status: "invalid event" } }); + + const result = await service.requestPagerDuty(args); + + expect(result).toBe(false); + }); + + it("returns false when response data is undefined", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ data: undefined }); + + const result = await service.requestPagerDuty(args); + + expect(result).toBe(false); + }); + + it("throws AppError on axios failure with response data", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("Request failed"), { + response: { data: { message: "invalid routing key" } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + await expect(service.requestPagerDuty(args)).rejects.toThrow("Request failed"); + }); + + it("throws AppError on network error without response", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("ECONNREFUSED")); + + await expect(service.requestPagerDuty(args)).rejects.toThrow("ECONNREFUSED"); + }); + + it("throws AppError with default message for non-Error thrown value", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(null); + + await expect(service.requestPagerDuty(args)).rejects.toThrow("null"); + }); + + it("uses fallback message when Error has empty string message", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("")); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.message).toBe("Error sending PagerDuty notification"); + } + }); + + it("includes responseData when non-Error object has response property", async () => { + const { service, axios } = createService(); + const errorObj = { response: { data: { detail: "bad key" } } }; + (axios.post as jest.Mock).mockRejectedValue(errorObj); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ responseData: { detail: "bad key" } }); + } + }); + + it("sets responseData to undefined when error is a primitive (non-object)", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(42); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ responseData: undefined }); + } + }); + + it("sets responseData to undefined when object has response key set to undefined", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue({ response: undefined }); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ responseData: undefined }); + } + }); + + it("includes responseData in AppError details when available", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("Bad request"), { + response: { data: { errors: ["invalid key"] } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ + responseData: { errors: ["invalid key"] }, + }); + } + }); + + it("sets responseData to undefined in AppError when error has no response", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("timeout")); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ responseData: undefined }); + } + }); + }); + + // ── requestMatrix ──────────────────────────────────────────────────────── + + describe("requestMatrix", () => { + const args = { + homeserverUrl: "https://matrix.example.com", + accessToken: "token-abc", + roomId: "!room:example.com", + message: "Alert", + }; + + it("sends correct payload and returns success response", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ status: 200, data: { event_id: "$abc" } }); + + const result = await service.requestMatrix(args); + + expect(result).toEqual({ + status: true, + code: 200, + message: "Successfully sent Matrix notification", + }); + expect(axios.post).toHaveBeenCalledWith( + "https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message?access_token=token-abc", + { + msgtype: "m.text", + body: "Alert", + format: "org.matrix.custom.html", + formatted_body: "Alert", + }, + { headers: { "Content-Type": "application/json" } } + ); + }); + + it("returns failure with response data on axios error with response (Error instance)", async () => { + const { service, axios, logger } = createService(); + const axiosError = Object.assign(new Error("Forbidden"), { + response: { status: 403, data: { errcode: "M_FORBIDDEN" } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestMatrix(args); + + expect(result).toEqual({ + status: false, + code: 403, + message: "Failed to send Matrix notification", + payload: { errcode: "M_FORBIDDEN" }, + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Forbidden", + service: "NetworkService", + method: "requestMatrix", + }) + ); + }); + + it("returns NETWORK_ERROR when axios error response has no status", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("timeout"), { + response: { status: 0, data: undefined }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestMatrix(args); + + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("returns failure with NETWORK_ERROR on Error without response property", async () => { + const { service, axios, logger } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("DNS failure")); + + const result = await service.requestMatrix(args); + + expect(result).toEqual({ + status: false, + code: NETWORK_ERROR, + message: "Failed to send Matrix notification", + }); + // First warn call is from the Error instanceof branch + expect(logger.warn).toHaveBeenCalledTimes(2); + }); + + it("returns failure with NETWORK_ERROR on non-Error thrown value", async () => { + const { service, axios, logger } = createService(); + (axios.post as jest.Mock).mockRejectedValue("string error"); + + const result = await service.requestMatrix(args); + + expect(result).toEqual({ + status: false, + code: NETWORK_ERROR, + message: "Failed to send Matrix notification", + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "string error", + service: "NetworkService", + method: "requestMatrix", + }) + ); + }); + + it("returns NETWORK_ERROR when Error has response key but response is undefined", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("fail"), { response: undefined }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestMatrix(args); + + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("returns NETWORK_ERROR when Error response has undefined status", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("fail"), { + response: { status: undefined, data: { error: "unknown" } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestMatrix(args); + + expect(result.code).toBe(NETWORK_ERROR); + expect(result.payload).toEqual({ error: "unknown" }); + }); + }); +}); diff --git a/server/test/unit/services/notificationMessageBuilder.test.ts b/server/test/unit/services/notificationMessageBuilder.test.ts new file mode 100644 index 000000000..d10bd1604 --- /dev/null +++ b/server/test/unit/services/notificationMessageBuilder.test.ts @@ -0,0 +1,761 @@ +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"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + name: "Test Monitor", + url: "https://example.com", + type: "http", + status: "down", + teamId: "team-1", + cpuAlertThreshold: undefined, + memoryAlertThreshold: undefined, + diskAlertThreshold: undefined, + tempAlertThreshold: undefined, + ...overrides, + }) as Monitor; + +const makeDecision = (overrides?: Partial): MonitorActionDecision => ({ + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: true, + incidentReason: null, + notificationReason: "status_change", + ...overrides, +}); + +const makeStatusResponse = (overrides?: Partial): MonitorStatusResponse => + ({ + monitorId: "mon-1", + teamId: "team-1", + type: "http", + status: false, + code: 500, + message: "Internal Server Error", + ...overrides, + }) as MonitorStatusResponse; + +const makeHardwarePayload = (overrides?: Partial): HardwareStatusPayload => + ({ + data: { + cpu: { usage_percent: 0.5, temperature: [45] }, + memory: { usage_percent: 0.6 }, + disk: [{ usage_percent: 0.7 }], + ...overrides, + }, + }) as HardwareStatusPayload; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("NotificationMessageBuilder", () => { + let builder: NotificationMessageBuilder; + + beforeEach(() => { + builder = new NotificationMessageBuilder(); + }); + + describe("SERVICE_NAME", () => { + it("returns NotificationMessageBuilder", () => { + expect(NotificationMessageBuilder.SERVICE_NAME).toBe("NotificationMessageBuilder"); + }); + }); + + // ── buildMessage ───────────────────────────────────────────────────── + + describe("buildMessage", () => { + it("builds a monitor_down message", () => { + const monitor = makeMonitor({ status: "down" }); + const decision = makeDecision(); + const response = makeStatusResponse(); + + const msg = builder.buildMessage(monitor, response, decision, "https://app.example.com"); + + expect(msg.type).toBe("monitor_down"); + expect(msg.severity).toBe("critical"); + expect(msg.monitor).toEqual({ + id: "mon-1", + name: "Test Monitor", + url: "https://example.com", + type: "http", + status: "down", + }); + expect(msg.content.title).toBe("Monitor Down: Test Monitor"); + expect(msg.clientHost).toBe("https://app.example.com"); + expect(msg.metadata).toEqual({ + teamId: "team-1", + notificationReason: "status_change", + }); + }); + + it("builds a monitor_up message", () => { + const monitor = makeMonitor({ status: "up", type: "http" }); + const decision = makeDecision({ notificationReason: "status_change" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com"); + + expect(msg.type).toBe("monitor_up"); + expect(msg.severity).toBe("success"); + expect(msg.content.title).toBe("Monitor Recovered: Test Monitor"); + }); + + it("builds a threshold_breach message", () => { + const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 }); + const decision = makeDecision({ notificationReason: "threshold_breach" }); + const response = makeStatusResponse({ + type: "hardware", + payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }), + } as any); + + const msg = builder.buildMessage(monitor, response, decision, "https://app.example.com"); + + expect(msg.type).toBe("threshold_breach"); + expect(msg.severity).toBe("warning"); + expect(msg.content.title).toBe("Threshold Exceeded: Test Monitor"); + expect(msg.content.thresholds).toBeDefined(); + expect(msg.content.thresholds!.length).toBeGreaterThan(0); + }); + + it("builds a threshold_resolved message for hardware monitor recovering", () => { + const monitor = makeMonitor({ status: "up", type: "hardware" }); + const decision = makeDecision({ notificationReason: "status_change" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com"); + + expect(msg.type).toBe("threshold_resolved"); + expect(msg.severity).toBe("success"); + expect(msg.content.title).toBe("Thresholds Resolved: Test Monitor"); + }); + + it("uses notificationReason from decision, falling back to status_change", () => { + const monitor = makeMonitor({ status: "down" }); + const decision = makeDecision({ notificationReason: null }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com"); + + expect(msg.metadata.notificationReason).toBe("status_change"); + }); + }); + + // ── determineNotificationType (via buildMessage) ───────────────────── + + describe("determineNotificationType", () => { + it("returns monitor_down when status is down, even if notificationReason is threshold_breach", () => { + const monitor = makeMonitor({ status: "down" }); + const decision = makeDecision({ notificationReason: "threshold_breach" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("monitor_down"); + }); + + it("returns threshold_breach when reason is threshold_breach and status is not down", () => { + const monitor = makeMonitor({ status: "up", type: "hardware" }); + const decision = makeDecision({ notificationReason: "threshold_breach" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("threshold_breach"); + }); + + it("returns threshold_resolved for hardware monitor with status_change and up", () => { + const monitor = makeMonitor({ status: "up", type: "hardware" }); + const decision = makeDecision({ notificationReason: "status_change" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("threshold_resolved"); + }); + + it("returns monitor_up for non-hardware monitor with status up", () => { + const monitor = makeMonitor({ status: "up", type: "http" }); + const decision = makeDecision({ notificationReason: "status_change" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("monitor_up"); + }); + + it("returns monitor_up as default for unrecognized status", () => { + const monitor = makeMonitor({ status: "unknown" as any }); + const decision = makeDecision({ notificationReason: null }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("monitor_up"); + }); + }); + + // ── determineSeverity (via buildMessage) ───────────────────────────── + + describe("determineSeverity", () => { + it("returns critical for monitor_down", () => { + const msg = builder.buildMessage(makeMonitor({ status: "down" }), makeStatusResponse(), makeDecision(), ""); + expect(msg.severity).toBe("critical"); + }); + + it("returns warning for threshold_breach", () => { + const msg = builder.buildMessage( + makeMonitor({ status: "up", type: "hardware" }), + makeStatusResponse(), + makeDecision({ notificationReason: "threshold_breach" }), + "" + ); + expect(msg.severity).toBe("warning"); + }); + + it("returns success for monitor_up", () => { + const msg = builder.buildMessage( + makeMonitor({ status: "up", type: "http" }), + makeStatusResponse(), + makeDecision({ notificationReason: "status_change" }), + "" + ); + expect(msg.severity).toBe("success"); + }); + + it("returns success for threshold_resolved", () => { + const msg = builder.buildMessage( + makeMonitor({ status: "up", type: "hardware" }), + makeStatusResponse(), + makeDecision({ notificationReason: "status_change" }), + "" + ); + expect(msg.severity).toBe("success"); + }); + }); + + // ── buildContent variants ──────────────────────────────────────────── + + describe("buildContent", () => { + describe("monitor_down", () => { + it("includes response code and error message when present", () => { + const monitor = makeMonitor({ status: "down" }); + const response = makeStatusResponse({ code: 503, message: "Service Unavailable" }); + + const msg = builder.buildMessage(monitor, response, makeDecision(), ""); + + expect(msg.content.details).toContain("Response Code: 503"); + expect(msg.content.details).toContain("Error: Service Unavailable"); + }); + + it("omits response code when falsy", () => { + const monitor = makeMonitor({ status: "down" }); + const response = makeStatusResponse({ code: 0, message: "" }); + + const msg = builder.buildMessage(monitor, response, makeDecision(), ""); + + expect(msg.content.details).not.toContainEqual(expect.stringContaining("Response Code:")); + expect(msg.content.details).not.toContainEqual(expect.stringContaining("Error:")); + }); + + it("includes URL, Status, and Type in details", () => { + const monitor = makeMonitor({ status: "down" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + + expect(msg.content.details).toContain("URL: https://example.com"); + expect(msg.content.details).toContain("Status: Down"); + expect(msg.content.details).toContain("Type: http"); + }); + + it("sets summary text", () => { + const monitor = makeMonitor({ status: "down" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + expect(msg.content.summary).toBe('Monitor "Test Monitor" is currently down and unreachable.'); + }); + + it("sets timestamp", () => { + const monitor = makeMonitor({ status: "down" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + expect(msg.content.timestamp).toBeInstanceOf(Date); + }); + }); + + describe("monitor_up", () => { + it("includes recovery details", () => { + const monitor = makeMonitor({ status: "up", type: "http" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + + expect(msg.content.title).toBe("Monitor Recovered: Test Monitor"); + expect(msg.content.summary).toBe('Monitor "Test Monitor" is back up and operational.'); + expect(msg.content.details).toContain("Status: Up"); + }); + }); + + describe("threshold_breach", () => { + it("includes threshold breaches in content", () => { + const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }), + } as any); + + const msg = builder.buildMessage(monitor, response, makeDecision({ notificationReason: "threshold_breach" }), ""); + + expect(msg.content.thresholds).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + metric: "cpu", + currentValue: 90, + threshold: 80, + }), + ]) + ); + }); + }); + + describe("threshold_resolved", () => { + it("includes resolved details", () => { + const monitor = makeMonitor({ status: "up", type: "hardware" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + + expect(msg.content.title).toBe("Thresholds Resolved: Test Monitor"); + expect(msg.content.summary).toBe('Monitor "Test Monitor" thresholds have returned to normal.'); + expect(msg.content.details).toContain("Status: Up"); + }); + }); + + describe("default content", () => { + it("builds default content for unhandled notification type", () => { + // Force default by creating a scenario where type falls through + // We can test buildContent default by subclassing or testing indirectly + // The default case in buildContent handles "test" type among others + // Since determineNotificationType never returns "test", we test via + // a monitor with unrecognized status that maps to monitor_up + // Instead, let's verify the default case exists by checking that + // non-standard types would get default content + const monitor = makeMonitor({ status: "up", type: "http" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + + // monitor_up is handled, so content should be monitor_up specific + expect(msg.content.title).toBe("Monitor Recovered: Test Monitor"); + }); + }); + }); + + // ── determineSeverity edge cases (via private method) ──────────────── + + describe("determineSeverity edge cases", () => { + it("returns info for test type", () => { + const result = (builder as any).determineSeverity("test"); + expect(result).toBe("info"); + }); + + it("returns info for unknown type (default)", () => { + const result = (builder as any).determineSeverity("unknown_type"); + expect(result).toBe("info"); + }); + }); + + // ── buildContent edge cases (via private method) ───────────────────── + + describe("buildContent edge cases", () => { + it("returns default content for unhandled notification type", () => { + const monitor = makeMonitor({ status: "up", type: "http" }); + const result = (builder as any).buildContent("test", monitor, makeStatusResponse()); + + expect(result.title).toBe("Monitor: Test Monitor"); + expect(result.summary).toBe('Status update for monitor "Test Monitor".'); + expect(result.details).toContain("URL: https://example.com"); + expect(result.details).toContain("Status: up"); + expect(result.details).toContain("Type: http"); + }); + }); + + // ── extractThresholdBreaches ───────────────────────────────────────── + + describe("extractThresholdBreaches", () => { + it("returns empty array for non-hardware monitor", () => { + const monitor = makeMonitor({ type: "http" }); + const response = makeStatusResponse(); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toEqual([]); + }); + + it("returns empty array when payload is missing", () => { + const monitor = makeMonitor({ type: "hardware" }); + const response = makeStatusResponse({ payload: undefined } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toEqual([]); + }); + + it("returns empty array when hardware data is missing", () => { + const monitor = makeMonitor({ type: "hardware" }); + const response = makeStatusResponse({ payload: { data: undefined } } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toEqual([]); + }); + + // ── CPU ────────────────────────────────────────────────────────── + + describe("cpu threshold", () => { + it("detects CPU breach when usage exceeds threshold", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "cpu", + currentValue: 90, + threshold: 80, + unit: "%", + formattedValue: "90.0%", + }) + ); + }); + + it("does not report CPU breach when usage is below threshold", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [50] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + + it("skips CPU check when cpuAlertThreshold is undefined", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: undefined }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.99 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + + it("skips CPU check when cpuAlertThreshold is null", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: null as any }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.99 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + + it("skips CPU check when cpu usage_percent is undefined", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: undefined } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + + it("skips CPU check when cpu object is undefined", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: undefined as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + }); + + // ── Memory ─────────────────────────────────────────────────────── + + describe("memory threshold", () => { + it("detects memory breach when usage exceeds threshold", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: 0.85 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "memory", + currentValue: 85, + threshold: 70, + unit: "%", + formattedValue: "85.0%", + }) + ); + }); + + it("does not report memory breach when usage is below threshold", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 90 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: 0.5 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + + it("skips memory check when memoryAlertThreshold is undefined", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: undefined }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: 0.99 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + + it("skips memory check when memoryAlertThreshold is null", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: null as any }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: 0.99 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + + it("skips memory check when memory usage_percent is undefined", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: undefined } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + + it("skips memory check when memory object is undefined", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: undefined as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + }); + + // ── Disk ───────────────────────────────────────────────────────── + + describe("disk threshold", () => { + it("detects disk breach using highest usage across multiple disks", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ + disk: [{ usage_percent: 0.5 }, { usage_percent: 0.95 }, { usage_percent: 0.7 }] as any, + }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "disk", + currentValue: 95, + threshold: 80, + formattedValue: "95.0%", + }) + ); + }); + + it("does not report disk breach when all disks are below threshold", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 90 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ + disk: [{ usage_percent: 0.5 }, { usage_percent: 0.6 }] as any, + }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + + it("skips disk check when diskAlertThreshold is undefined", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: undefined }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ disk: [{ usage_percent: 0.99 }] as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + + it("skips disk check when diskAlertThreshold is null", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: null as any }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ disk: [{ usage_percent: 0.99 }] as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + + it("skips disk check when disk is not an array", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ disk: "not-an-array" as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + + it("skips disks with undefined usage_percent", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 50 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ + disk: [{ usage_percent: undefined }, { usage_percent: 0.3 }] as any, + }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + }); + + // ── Temperature ────────────────────────────────────────────────── + + describe("temperature threshold", () => { + it("detects temperature breach from array of temperatures", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [65, 75, 68] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "temp", + currentValue: 75, + threshold: 70, + unit: "°C", + formattedValue: "75.0°C", + }) + ); + }); + + it("handles single temperature value (non-array)", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: 80 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "temp", + currentValue: 80, + threshold: 70, + }) + ); + }); + + it("does not report temperature breach when below threshold", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [60, 65] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + + it("skips temperature check when tempAlertThreshold is undefined", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: undefined }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [99] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + + it("skips temperature check when tempAlertThreshold is null", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: null as any }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [99] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + + it("skips temperature check when cpu.temperature is falsy", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: null } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + + it("skips temperature check when cpu object is undefined", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: undefined as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + }); + + // ── Combined ───────────────────────────────────────────────────── + + describe("combined thresholds", () => { + it("detects multiple breaches simultaneously", () => { + const monitor = makeMonitor({ + type: "hardware", + cpuAlertThreshold: 80, + memoryAlertThreshold: 70, + diskAlertThreshold: 85, + tempAlertThreshold: 65, + }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ + cpu: { usage_percent: 0.9, temperature: [70] } as any, + memory: { usage_percent: 0.85 } as any, + disk: [{ usage_percent: 0.95 }] as any, + }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + const metrics = breaches.map((b) => b.metric); + expect(metrics).toContain("cpu"); + expect(metrics).toContain("memory"); + expect(metrics).toContain("disk"); + expect(metrics).toContain("temp"); + }); + }); + }); +}); diff --git a/server/test/unit/services/notificationsService.test.ts b/server/test/unit/services/notificationsService.test.ts new file mode 100644 index 000000000..cdc1ad320 --- /dev/null +++ b/server/test/unit/services/notificationsService.test.ts @@ -0,0 +1,357 @@ +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"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createProvider = () => ({ + sendMessage: jest.fn<() => Promise>().mockResolvedValue(true), + sendTestAlert: jest.fn<() => Promise>().mockResolvedValue(true), +}); + +const createNotificationsRepo = () => ({ + create: jest.fn(), + findById: jest.fn(), + findNotificationsByIds: jest.fn(), + findByTeamId: jest.fn(), + updateById: jest.fn(), + deleteById: jest.fn(), +}); + +const createMonitorsRepo = () => ({ + removeNotificationFromMonitors: jest.fn(), +}); + +const createSettingsService = (clientHost = "https://app.example.com") => ({ + getSettings: jest.fn().mockReturnValue({ clientHost }), +}); + +const createMessageBuilder = () => ({ + buildMessage: jest.fn().mockReturnValue({ type: "monitor_down", content: { title: "Down" } }), + extractThresholdBreaches: jest.fn(), +}); + +const createService = (overrides?: Record) => { + const logger = createMockLogger(); + const notificationsRepository = createNotificationsRepo(); + const monitorsRepository = createMonitorsRepo(); + const webhookProvider = createProvider(); + const emailProvider = createProvider(); + const slackProvider = createProvider(); + const discordProvider = createProvider(); + const pagerDutyProvider = createProvider(); + const matrixProvider = createProvider(); + const teamsProvider = createProvider(); + const telegramProvider = createProvider(); + const pushoverProvider = createProvider(); + const twilioProvider = createProvider(); + const settingsService = createSettingsService(); + const notificationMessageBuilder = createMessageBuilder(); + + const defaults = { + logger, + notificationsRepository, + monitorsRepository, + webhookProvider, + emailProvider, + slackProvider, + discordProvider, + pagerDutyProvider, + matrixProvider, + teamsProvider, + telegramProvider, + pushoverProvider, + twilioProvider, + settingsService, + notificationMessageBuilder, + ...overrides, + }; + + const service = new NotificationsService( + defaults.notificationsRepository as any, + defaults.monitorsRepository as any, + defaults.webhookProvider as any, + defaults.emailProvider as any, + defaults.slackProvider as any, + defaults.discordProvider as any, + defaults.pagerDutyProvider as any, + defaults.matrixProvider as any, + defaults.teamsProvider as any, + defaults.telegramProvider as any, + defaults.pushoverProvider as any, + defaults.twilioProvider as any, + defaults.settingsService as any, + defaults.logger as any, + defaults.notificationMessageBuilder as any + ); + + return { service, ...defaults }; +}; + +const makeNotification = (overrides?: Partial): Notification => + ({ + id: "notif-1", + userId: "user-1", + teamId: "team-1", + type: "email", + notificationName: "Email Alert", + address: "test@example.com", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Notification; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + name: "Test Monitor", + type: "http", + notifications: ["notif-1"], + ...overrides, + }) as Monitor; + +const makeDecision = (overrides?: Partial): MonitorActionDecision => ({ + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: true, + incidentReason: null, + notificationReason: "status_change", + ...overrides, +}); + +const makeStatusResponse = () => ({ monitorId: "mon-1", status: false, code: 500 }) as unknown as MonitorStatusResponse; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("NotificationsService", () => { + // ── handleNotifications ─────────────────────────────────────────────────── + + describe("handleNotifications", () => { + it("returns false when shouldSendNotification is false", async () => { + const { service } = createService(); + const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision({ shouldSendNotification: false })); + expect(result).toBe(false); + }); + + it("sends notifications to all configured providers and returns true", async () => { + const { service, notificationsRepository, emailProvider } = createService(); + (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]); + + const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision()); + + expect(result).toBe(true); + expect(emailProvider.sendMessage).toHaveBeenCalledTimes(1); + }); + + 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; + for (const type of types) { + const deps = createService(); + (deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]); + + await deps.service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision()); + + 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].sendMessage).toHaveBeenCalledTimes(1); + } + }); + + it("returns false and logs warning for unknown notification type", async () => { + const { service, notificationsRepository, logger } = createService(); + (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "carrier_pigeon" as any })]); + + const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision()); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("Unknown notification type: carrier_pigeon") }) + ); + }); + + it("returns false and logs warning when notificationMessage is undefined", async () => { + const notificationMessageBuilder = createMessageBuilder(); + notificationMessageBuilder.buildMessage.mockReturnValue(undefined); + const { service, notificationsRepository, logger } = createService({ notificationMessageBuilder }); + (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]); + + const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision()); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Notification message not provided" })); + }); + + it("handles monitors with no notification IDs", async () => { + const { service, notificationsRepository } = createService(); + (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([]); + + const result = await service.handleNotifications(makeMonitor({ notifications: undefined as any }), makeStatusResponse(), makeDecision()); + + expect(result).toBe(true); + expect(notificationsRepository.findNotificationsByIds).toHaveBeenCalledWith([]); + }); + + it("returns false and logs when some notifications fail", async () => { + const { service, notificationsRepository, emailProvider, slackProvider, logger } = createService(); + (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([ + makeNotification({ id: "n1", type: "email" }), + makeNotification({ id: "n2", type: "slack" }), + ]); + emailProvider.sendMessage.mockResolvedValue(true); + slackProvider.sendMessage.mockResolvedValue(false); + + const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision()); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 success, 1 failure") })); + }); + + it("uses fallback clientHost when settings.clientHost is empty", async () => { + const settingsService = createSettingsService(""); + const { service, notificationsRepository, notificationMessageBuilder } = createService({ settingsService }); + (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]); + + await service.handleNotifications(makeMonitor(), makeStatusResponse(), makeDecision()); + + expect(notificationMessageBuilder.buildMessage).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + "Host not defined" + ); + }); + }); + + // ── 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 }); + + 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); + } + ); + + it("returns false for unknown notification type", async () => { + const { service } = createService(); + const result = await service.sendTestNotification(makeNotification({ type: "unknown" as any })); + expect(result).toBe(false); + }); + }); + + // ── testAllNotifications ───────────────────────────────────────────────── + + describe("testAllNotifications", () => { + it("returns true when all test alerts succeed", async () => { + const { service, notificationsRepository } = createService(); + (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([ + makeNotification({ type: "email" }), + makeNotification({ type: "slack" }), + ]); + + const result = await service.testAllNotifications(["notif-1", "notif-2"]); + expect(result).toBe(true); + }); + + it("returns false when any test alert fails", async () => { + const { service, notificationsRepository, emailProvider } = createService(); + emailProvider.sendTestAlert.mockResolvedValue(false); + (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]); + + const result = await service.testAllNotifications(["notif-1"]); + expect(result).toBe(false); + }); + }); + + // ── CRUD operations ────────────────────────────────────────────────────── + + describe("createNotification", () => { + it("sets userId and teamId and delegates to repository", async () => { + const created = makeNotification(); + const { service, notificationsRepository } = createService(); + (notificationsRepository.create as jest.Mock).mockResolvedValue(created); + + const result = await service.createNotification({ type: "email", address: "a@b.com" }, "user-1", "team-1"); + + expect(result).toBe(created); + expect(notificationsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ userId: "user-1", teamId: "team-1", type: "email" })); + }); + }); + + describe("findById", () => { + it("delegates to repository", async () => { + const notification = makeNotification(); + const { service, notificationsRepository } = createService(); + (notificationsRepository.findById as jest.Mock).mockResolvedValue(notification); + + const result = await service.findById("notif-1", "team-1"); + expect(result).toBe(notification); + }); + }); + + describe("findNotificationsByTeamId", () => { + it("delegates to repository", async () => { + const notifications = [makeNotification()]; + const { service, notificationsRepository } = createService(); + (notificationsRepository.findByTeamId as jest.Mock).mockResolvedValue(notifications); + + const result = await service.findNotificationsByTeamId("team-1"); + expect(result).toBe(notifications); + }); + }); + + describe("updateById", () => { + it("delegates to repository", async () => { + const updated = makeNotification({ address: "new@example.com" }); + const { service, notificationsRepository } = createService(); + (notificationsRepository.updateById as jest.Mock).mockResolvedValue(updated); + + const result = await service.updateById("notif-1", "team-1", { address: "new@example.com" }); + expect(result).toBe(updated); + }); + }); + + describe("deleteById", () => { + it("deletes notification and removes from monitors", async () => { + const deleted = makeNotification(); + const { service, notificationsRepository, monitorsRepository } = createService(); + (notificationsRepository.deleteById as jest.Mock).mockResolvedValue(deleted); + + const result = await service.deleteById("notif-1", "team-1"); + + expect(result).toBe(deleted); + expect(monitorsRepository.removeNotificationFromMonitors).toHaveBeenCalledWith("notif-1"); + }); + }); +}); diff --git a/server/test/unit/services/settingsService.test.ts b/server/test/unit/services/settingsService.test.ts new file mode 100644 index 000000000..da75d3ee5 --- /dev/null +++ b/server/test/unit/services/settingsService.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { SettingsService } from "../../../src/service/system/settingsService.ts"; +import type { ISettingsRepository } from "../../../src/repositories/settings/ISettingsRepository.ts"; +import type { ValidatedEnv } from "../../../src/validation/envValidation.ts"; +import type { Settings } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeEnv = (overrides?: Partial): ValidatedEnv => + ({ + JWT_SECRET: "test-secret", + TOKEN_TTL: "99d", + NODE_ENV: "development", + LOG_LEVEL: "debug", + CLIENT_HOST: "http://localhost:5173", + DB_CONNECTION_STRING: "mongodb://localhost:27017/test_db", + DB_TYPE: "mongodb", + STATUS_PAGE_THEMES_ENABLED: false, + ...overrides, + }) as ValidatedEnv; + +const makeSettings = (overrides?: Partial): Settings => + ({ + id: "settings-1", + checkTTL: 30, + language: "en", + systemEmailSecure: false, + systemEmailPool: false, + systemEmailIgnoreTLS: false, + systemEmailRequireTLS: false, + systemEmailRejectUnauthorized: false, + showURL: true, + singleton: true, + version: 1, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Settings; + +const createSettingsRepo = () => + ({ + create: jest.fn().mockResolvedValue(makeSettings()), + findSingleton: jest.fn().mockResolvedValue(makeSettings()), + update: jest.fn().mockResolvedValue(makeSettings()), + deleteLegacy: jest.fn().mockResolvedValue(true), + }) as unknown as jest.Mocked; + +const createService = (envOverrides?: Partial) => { + const env = makeEnv(envOverrides); + const service = new SettingsService(env); + const settingsRepository = createSettingsRepo(); + service.setRepository(settingsRepository); + return { service, settingsRepository, env }; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("SettingsService", () => { + describe("serviceName", () => { + it("returns SettingsService from static property", () => { + expect(SettingsService.SERVICE_NAME).toBe("SettingsService"); + }); + + it("returns SettingsService from instance getter", () => { + const { service } = createService(); + expect(service.serviceName).toBe("SettingsService"); + }); + }); + + // ── loadSettings ──────────────────────────────────────────────────────── + + describe("loadSettings", () => { + it("returns env config mapped from constructor", () => { + const { service } = createService(); + const settings = service.loadSettings(); + + expect(settings).toEqual({ + jwtSecret: "test-secret", + jwtTTL: "99d", + nodeEnv: "development", + logLevel: "debug", + clientHost: "http://localhost:5173", + dbConnectionString: "mongodb://localhost:27017/test_db", + dbType: "mongodb", + statusPageThemesEnabled: false, + }); + }); + + it("reflects custom env values", () => { + const { service } = createService({ NODE_ENV: "production", LOG_LEVEL: "error" }); + const settings = service.loadSettings(); + + expect(settings.nodeEnv).toBe("production"); + expect(settings.logLevel).toBe("error"); + }); + }); + + // ── getSettings ───────────────────────────────────────────────────────── + + describe("getSettings", () => { + it("returns the same config as loadSettings", () => { + const { service } = createService(); + + expect(service.getSettings()).toEqual(service.loadSettings()); + }); + }); + + // ── areStatusPageThemesEnabled ────────────────────────────────────────── + + describe("areStatusPageThemesEnabled", () => { + it("returns false when STATUS_PAGE_THEMES_ENABLED env is false", () => { + const { service } = createService({ STATUS_PAGE_THEMES_ENABLED: false }); + + expect(service.areStatusPageThemesEnabled()).toBe(false); + }); + + it("returns true when STATUS_PAGE_THEMES_ENABLED env is true", () => { + const { service } = createService({ STATUS_PAGE_THEMES_ENABLED: true }); + + expect(service.areStatusPageThemesEnabled()).toBe(true); + }); + }); + + // ── setRepository ─────────────────────────────────────────────────────── + + describe("setRepository", () => { + it("allows DB methods to work after being called", async () => { + const env = makeEnv(); + const service = new SettingsService(env); + const repo = createSettingsRepo(); + + service.setRepository(repo); + const settings = await service.getDBSettings(); + + expect(settings).toEqual(makeSettings()); + }); + }); + + // ── getDBSettings ─────────────────────────────────────────────────────── + + describe("getDBSettings", () => { + it("deletes legacy settings and returns singleton", async () => { + const { service, settingsRepository } = createService(); + const expected = makeSettings(); + (settingsRepository.findSingleton as jest.Mock).mockResolvedValue(expected); + + const result = await service.getDBSettings(); + + expect(settingsRepository.deleteLegacy).toHaveBeenCalled(); + expect(settingsRepository.findSingleton).toHaveBeenCalled(); + expect(result).toBe(expected); + }); + + it("creates default settings when singleton is null, then returns them", async () => { + const { service, settingsRepository } = createService(); + const created = makeSettings(); + (settingsRepository.findSingleton as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(created); + + const result = await service.getDBSettings(); + + expect(settingsRepository.create).toHaveBeenCalledWith({}); + expect(settingsRepository.findSingleton).toHaveBeenCalledTimes(2); + expect(result).toBe(created); + }); + + it("throws when settings are still null after creation attempt", async () => { + const { service, settingsRepository } = createService(); + (settingsRepository.findSingleton as jest.Mock).mockResolvedValue(null); + + await expect(service.getDBSettings()).rejects.toThrow("Settings not found"); + }); + + it("throws when repository is not set", async () => { + const env = makeEnv(); + const service = new SettingsService(env); + + await expect(service.getDBSettings()).rejects.toThrow("Settings repository not initialized"); + }); + }); + + // ── updateDbSettings ──────────────────────────────────────────────────── + + describe("updateDbSettings", () => { + it("delegates to repository and returns updated settings", async () => { + const updated = makeSettings({ checkTTL: 60 }); + const { service, settingsRepository } = createService(); + (settingsRepository.update as jest.Mock).mockResolvedValue(updated); + + const result = await service.updateDbSettings({ checkTTL: 60 }); + + expect(result).toBe(updated); + expect(settingsRepository.update).toHaveBeenCalledWith({ checkTTL: 60 }); + }); + + it("throws when repository is not set", async () => { + const env = makeEnv(); + const service = new SettingsService(env); + + await expect(service.updateDbSettings({ checkTTL: 60 })).rejects.toThrow("Settings repository not initialized"); + }); + }); +}); diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts new file mode 100644 index 000000000..2deac9897 --- /dev/null +++ b/server/test/unit/services/statusPageService.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { StatusPageService } from "../../../src/service/business/statusPageService.ts"; +import type { IStatusPagesRepository } from "../../../src/repositories/status-pages/IStatusPagesRepository.ts"; +import type { ISettingsService } from "../../../src/service/system/settingsService.ts"; +import type { StatusPage } from "../../../src/types/index.ts"; +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeStatusPage = (overrides?: Partial): StatusPage => + ({ + id: "sp-1", + teamId: "team-1", + userId: "user-1", + url: "my-status-page", + companyName: "Test Co", + monitors: ["mon-1"], + theme: "modern", + themeMode: "dark", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as StatusPage; + +const createRepo = () => + ({ + create: jest.fn().mockResolvedValue(makeStatusPage()), + findByUrl: jest.fn().mockResolvedValue(makeStatusPage()), + findByTeamId: jest.fn().mockResolvedValue([makeStatusPage()]), + updateById: jest.fn().mockResolvedValue(makeStatusPage()), + deleteById: jest.fn().mockResolvedValue(makeStatusPage()), + removeMonitorFromStatusPages: jest.fn().mockResolvedValue(1), + }) as unknown as jest.Mocked; + +const createSettingsService = (themesEnabled: boolean) => + ({ + areStatusPageThemesEnabled: jest.fn().mockReturnValue(themesEnabled), + }) as unknown as jest.Mocked; + +const createService = (themesEnabled = true) => { + const repo = createRepo(); + const settingsService = createSettingsService(themesEnabled); + const service = new StatusPageService(repo, settingsService); + return { service, repo, settingsService }; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("StatusPageService", () => { + describe("createStatusPage", () => { + it("delegates to repository with all parameters when themes enabled", async () => { + const { service, repo } = createService(true); + const data = { companyName: "New Co" }; + const file = { originalname: "logo.png" } as Express.Multer.File; + + const result = await service.createStatusPage("user-1", "team-1", file, data); + + expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", file, data); + expect(result).toEqual(makeStatusPage()); + }); + + it("passes undefined image when not provided", async () => { + const { service, repo } = createService(true); + + await service.createStatusPage("user-1", "team-1", undefined, {}); + + expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, {}); + }); + + it("strips theme fields from input and applies defaults to result when themes disabled", async () => { + const { service, repo } = createService(false); + const data = { companyName: "New Co", theme: "bold" as const, themeMode: "light" as const }; + + const result = await service.createStatusPage("user-1", "team-1", undefined, data); + + expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, { companyName: "New Co" }); + expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + }); + }); + + describe("getStatusPageByUrl", () => { + it("delegates to repository", async () => { + const { service, repo } = createService(true); + + const result = await service.getStatusPageByUrl("my-status-page"); + + expect(repo.findByUrl).toHaveBeenCalledWith("my-status-page"); + expect(result).toEqual(makeStatusPage()); + }); + + it("applies default theme when themes disabled", async () => { + const { service } = createService(false); + + const result = await service.getStatusPageByUrl("my-status-page"); + + expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + }); + }); + + describe("getStatusPagesByTeamId", () => { + it("delegates to repository", async () => { + const { service, repo } = createService(true); + + const result = await service.getStatusPagesByTeamId("team-1"); + + expect(repo.findByTeamId).toHaveBeenCalledWith("team-1"); + expect(result).toEqual([makeStatusPage()]); + }); + + it("applies default theme to every result when themes disabled", async () => { + const { service, repo } = createService(false); + (repo.findByTeamId as jest.Mock).mockResolvedValue([ + makeStatusPage({ id: "sp-1", theme: "bold" }), + makeStatusPage({ id: "sp-2", theme: "editorial" }), + ]); + + const result = await service.getStatusPagesByTeamId("team-1"); + + expect(result).toHaveLength(2); + for (const sp of result) { + expect(sp.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(sp.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + } + }); + }); + + describe("updateStatusPage", () => { + it("delegates to repository with all parameters", async () => { + const { service, repo } = createService(true); + const updated = makeStatusPage({ companyName: "Updated Co" }); + (repo.updateById as jest.Mock).mockResolvedValue(updated); + const file = { originalname: "new-logo.png" } as Express.Multer.File; + + const result = await service.updateStatusPage("sp-1", "team-1", file, { companyName: "Updated Co" }); + + expect(repo.updateById).toHaveBeenCalledWith("sp-1", "team-1", file, { companyName: "Updated Co" }); + expect(result).toBe(updated); + }); + + it("passes theme and themeMode through to the repository when themes enabled", async () => { + const { service, repo } = createService(true); + const patch = { theme: "modern" as const, themeMode: "dark" as const }; + + await service.updateStatusPage("sp-1", "team-1", undefined, patch); + + expect(repo.updateById).toHaveBeenCalledWith("sp-1", "team-1", undefined, patch); + }); + + it("strips theme fields from input and applies defaults to result when themes disabled", async () => { + const { service, repo } = createService(false); + const patch = { companyName: "Updated Co", theme: "modern" as const, themeMode: "dark" as const }; + + const result = await service.updateStatusPage("sp-1", "team-1", undefined, patch); + + expect(repo.updateById).toHaveBeenCalledWith("sp-1", "team-1", undefined, { companyName: "Updated Co" }); + expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + }); + }); + + describe("deleteStatusPage", () => { + it("delegates to repository and returns deleted page", async () => { + const { service, repo } = createService(true); + + const result = await service.deleteStatusPage("sp-1", "team-1"); + + expect(repo.deleteById).toHaveBeenCalledWith("sp-1", "team-1"); + expect(result).toEqual(makeStatusPage()); + }); + }); +}); diff --git a/server/test/unit/services/statusService.test.ts b/server/test/unit/services/statusService.test.ts new file mode 100644 index 000000000..75323943b --- /dev/null +++ b/server/test/unit/services/statusService.test.ts @@ -0,0 +1,1296 @@ +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"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createBuffer = (): jest.Mocked> => ({ + addToBuffer: jest.fn(), +}); + +const createMonitorsRepo = () => { + const findById = jest.fn(); + const updateStatusWindowAndChecks = jest + .fn() + .mockImplementation( + ( + _id: unknown, + _tid: unknown, + status: boolean, + checkSnapshot: unknown, + windowSize: number, + maxRecentChecks: number, + statusPatch?: Partial + ) => { + // Grab the monitor that findById was configured to resolve with + const monitor = findById.mock.results.at(-1)?.value; + if (monitor && typeof monitor.then === "function") { + return monitor.then((m: any) => { + m.statusWindow = m.statusWindow || []; + m.statusWindow.push(status); + while (m.statusWindow.length > windowSize) { + m.statusWindow.shift(); + } + m.recentChecks = m.recentChecks || []; + m.recentChecks.push(checkSnapshot); + while (m.recentChecks.length > maxRecentChecks) { + m.recentChecks.shift(); + } + if (statusPatch) { + Object.assign(m, statusPatch); + } + return { ...m }; + }); + } + return Promise.reject(new Error("findById not mocked")); + } + ); + return { + findById, + updateById: jest.fn(), + updateStatusWindowAndChecks, + } as unknown as jest.Mocked; +}; + +const createMonitorStatsRepo = () => + ({ + findByMonitorId: jest.fn(), + create: jest.fn(), + updateByMonitorId: jest.fn(), + }) as unknown as jest.Mocked; + +const createChecksRepo = () => ({}) as unknown as jest.Mocked; + +const createService = (overrides?: { + logger?: ReturnType; + buffer?: ReturnType; + monitorsRepository?: ReturnType; + monitorStatsRepository?: ReturnType; + checksRepository?: ReturnType; +}) => { + const logger = overrides?.logger ?? createMockLogger(); + const buffer = overrides?.buffer ?? createBuffer(); + const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo(); + const monitorStatsRepository = overrides?.monitorStatsRepository ?? createMonitorStatsRepo(); + const checksRepository = overrides?.checksRepository ?? createChecksRepo(); + + const service = new StatusService(logger as any, buffer as any, monitorsRepository, monitorStatsRepository, checksRepository); + return { service, logger, buffer, monitorsRepository, monitorStatsRepository, checksRepository }; +}; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + userId: "user-1", + teamId: "team-1", + name: "Test Monitor", + type: "http", + url: "https://example.com", + isActive: true, + interval: 60000, + status: "up", + statusWindow: [], + statusWindowSize: 5, + statusWindowThreshold: 80, + recentChecks: [], + cpuAlertThreshold: 80, + memoryAlertThreshold: 80, + diskAlertThreshold: 80, + tempAlertThreshold: 80, + cpuAlertCounter: 5, + memoryAlertCounter: 5, + diskAlertCounter: 5, + tempAlertCounter: 5, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Monitor; + +const makeStatusResponse = (overrides?: Partial): MonitorStatusResponse => + ({ + monitorId: "mon-1", + teamId: "team-1", + type: "http", + status: true, + code: 200, + message: "OK", + responseTime: 100, + ...overrides, + }) as MonitorStatusResponse; + +const makeCheck = (overrides?: Partial): Check => + ({ + id: "check-1", + metadata: { monitorId: "mon-1", teamId: "team-1" }, + status: true, + responseTime: 100, + statusCode: 200, + message: "OK", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Check; + +const makeExistingStats = (overrides?: Record) => ({ + id: "stats-1", + monitorId: "mon-1", + avgResponseTime: 100, + maxResponseTime: 200, + totalChecks: 10, + totalUpChecks: 9, + totalDownChecks: 1, + uptimePercentage: 0.9, + lastResponseTime: 90, + lastCheckTimestamp: 1000, + timeOfLastFailure: 500, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("StatusService", () => { + describe("serviceName", () => { + it("returns StatusService", () => { + const { service } = createService(); + expect(service.serviceName).toBe("StatusService"); + }); + }); + + // ── updateRunningStats ─────────────────────────────────────────────────── + + describe("updateRunningStats", () => { + // The service now delegates the entire running-stats computation to the repository's + // atomic `updateByMonitorId`, so the service-level tests only verify forwarding and error + // handling. The math (running average, uptimePercentage, timeOfLastFailure state machine) + // is a repository-level concern and is exercised by the repository tests. + + it("forwards a successful check to updateByMonitorId", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50 })); + + expect(result).toBe(true); + expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith( + "mon-1", + expect.objectContaining({ + status: true, + responseTime: 50, + now: expect.any(Number), + }) + ); + }); + + it("forwards a failed check to updateByMonitorId", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ status: false, responseTime: 100 })); + + expect(result).toBe(true); + expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ status: false, responseTime: 100 })); + }); + + it("passes 0 when responseTime is undefined", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: undefined })); + + expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ responseTime: 0 })); + }); + + it("forwards responseTime of 0 as 0 (falsy but defined)", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 0 })); + + expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ responseTime: 0 })); + }); + + it("coerces a non-true status to false (e.g. undefined)", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + await service.updateRunningStats(makeMonitor(), makeStatusResponse({ status: undefined as unknown as boolean })); + + expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ status: false })); + }); + + it("returns false and logs error when updateByMonitorId throws", async () => { + const { service, logger, monitorStatsRepository } = createService(); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue(new Error("db write failed")); + + const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse()); + + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + service: "StatusService", + method: "updateRunningStats", + message: "db write failed", + }) + ); + }); + + it("logs error with 'Unknown error' for non-Error exceptions", async () => { + const { service, logger, monitorStatsRepository } = createService(); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue("string error"); + + const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse()); + + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── updateMonitorStatus ────────────────────────────────────────────────── + + describe("updateMonitorStatus", () => { + it("returns early with no status change when statusWindow is not full", async () => { + const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "up" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck()); + + expect(result.statusChanged).toBe(false); + expect(result.prevStatus).toBe("up"); + }); + + it("pushes to statusWindow and trims to statusWindowSize", async () => { + const monitor = makeMonitor({ statusWindow: [true, true, true, true, true], statusWindowSize: 5 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + + // Atomic push should have been called with the correct status and window size + expect(monitorsRepository.updateStatusWindowAndChecks).toHaveBeenCalledWith( + "mon-1", + "team-1", + false, + expect.any(Object), + 5, + MAX_RECENT_CHECKS, + expect.objectContaining({ status: expect.any(String) }) + ); + }); + + 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); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + await service.updateMonitorStatus(makeStatusResponse(), makeCheck({ 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 () => { + // 5/5 failures = 100% >= 80% threshold + const monitor = makeMonitor({ + statusWindow: [false, false, false, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "up", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("down"); + }); + + it("recovers to up when failure rate drops below threshold", async () => { + // 1/5 failures = 20% < 80% threshold, and monitor was down + const monitor = makeMonitor({ + statusWindow: [true, true, true, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "down", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true })); + + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("up"); + }); + + it("does not change status when already up and below threshold", async () => { + const monitor = makeMonitor({ + statusWindow: [true, true, true, true], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "up", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck()); + + expect(result.statusChanged).toBe(false); + expect(result.monitor.status).toBe("up"); + }); + + it("keeps status 'up' on a sub-threshold failed check (regression: #3438)", async () => { + // Monitor is up, window has one failure already, incoming check fails. + // 2/5 = 40% < 80% threshold — must NOT silently write status='down', + // otherwise the next successful check fires a spurious 'Recovered' notification. + const monitor = makeMonitor({ + statusWindow: [true, true, true, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "up", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + + expect(result.statusChanged).toBe(false); + expect(result.monitor.status).toBe("up"); + }); + + it("does not change status when already down and still above threshold", async () => { + const monitor = makeMonitor({ + statusWindow: [false, false, false, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "down", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + + expect(result.statusChanged).toBe(false); + expect(result.monitor.status).toBe("down"); + }); + + it("initializes statusWindow when undefined", async () => { + const monitor = makeMonitor({ statusWindow: undefined as any, statusWindowSize: 5 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + await service.updateMonitorStatus(makeStatusResponse(), makeCheck()); + + expect(monitor.statusWindow).toEqual([true]); + }); + + it("initializes recentChecks when undefined", async () => { + const monitor = makeMonitor({ recentChecks: undefined as any, statusWindowSize: 5 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + await service.updateMonitorStatus(makeStatusResponse(), makeCheck()); + + expect(monitor.recentChecks).toHaveLength(1); + }); + + it("throws AppError when repository throws", async () => { + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockRejectedValue(new Error("db error")); + + await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck())).rejects.toThrow("Failed to update monitor"); + }); + + it("throws AppError with 'Unknown error' for non-Error exceptions", async () => { + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockRejectedValue("string error"); + + await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck())).rejects.toThrow("Unknown error"); + }); + + it("keeps status 'down' on a single successful check that leaves failure rate at threshold (regression: #3438 mirror)", async () => { + // Monitor is down, window is [false,false,false,false]. A single successful check makes it + // [false,false,false,false,true] → 4/5 = 80%, still at threshold, must NOT silently recover. + // Pre-fix, newStatus was seeded "up" from the raw check and fell through to be persisted, + // causing the next failing check to fire a spurious second 'Down' notification. + const monitor = makeMonitor({ + statusWindow: [false, false, false, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "down", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true })); + + expect(result.statusChanged).toBe(false); + expect(result.monitor.status).toBe("down"); + }); + + it("triggers 'down' transition exactly at the failure threshold boundary", async () => { + // 4/5 = 80%, threshold is 80, comparison is >= so this must trip. + const monitor = makeMonitor({ + statusWindow: [false, false, false, true], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "up", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("down"); + }); + + it("resumes from 'initializing' to 'up' on a passing check with a full window (regression: pause/resume)", async () => { + // When a monitor is paused and resumed, togglePauseById sets status='initializing' + // but does NOT reset statusWindow. On the next check the window is already full, + // so the warmup early-return is skipped. Pre-fix, newStatus was seeded from + // monitor.status ('initializing') and computeReachability never transitioned + // out of 'initializing' on a passing window, so the monitor was stuck forever. + const monitor = makeMonitor({ + statusWindow: [true, true, true, true, true], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "initializing", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true })); + + expect(result.monitor.status).toBe("up"); + }); + + it("resumes from 'initializing' to 'down' on a failing check with a full window (regression: pause/resume mirror)", async () => { + // Mirror of the pause/resume regression with a failing check: should flip to 'down' + // immediately rather than stay stuck in 'initializing'. + const monitor = makeMonitor({ + statusWindow: [false, false, false, false, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "initializing", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + + expect(result.monitor.status).toBe("down"); + }); + + it("during warmup, sub-threshold raw checks no longer flip stored status (regression: down-during-init incident not resolving)", async () => { + // Once the monitor has left 'initializing', the warmup branch must NOT silently + // flip status from raw checks. Otherwise a down check during warmup writes + // status='down' without statusChanged, and the next up check leaks status='up' + // the same way — by the time the window fills, monitor.status is already 'up' + // and computeReachability cannot fire down→up to resolve the incident. + const monitor = makeMonitor({ statusWindow: [true, true], statusWindowSize: 5, status: "up" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + + expect(result.statusChanged).toBe(false); + expect(result.monitor.status).toBe("up"); + expect(monitor.statusWindow).toEqual([true, true, false]); + }); + + it("creates incident on initial down check when statusWindowSize is 1 (regression: alert on initial down skips warmup branch)", async () => { + // statusWindowSize=1 means the very first check skips the warmup branch entirely + // (1 < 1 is false) and lands in the full-window branch. The 'initializing' override + // must still surface statusChanged=true on a down result, otherwise computeReachability + // sees currentStatus='down' and refuses to transition, and no incident is opened. + const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 1, statusWindowThreshold: 60, status: "initializing" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("down"); + expect(result.prevStatus).toBe("initializing"); + }); + + it("creates incident on initial down check for a brand-new monitor (regression: alert on initial down)", async () => { + // Brand-new monitor: status='initializing', empty statusWindow. First check is down. + // Must surface statusChanged=true with monitor.status='down' so the helper opens an incident. + const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "initializing" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("down"); + expect(result.prevStatus).toBe("initializing"); + }); + + it("does not flip status on subsequent checks during warmup once 'initializing' has been left (regression: down→up incident resolves once window fills)", async () => { + // After a 'initializing'→'down' transition on check #1, monitor.status is now 'down' + // and statusWindow has one false. Subsequent up checks during warmup must leave + // monitor.status='down' so the sliding window can detect the down→up transition + // once the window fills. + const monitor = makeMonitor({ statusWindow: [false], statusWindowSize: 5, status: "down" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true })); + + expect(result.statusChanged).toBe(false); + expect(result.monitor.status).toBe("down"); + }); + + it("logs a warning but still succeeds when running-stats update fails mid-flight", async () => { + const monitor = makeMonitor({ statusWindow: [true, true, true, true], statusWindowSize: 5, status: "up" }); + const { service, logger, monitorsRepository, monitorStatsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue(new Error("stats db down")); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck()); + + expect(result.monitor.status).toBe("up"); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + service: "StatusService", + method: "updateMonitorStatus", + message: expect.stringContaining("Stats update failed"), + }) + ); + }); + + it("returns code and timestamp in result", async () => { + const monitor = makeMonitor({ statusWindow: [true, true, true, true], statusWindowSize: 5 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ code: 201 }), makeCheck()); + + expect(result.code).toBe(201); + expect(result.timestamp).toBeGreaterThan(0); + }); + + // ── Hardware threshold breach tests ────────────────────────────────── + + describe("hardware threshold breaches", () => { + const makeHardwareMonitor = (overrides?: Partial) => + makeMonitor({ + type: "hardware", + statusWindow: [true, true, true, true], + statusWindowSize: 5, + status: "up", + cpuAlertThreshold: 80, + memoryAlertThreshold: 80, + diskAlertThreshold: 80, + tempAlertThreshold: 80, + cpuAlertCounter: 5, + memoryAlertCounter: 5, + diskAlertCounter: 5, + tempAlertCounter: 5, + ...overrides, + }); + + const makeHardwareResponse = (payload: HardwareStatusPayload, overrides?: Partial>) => + makeStatusResponse({ + type: "hardware", + status: true, + payload, + ...overrides, + } as any) as MonitorStatusResponse; + + it("detects CPU threshold breach and decrements counter", async () => { + const monitor = makeHardwareMonitor({ cpuAlertCounter: 1 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.5 }, disk: [], host: {} } } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.cpu).toBe(true); + expect(result.thresholdBreaches?.memory).toBe(false); + const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; + expect(patch.cpuAlertCounter).toBe(0); + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("breached"); + }); + + it("detects memory threshold breach", async () => { + const monitor = makeHardwareMonitor({ memoryAlertCounter: 1 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.5 }, memory: { usage_percent: 0.9 }, disk: [], host: {} } } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.memory).toBe(true); + const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; + expect(patch.memoryAlertCounter).toBe(0); + expect(result.monitor.status).toBe("breached"); + }); + + it("detects disk threshold breach", async () => { + const monitor = makeHardwareMonitor({ diskAlertCounter: 1 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.95 }], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.disk).toBe(true); + const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; + expect(patch.diskAlertCounter).toBe(0); + expect(result.monitor.status).toBe("breached"); + }); + + it("detects temperature threshold breach", async () => { + const monitor = makeHardwareMonitor({ tempAlertCounter: 1 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1, temperature: [90] }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.temp).toBe(true); + const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; + expect(patch.tempAlertCounter).toBe(0); + expect(result.monitor.status).toBe("breached"); + }); + + it("resets counters to 5 when thresholds are not breached", async () => { + const monitor = makeHardwareMonitor({ + cpuAlertCounter: 2, + memoryAlertCounter: 2, + diskAlertCounter: 2, + tempAlertCounter: 2, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} }, + } as any); + await service.updateMonitorStatus(response, makeCheck()); + + const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; + expect(patch.cpuAlertCounter).toBe(5); + expect(patch.memoryAlertCounter).toBe(5); + expect(patch.diskAlertCounter).toBe(5); + expect(patch.tempAlertCounter).toBe(5); + }); + + it("stays breached without statusChanged when already breached and counter still at 0", async () => { + const monitor = makeHardwareMonitor({ + status: "breached", + cpuAlertCounter: 1, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.monitor.status).toBe("breached"); + expect(result.statusChanged).toBe(false); + }); + + it("recovers from breached to up when all thresholds return to normal", async () => { + const monitor = makeHardwareMonitor({ status: "breached" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("up"); + }); + + it("does not override down status with breached", async () => { + // Monitor is down due to failure threshold, hardware breaches should not override + const monitor = makeHardwareMonitor({ + statusWindow: [false, false, false, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "up", + cpuAlertCounter: 1, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + status: false, + data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck({ status: false })); + + expect(result.monitor.status).toBe("down"); + }); + + it("handles missing cpu usage_percent (returns -1)", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ data: { cpu: {}, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.cpu).toBe(false); + }); + + it("handles missing memory usage_percent", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1 }, memory: {}, disk: [], host: {} } } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.memory).toBe(false); + }); + + it("handles empty temperature array", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1, temperature: [] }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.temp).toBe(false); + }); + + it("handles undefined temperature", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.temp).toBe(false); + }); + + it("skips threshold evaluation when payload is undefined", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse(undefined as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches).toBeUndefined(); + }); + + it("skips threshold evaluation when payload.data is undefined", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({} as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches).toBeUndefined(); + }); + + it("does not set thresholdBreaches for non-hardware monitors", async () => { + const monitor = makeMonitor({ + type: "http", + statusWindow: [true, true, true, true], + statusWindowSize: 5, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse(), makeCheck()); + + expect(result.thresholdBreaches).toBeUndefined(); + }); + + it("handles null entry in disk array", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [null as any], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.disk).toBe(false); + }); + + it("handles disk with undefined usage_percent entries", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ device: "/dev/sda" }], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.disk).toBe(false); + }); + + it("handles nullish cpu in metrics", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: undefined, memory: { usage_percent: 0.1 }, disk: [], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.cpu).toBe(false); + expect(result.thresholdBreaches?.temp).toBe(false); + }); + + it("handles nullish memory in metrics", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1 }, memory: undefined, disk: [], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.memory).toBe(false); + }); + + it("handles nullish disk in metrics", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: undefined, host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.disk).toBe(false); + }); + + it("decrements breach counter without changing status while counter > 0", async () => { + // Counter at 3 → decrements to 2, status must stay 'up' and statusChanged must be false. + // Only counter→0 may flip to 'breached'. + const monitor = makeHardwareMonitor({ cpuAlertCounter: 3 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + const patch = (monitorsRepository.updateStatusWindowAndChecks as jest.Mock).mock.calls.at(-1)?.[6]; + expect(patch.cpuAlertCounter).toBe(2); + expect(result.thresholdBreaches?.cpu).toBe(true); + expect(result.statusChanged).toBe(false); + expect(result.monitor.status).toBe("up"); + }); + + it("transitions from 'breached' to 'down' when the reachability threshold trips", async () => { + // A hardware monitor currently in 'breached' state starts failing its reachability + // checks enough to cross the statusWindow threshold — 'down' must take precedence. + const monitor = makeHardwareMonitor({ + status: "breached", + statusWindow: [false, false, false, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + status: false, + data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck({ status: false })); + + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("down"); + }); + + it("counters do not go below 0", async () => { + const monitor = makeHardwareMonitor({ + cpuAlertCounter: 0, + memoryAlertCounter: 0, + diskAlertCounter: 0, + tempAlertCounter: 0, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.9, temperature: [90] }, memory: { usage_percent: 0.9 }, disk: [{ usage_percent: 0.95 }], host: {} }, + } as any); + await service.updateMonitorStatus(response, makeCheck()); + + expect(monitor.cpuAlertCounter).toBe(0); + expect(monitor.memoryAlertCounter).toBe(0); + expect(monitor.diskAlertCounter).toBe(0); + expect(monitor.tempAlertCounter).toBe(0); + }); + }); + }); + + // ── Pure helpers ───────────────────────────────────────────────────────── + // These test the private state-machine helpers directly, without repository + // or logger mocks. The helpers are accessed via `service as any` because + // they're private implementation details; exposing them publicly would + // widen the API surface just for testability. + + describe("computeReachability (pure)", () => { + const reach = (currentStatus: MonitorStatus, window: boolean[], threshold = 80) => { + const { service } = createService(); + return (service as any).computeReachability(currentStatus, window, threshold) as { + nextStatus: "up" | "down"; + transitioned: boolean; + }; + }; + + // ── Monitor is "up" ── + it("up + window below threshold → no transition", () => { + expect(reach("up", [true, true, true, true, true])).toEqual({ nextStatus: "up", transitioned: false }); + }); + + it("up + window exactly at threshold → transitions to down (>= boundary)", () => { + // 4/5 = 80% and threshold is 80, >= so this must trip + expect(reach("up", [false, false, false, false, true])).toEqual({ nextStatus: "down", transitioned: true }); + }); + + it("up + window over threshold → transitions to down", () => { + expect(reach("up", [false, false, false, false, false])).toEqual({ nextStatus: "down", transitioned: true }); + }); + + // ── Monitor is "down" ── + it("down + window below threshold → transitions to up (recovery)", () => { + // 0/5 failures, fully healthy window + expect(reach("down", [true, true, true, true, true])).toEqual({ nextStatus: "up", transitioned: true }); + }); + + it("down + window exactly at threshold → no transition (strict < for recovery)", () => { + // 4/5 = 80%; recovery requires failureRate < threshold, not <=, so stays down + const result = reach("down", [false, false, false, false, true]); + expect(result.transitioned).toBe(false); + // Orchestrator ignores nextStatus when !transitioned, so the returned value + // is irrelevant to observable behavior; documenting here for clarity. + }); + + it("down + window over threshold → no transition", () => { + const result = reach("down", [false, false, false, false, false]); + expect(result.transitioned).toBe(false); + }); + + // ── Monitor is "breached" (hardware state, passes through reachability) ── + it("breached + healthy window → no transition (reachability does not touch breached)", () => { + // Critical: reachability must NOT claim to recover a breached monitor. That + // transition is owned by the hardware block (all thresholds back to normal). + const result = reach("breached", [true, true, true, true, true]); + expect(result.transitioned).toBe(false); + }); + + it("breached + window at threshold → transitions to down (down beats breached)", () => { + // A breached monitor that also loses reachability must flip to "down". + // Current status is "breached" which satisfies `!== "down"`, so the branch fires. + expect(reach("breached", [false, false, false, false, true])).toEqual({ nextStatus: "down", transitioned: true }); + }); + + it("breached + window over threshold → transitions to down", () => { + expect(reach("breached", [false, false, false, false, false])).toEqual({ nextStatus: "down", transitioned: true }); + }); + + // ── Threshold variations ── + it("non-default threshold (50%) trips at 3/5 failures", () => { + expect(reach("up", [false, false, false, true, true], 50)).toEqual({ nextStatus: "down", transitioned: true }); + }); + + it("non-default threshold (50%) does not trip at 2/5 failures", () => { + expect(reach("up", [false, false, true, true, true], 50).transitioned).toBe(false); + }); + }); + + describe("computeHardwareStatus (pure)", () => { + type Params = { + currentStatus: MonitorStatus; + reachabilityDown: boolean; + metrics: any; + thresholds: { cpu: number; memory: number; disk: number; temp: number }; + counters: { cpu: number; memory: number; disk: number; temp: number }; + }; + + const healthyMetrics = () => ({ + cpu: { usage_percent: 0.1, temperature: [30] }, + memory: { usage_percent: 0.1 }, + disk: [{ usage_percent: 0.1 }], + host: {}, + }); + + const defaults = (): Params => ({ + currentStatus: "up", + reachabilityDown: false, + metrics: healthyMetrics(), + thresholds: { cpu: 80, memory: 80, disk: 80, temp: 80 }, + counters: { cpu: 5, memory: 5, disk: 5, temp: 5 }, + }); + + const compute = (overrides: Partial = {}) => { + const { service } = createService(); + return (service as any).computeHardwareStatus({ ...defaults(), ...overrides }); + }; + + // ── Breach detection ── + it("detects CPU breach when usage > threshold", () => { + const r = compute({ metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.9, temperature: [30] } } }); + expect(r.breaches.cpu).toBe(true); + }); + + it("does not flag CPU at exactly the threshold (strict >, not >=)", () => { + const r = compute({ metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.8, temperature: [30] } } }); + expect(r.breaches.cpu).toBe(false); + }); + + it("does not flag CPU when usage_percent is missing", () => { + const r = compute({ metrics: { ...healthyMetrics(), cpu: { temperature: [30] } } }); + expect(r.breaches.cpu).toBe(false); + }); + + it("detects memory breach", () => { + const r = compute({ metrics: { ...healthyMetrics(), memory: { usage_percent: 0.9 } } }); + expect(r.breaches.memory).toBe(true); + }); + + it("detects disk breach when any disk exceeds threshold", () => { + const r = compute({ metrics: { ...healthyMetrics(), disk: [{ usage_percent: 0.1 }, { usage_percent: 0.95 }] } }); + expect(r.breaches.disk).toBe(true); + }); + + it("skips null disk entries", () => { + const r = compute({ metrics: { ...healthyMetrics(), disk: [null, { usage_percent: 0.1 }] } }); + expect(r.breaches.disk).toBe(false); + }); + + it("skips disk entries missing usage_percent", () => { + const r = compute({ metrics: { ...healthyMetrics(), disk: [{ device: "/dev/sda" }] } }); + expect(r.breaches.disk).toBe(false); + }); + + it("detects temperature breach when any core exceeds threshold", () => { + const r = compute({ metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.1, temperature: [30, 50, 90] } } }); + expect(r.breaches.temp).toBe(true); + }); + + it("empty temperature array → no temp breach", () => { + const r = compute({ metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.1, temperature: [] } } }); + expect(r.breaches.temp).toBe(false); + }); + + it("missing temperature → no temp breach", () => { + const r = compute({ metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.1 } } }); + expect(r.breaches.temp).toBe(false); + }); + + it("detects multiple simultaneous breaches", () => { + const r = compute({ + metrics: { + cpu: { usage_percent: 0.9, temperature: [90] }, + memory: { usage_percent: 0.9 }, + disk: [{ usage_percent: 0.95 }], + host: {}, + }, + }); + expect(r.breaches).toEqual({ cpu: true, memory: true, disk: true, temp: true }); + }); + + // ── Counter mechanics ── + it("decrements counter on breach", () => { + const r = compute({ + metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.9, temperature: [30] } }, + counters: { cpu: 3, memory: 5, disk: 5, temp: 5 }, + }); + expect(r.nextCounters.cpu).toBe(2); + }); + + it("resets counter to start value when metric returns to normal", () => { + const r = compute({ counters: { cpu: 2, memory: 2, disk: 2, temp: 2 } }); + expect(r.nextCounters).toEqual({ cpu: 5, memory: 5, disk: 5, temp: 5 }); + }); + + it("counter floors at zero (does not go negative)", () => { + const r = compute({ + metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.9, temperature: [30] } }, + counters: { cpu: 0, memory: 5, disk: 5, temp: 5 }, + }); + expect(r.nextCounters.cpu).toBe(0); + }); + + it("counters are independent per metric", () => { + // CPU breaching, memory normal: CPU decrements, memory resets. + const r = compute({ + metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.9, temperature: [30] } }, + counters: { cpu: 3, memory: 2, disk: 2, temp: 2 }, + }); + expect(r.nextCounters).toEqual({ cpu: 2, memory: 5, disk: 5, temp: 5 }); + }); + + // ── Status transitions ── + it("up → breached when counter hits zero on breach", () => { + const r = compute({ + currentStatus: "up", + metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.9, temperature: [30] } }, + counters: { cpu: 1, memory: 5, disk: 5, temp: 5 }, + }); + expect(r.nextStatus).toBe("breached"); + expect(r.transitioned).toBe(true); + }); + + it("up → up (no transition) while counter is still decrementing", () => { + const r = compute({ + currentStatus: "up", + metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.9, temperature: [30] } }, + counters: { cpu: 3, memory: 5, disk: 5, temp: 5 }, + }); + expect(r.nextStatus).toBe("up"); + expect(r.transitioned).toBe(false); + }); + + it("breached → breached (no transition) when already breached and still breaching", () => { + const r = compute({ + currentStatus: "breached", + metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.9, temperature: [30] } }, + counters: { cpu: 0, memory: 5, disk: 5, temp: 5 }, + }); + expect(r.transitioned).toBe(false); + // nextStatus stays at currentStatus ("breached") since no transition fires + expect(r.nextStatus).toBe("breached"); + }); + + it("breached → up when all metrics return to normal", () => { + const r = compute({ currentStatus: "breached" }); + expect(r.nextStatus).toBe("up"); + expect(r.transitioned).toBe(true); + }); + + it("any one counter at zero is enough to trigger initial breach", () => { + // memory counter at 1, memory currently breaching, cpu fine + const r = compute({ + currentStatus: "up", + metrics: { ...healthyMetrics(), memory: { usage_percent: 0.9 } }, + counters: { cpu: 5, memory: 1, disk: 5, temp: 5 }, + }); + expect(r.nextStatus).toBe("breached"); + expect(r.transitioned).toBe(true); + }); + + // ── Reachability-down gate ── + it("reachabilityDown blocks status transition even when counter hits zero", () => { + const r = compute({ + currentStatus: "up", + reachabilityDown: true, + metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.9, temperature: [30] } }, + counters: { cpu: 1, memory: 5, disk: 5, temp: 5 }, + }); + expect(r.transitioned).toBe(false); + expect(r.nextStatus).toBe("up"); // stays at currentStatus + }); + + it("reachabilityDown still updates counters (the gate is only around status, not metrics math)", () => { + const r = compute({ + reachabilityDown: true, + metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.9, temperature: [30] } }, + counters: { cpu: 3, memory: 5, disk: 5, temp: 5 }, + }); + expect(r.nextCounters.cpu).toBe(2); + expect(r.breaches.cpu).toBe(true); + }); + + it("reachabilityDown blocks recovery from breached", () => { + // Even with all metrics normal, if reachability says down, we can't recover. + const r = compute({ currentStatus: "breached", reachabilityDown: true }); + expect(r.transitioned).toBe(false); + expect(r.nextStatus).toBe("breached"); + }); + + // ── Percentage vs raw-degree thresholds ── + it("CPU/memory/disk thresholds are percentages (compared against usage * 100)", () => { + // threshold 50 means 50%, usage 0.6 = 60% → breach + const r = compute({ + metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.6, temperature: [30] } }, + thresholds: { cpu: 50, memory: 80, disk: 80, temp: 80 }, + }); + expect(r.breaches.cpu).toBe(true); + }); + + it("temperature threshold is raw degrees (no /100 scaling)", () => { + // threshold 75, temp 80 → breach + const r = compute({ + metrics: { ...healthyMetrics(), cpu: { usage_percent: 0.1, temperature: [80] } }, + thresholds: { cpu: 80, memory: 80, disk: 80, temp: 75 }, + }); + expect(r.breaches.temp).toBe(true); + }); + }); +}); diff --git a/server/test/unit/services/superSimpleQueue.test.ts b/server/test/unit/services/superSimpleQueue.test.ts new file mode 100644 index 000000000..685dd71c7 --- /dev/null +++ b/server/test/unit/services/superSimpleQueue.test.ts @@ -0,0 +1,672 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { Monitor } from "../../../src/types/monitor.ts"; + +// ── Mock Scheduler ─────────────────────────────────────────────────────────── + +const createScheduler = () => { + const listeners: Record = {}; + return { + on: jest.fn((event: string, cb: Function) => { + listeners[event] = listeners[event] || []; + listeners[event].push(cb); + }), + emit: (event: string, ...args: unknown[]) => { + (listeners[event] || []).forEach((cb) => cb(...args)); + }, + start: jest.fn(), + stop: jest.fn().mockResolvedValue(true), + addTemplate: jest.fn(), + addJob: jest.fn(), + removeJob: jest.fn(), + getJob: jest.fn().mockResolvedValue(null), + getJobs: jest.fn().mockResolvedValue([]), + 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); + +jest.unstable_mockModule("super-simple-scheduler", () => ({ + default: MockScheduler, +})); + +const { SuperSimpleQueue } = await import("../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.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 createQueue = (overrides?: Record) => { + const logger = createMockLogger(); + const helper = createQueueHelper(); + const monitorsRepository = createMonitorsRepo(); + const scheduler = createScheduler(); + + const defaults = { logger, helper, monitorsRepository, scheduler, ...overrides }; + + const queue = new SuperSimpleQueue(defaults.logger as any, defaults.helper as any, defaults.monitorsRepository as any, defaults.scheduler 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("SuperSimpleQueue", () => { + describe("serviceName", () => { + it("returns JobQueue", () => { + const { queue } = createQueue(); + expect(queue.serviceName).toBe("JobQueue"); + }); + }); + + // ── init ───────────────────────────────────────────────────────────────── + + describe("init", () => { + it("registers listeners, starts scheduler, adds templates and system jobs", async () => { + const { queue, scheduler, helper } = 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(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" })); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" })); + }); + + it("schedules existing monitors with staggered offsets", async () => { + jest.useFakeTimers(); + const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })]; + const { queue, scheduler, monitorsRepository } = createQueue(); + (monitorsRepository.findAll as jest.Mock).mockResolvedValue(monitors); + + await queue.init(); + jest.runAllTimers(); + + // Each monitor gets addJob called (once for the monitor itself via addJob method) + // The init schedules via setTimeout → addJob, plus the two system jobs + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m1", template: "monitor-job" })); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m2", template: "monitor-job" })); + jest.useRealTimers(); + }); + + it("returns true 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); + // System jobs should not be added when monitors is null (early return) + 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") })); + }); + }); + + // ── registerListeners ──────────────────────────────────────────────────── + + describe("registerListeners (via init)", () => { + it("logs on scheduler:start", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("scheduler:start"); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler started" })); + }); + + it("logs on scheduler:stop", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("scheduler:stop"); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler stopped" })); + }); + + it("logs on scheduler:error with Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("scheduler:error", new Error("boom")); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler error: boom" })); + }); + + it("logs on scheduler:error with non-Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("scheduler:error", "string error"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler error: string error", stack: undefined })); + }); + + it("logs on job:abort", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:abort", { id: "m1" }, "timeout"); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 aborted: timeout" })); + }); + + it("logs on job:attempt", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:attempt", { id: "m1" }, 2); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 attempt 2" })); + }); + + it("logs on job:complete", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:complete", { id: "m1" }); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 completed successfully" })); + }); + + it("logs on job:exhausted with Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:exhausted", { id: "m1" }, new Error("gave up")); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 exhausted all retries: gave up" })); + }); + + it("logs on job:exhausted with non-Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:exhausted", { id: "m1" }, "gave up"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 exhausted all retries: gave up", stack: undefined })); + }); + + it("logs on job:fail with Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:fail", { id: "m1" }, new Error("oops"), 1); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 failed on attempt 1: oops" })); + }); + + it("logs on job:fail with non-Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:fail", { id: "m1" }, "oops", 1); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 failed on attempt 1: oops", stack: undefined })); + }); + + it("logs on job:start", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:start", { id: "m1" }); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 started" })); + }); + }); + + // ── addJob ─────────────────────────────────────────────────────────────── + + describe("addJob", () => { + it("adds monitor job to scheduler", async () => { + const { queue, scheduler } = createQueue(); + await queue.addJob("mon-1", makeMonitor()); + expect(scheduler.addJob).toHaveBeenCalledWith(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.addJob).toHaveBeenCalledWith(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.addJob).not.toHaveBeenCalledWith(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.addJob).not.toHaveBeenCalledWith(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 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.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("refreshes scheduler data with current monitor on pause", async () => { + const { queue, scheduler } = createQueue(); + (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("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 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("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 ──────────────────────────────────────────────────────────── + + describe("updateJob", () => { + it("updates monitor job and syncs geo job", 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 })); + }); + }); + + // ── syncGeoJob (via updateJob) ─────────────────────────────────────────── + + describe("syncGeoJob (via updateJob)", () => { + 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("does not remove when geo disabled and no existing geo job", async () => { + const { queue, scheduler } = createQueue(); + await queue.updateJob(makeMonitor({ geoCheckEnabled: false })); + expect(scheduler.removeJob).not.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 }); + }); + + it("aggregates metrics from jobs", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", runCount: 10, failCount: 2, lastFailedAt: 200, lastRunAt: 100, lockedAt: null, data: { url: "http://a.com", type: "http" } }, + { id: "m2", runCount: 5, failCount: 0, lastFailedAt: 0, lastRunAt: 50, lockedAt: 999, data: { url: "http://b.com", type: "ping" } }, + ]); + const metrics = await queue.getMetrics(); + + expect(metrics.jobs).toBe(2); + expect(metrics.totalRuns).toBe(15); + expect(metrics.totalFailures).toBe(2); + expect(metrics.activeJobs).toBe(1); + expect(metrics.failingJobs).toBe(1); + expect(metrics.jobsWithFailures).toHaveLength(1); + expect(metrics.jobsWithFailures[0]).toEqual(expect.objectContaining({ monitorId: "m1", failCount: 2, monitorUrl: "http://a.com" })); + }); + + it("handles jobs with undefined fields", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", data: null }]); + const metrics = await queue.getMetrics(); + expect(metrics.jobs).toBe(1); + expect(metrics.totalRuns).toBe(0); + }); + + it("handles failing job with null data", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", runCount: 1, failCount: 1, lastFailedAt: 100, lastRunAt: 50, data: null, lastFailReason: "timeout" }, + ]); + const metrics = await queue.getMetrics(); + expect(metrics.jobsWithFailures).toHaveLength(1); + expect(metrics.jobsWithFailures[0].monitorUrl).toBeNull(); + expect(metrics.jobsWithFailures[0].monitorType).toBeNull(); + expect(metrics.jobsWithFailures[0].failReason).toBe("timeout"); + }); + + it("handles failing job with undefined lastFailedAt and lastFailReason", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", runCount: 1, failCount: 1, data: { url: "http://a.com", type: "http" } }]); + const metrics = await queue.getMetrics(); + expect(metrics.jobsWithFailures[0].failedAt).toBeNull(); + expect(metrics.jobsWithFailures[0].failReason).toBeNull(); + }); + + it("does not count as failing when lastFailedAt < lastRunAt", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", runCount: 10, failCount: 1, lastFailedAt: 50, lastRunAt: 100, data: { url: "a" } }, + ]); + const metrics = await queue.getMetrics(); + expect(metrics.failingJobs).toBe(0); + expect(metrics.jobsWithFailures).toHaveLength(1); + }); + }); + + // ── getJobs ────────────────────────────────────────────────────────────── + + describe("getJobs", () => { + it("maps scheduler jobs to summaries", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { + id: "m1", + active: true, + repeat: 60000, + lockedAt: null, + runCount: 5, + failCount: 1, + lastFailReason: "timeout", + lastRunAt: 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, + 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", active: true, lockedAt: 999, runCount: 1, data: { url: "a", type: "http", interval: 60000 } }, + ]); + 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(); + 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); + }); + }); + + // ── 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("returns false when any step 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", () => { + it("creates a SuperSimpleQueue instance with a Scheduler and calls init", async () => { + const logger = createMockLogger(); + const helper = createQueueHelper(); + const monitorsRepository = createMonitorsRepo(); + + const instance = await SuperSimpleQueue.create(logger as any, helper as any, monitorsRepository as any); + + expect(MockScheduler).toHaveBeenCalled(); + expect(instance).toBeInstanceOf(SuperSimpleQueue); + // init was called, which calls scheduler.start + expect(mockSchedulerInstance.start).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/unit/services/superSimpleQueueHelper.test.ts b/server/test/unit/services/superSimpleQueueHelper.test.ts new file mode 100644 index 000000000..4d8ecfbc0 --- /dev/null +++ b/server/test/unit/services/superSimpleQueueHelper.test.ts @@ -0,0 +1,718 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { SuperSimpleQueueHelper } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import type { Monitor } from "../../../src/types/monitor.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createHelper = (overrides?: Record) => { + const maintenanceWindowsRepository = { + findByMonitorId: jest.fn().mockResolvedValue([]), + }; + const monitorsRepository = { + updateById: jest.fn().mockResolvedValue({}), + findAllMonitorIds: jest.fn().mockResolvedValue(["m1"]), + deleteByTeamIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const teamsRepository = { + findAllTeamIds: jest.fn().mockResolvedValue(["team"]), + }; + const monitorStatsRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const checksRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const incidentsRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const geoChecksRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const statusServiceMock = { + updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }), + }; + const settingsServiceMock = { + getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }), + }; + const geoChecksServiceMock = { + buildGeoCheck: jest.fn().mockResolvedValue(null), + }; + + const defaults = { + logger: createMockLogger(), + networkService: { requestStatus: jest.fn() }, + statusService: statusServiceMock, + notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) }, + checkService: { buildCheck: jest.fn().mockReturnValue({ id: "check-1" }), deleteOlderThan: jest.fn().mockResolvedValue(0) }, + settingsService: settingsServiceMock, + buffer: { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn() }, + incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) }, + maintenanceWindowsRepository, + monitorsRepository, + teamsRepository, + monitorStatsRepository, + checksRepository, + incidentsRepository, + geoChecksService: geoChecksServiceMock, + geoChecksRepository, + ...overrides, + }; + + const helper = new SuperSimpleQueueHelper( + defaults.logger as any, + defaults.networkService as any, + defaults.statusService as any, + defaults.notificationsService as any, + defaults.checkService as any, + defaults.settingsService as any, + defaults.buffer as any, + defaults.incidentService as any, + defaults.maintenanceWindowsRepository as any, + defaults.monitorsRepository as any, + defaults.teamsRepository as any, + defaults.monitorStatsRepository as any, + defaults.checksRepository as any, + defaults.incidentsRepository as any, + defaults.geoChecksService as any, + defaults.geoChecksRepository as any + ); + return { helper, defaults, maintenanceWindowsRepository }; +}; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "m1", + teamId: "team", + type: "http", + interval: 60000, + status: "up", + geoCheckEnabled: false, + geoCheckLocations: [], + ...overrides, + }) as Monitor; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("SuperSimpleQueueHelper", () => { + describe("serviceName", () => { + it("returns JobQueueHelper", () => { + const { helper } = createHelper(); + expect(helper.serviceName).toBe("JobQueueHelper"); + }); + }); + + // ── getHeartbeatJob ────────────────────────────────────────────────────── + + describe("getHeartbeatJob", () => { + it("throws when monitor id is missing", async () => { + const { helper } = createHelper(); + const job = helper.getHeartbeatJob(); + await expect(job({} as Monitor)).rejects.toThrow("No monitor id"); + }); + + it("skips execution and sets maintenance status when in maintenance window", async () => { + const { helper, defaults } = createHelper(); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor({ status: "up" })); + + expect(defaults.networkService.requestStatus).not.toHaveBeenCalled(); + expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" }); + }); + + it("skips monitor status update when already in maintenance", async () => { + const { helper, defaults } = createHelper(); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor({ status: "maintenance" })); + + expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled(); + }); + + it("throws when network response is null", async () => { + const { helper } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(null) }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await expect(job(makeMonitor())).rejects.toThrow("No network response"); + }); + + it("returns early and warns when buildCheck returns null", async () => { + const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + checkService: { buildCheck: jest.fn().mockReturnValue(null), deleteOlderThan: jest.fn() }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") })); + expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled(); + }); + + it("processes full pipeline: network → check → status → incident", async () => { + const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + + expect(defaults.buffer.addToBuffer).toHaveBeenCalled(); + expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled(); + expect(defaults.incidentService.handleIncident).toHaveBeenCalled(); + }); + + it("sends notifications when decision says shouldSendNotification", async () => { + const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" }; + const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + + expect(defaults.notificationsService.handleNotifications).toHaveBeenCalledWith( + statusResult.monitor, + networkResponse, + expect.objectContaining({ shouldSendNotification: true, shouldCreateIncident: true }) + ); + }); + + it("logs error when notifications fail (fire-and-forget)", async () => { + const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" }; + const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; + const notificationsService = { handleNotifications: jest.fn().mockRejectedValue(new Error("smtp down")) }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, + notificationsService, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + + // Wait for the fire-and-forget catch to execute + await new Promise((r) => setTimeout(r, 10)); + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("smtp down") })); + }); + + it("logs error when notifications fail with non-Error (fire-and-forget)", async () => { + const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" }; + const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; + const notificationsService = { handleNotifications: jest.fn().mockRejectedValue("string error") }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, + notificationsService, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + await new Promise((r) => setTimeout(r, 10)); + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") })); + }); + + it("logs warning when incident handling fails (fire-and-forget)", async () => { + const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; + const incidentService = { handleIncident: jest.fn().mockRejectedValue(new Error("db error")) }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + incidentService, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + await new Promise((r) => setTimeout(r, 10)); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db error") })); + }); + + it("logs warning when incident handling fails with non-Error", async () => { + const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; + const incidentService = { handleIncident: jest.fn().mockRejectedValue(42) }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + incidentService, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + await new Promise((r) => setTimeout(r, 10)); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") })); + }); + + it("logs and rethrows on unexpected error with non-Error", async () => { + const { helper } = createHelper({ + networkService: { requestStatus: jest.fn().mockRejectedValue("unexpected") }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await expect(job(makeMonitor())).rejects.toBe("unexpected"); + }); + }); + + // ── getHeartbeatGeoJob ─────────────────────────────────────────────────── + + describe("getHeartbeatGeoJob", () => { + it("throws nothing when monitor id is missing (logs error)", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getHeartbeatGeoJob(); + + await job({} as Monitor); + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "No monitor id" })); + }); + + it("returns early when geoCheckEnabled is false", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: false })); + + expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); + }); + + it("returns early when monitor type does not support geo checks", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "hardware", geoCheckLocations: ["us-east"] })); + + expect(defaults.logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("does not support geo checks") }) + ); + expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); + }); + + it("returns early when geoCheckLocations is empty", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: [] })); + + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") })); + }); + + it("returns early when geoCheckLocations is undefined", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: undefined as any })); + + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") })); + }); + + it("skips when in maintenance window", async () => { + const { helper, defaults } = createHelper(); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); + + expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("maintenance window") })); + expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); + }); + + it("warns when buildGeoCheck returns null", async () => { + const { helper, defaults } = createHelper(); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); + + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check could be built") })); + }); + + it("adds geo check to buffer on success", async () => { + const geoCheck = { id: "gc-1", monitorId: "m1" }; + const { helper, defaults } = createHelper({ + geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(geoCheck) }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); + + expect(defaults.buffer.addGeoCheckToBuffer).toHaveBeenCalledWith(geoCheck); + expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Geo check job executed") })); + }); + + it("logs error on unexpected failure without rethrowing", async () => { + const { helper, defaults } = createHelper({ + geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue(new Error("api timeout")) }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "api timeout" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { helper, defaults } = createHelper({ + geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue("string error") }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── getCleanupOrphanedJob ──────────────────────────────────────────────── + + describe("getCleanupOrphanedJob", () => { + it("cleans up orphaned data across all repositories", async () => { + const { helper, defaults } = createHelper(); + (defaults.monitorsRepository.deleteByTeamIdsNotIn as jest.Mock).mockResolvedValue(2); + (defaults.monitorStatsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(3); + (defaults.checksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(4); + (defaults.incidentsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(1); + (defaults.geoChecksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(5); + + const job = helper.getCleanupOrphanedJob(); + await job(); + + expect(defaults.teamsRepository.findAllTeamIds).toHaveBeenCalled(); + expect(defaults.monitorsRepository.deleteByTeamIdsNotIn).toHaveBeenCalledWith(["team"]); + expect(defaults.monitorsRepository.findAllMonitorIds).toHaveBeenCalled(); + expect(defaults.monitorStatsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + // Logs info for each deleted count > 0 + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 orphaned incidents") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("5 orphaned geo checks") })); + }); + + it("skips info logs when no orphaned data is found", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getCleanupOrphanedJob(); + await job(); + + // Only start and completion logs, no deletion logs + const infoMessages = (defaults.logger.info as jest.Mock).mock.calls.map((c: any) => c[0].message); + expect(infoMessages).toContain("Starting cleanup of orphaned data"); + expect(infoMessages).toContain("Cleanup of orphaned data completed"); + expect(infoMessages).not.toContain(expect.stringContaining("orphaned monitors")); + }); + + it("logs warning and rethrows on error", async () => { + const { helper, defaults } = createHelper(); + (defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(new Error("db down")); + const job = helper.getCleanupOrphanedJob(); + + await expect(job()).rejects.toThrow("db down"); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "db down" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { helper, defaults } = createHelper(); + (defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(null); + const job = helper.getCleanupOrphanedJob(); + + await expect(job()).rejects.toBeNull(); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── getCleanupRetentionJob ─────────────────────────────────────────────── + + describe("getCleanupRetentionJob", () => { + it("deletes checks older than TTL cutoff", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getCleanupRetentionJob(); + await job(); + + expect(defaults.checkService.deleteOlderThan).toHaveBeenCalledWith(expect.any(Date)); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Deleted") })); + }); + + it("skips cleanup when checkTTL is sentinel value (366)", async () => { + const { helper, defaults } = createHelper({ + settingsService: { getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 366 }) }, + }); + const job = helper.getCleanupRetentionJob(); + await job(); + + expect(defaults.checkService.deleteOlderThan).not.toHaveBeenCalled(); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("unlimited") })); + }); + + it("logs error on failure without rethrowing", async () => { + const { helper, defaults } = createHelper({ + settingsService: { getDBSettings: jest.fn().mockRejectedValue(new Error("db error")) }, + }); + const job = helper.getCleanupRetentionJob(); + + await job(); // Should not throw + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "db error" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { helper, defaults } = createHelper({ + settingsService: { getDBSettings: jest.fn().mockRejectedValue("boom") }, + }); + const job = helper.getCleanupRetentionJob(); + await job(); + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── isInMaintenanceWindow ──────────────────────────────────────────────── + + describe("isInMaintenanceWindow", () => { + it("returns true when an active window spans now", async () => { + const now = new Date(); + const { helper, maintenanceWindowsRepository } = createHelper(); + maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ + { + active: true, + start: new Date(now.getTime() - 1000).toISOString(), + end: new Date(now.getTime() + 1000).toISOString(), + repeat: 0, + }, + ]); + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); + }); + + it("returns true when repeat interval advances window into current time", async () => { + const now = Date.now(); + const { helper, maintenanceWindowsRepository } = createHelper(); + maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ + { + active: true, + start: new Date(now - 7200000).toISOString(), + end: new Date(now - 6600000).toISOString(), + repeat: 3600000, + }, + ]); + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); + }); + + it("returns false when no active windows exist", async () => { + const { helper } = createHelper(); + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); + }); + + it("returns false when window is inactive", async () => { + const now = new Date(); + const { helper, maintenanceWindowsRepository } = createHelper(); + maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ + { + active: false, + start: new Date(now.getTime() - 1000).toISOString(), + end: new Date(now.getTime() + 1000).toISOString(), + repeat: 0, + }, + ]); + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); + }); + + it("returns false when active window is in the past without repeat", async () => { + const now = Date.now(); + const { helper, maintenanceWindowsRepository } = createHelper(); + maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ + { + active: true, + start: new Date(now - 5000).toISOString(), + end: new Date(now - 1000).toISOString(), + repeat: 0, + }, + ]); + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); + }); + + it("returns false when repeat advances past current time without match", async () => { + const now = Date.now(); + const { helper, maintenanceWindowsRepository } = createHelper(); + // Window was 2 hours ago, lasts 10 minutes, repeats every hour + // First repeat: -1h to -50min (past), second repeat: now to +10min... actually this would match + // Use a repeat that skips over now + maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ + { + active: true, + start: new Date(now - 3600000 * 3 - 300000).toISOString(), // 3h5min ago + end: new Date(now - 3600000 * 3).toISOString(), // 3h ago, 5min window + repeat: 3600000, // 1 hour repeat + }, + ]); + // Advances: -2h5m to -2h, -1h5m to -1h, -5m to 0 — that last one might match if now is within 5min + // Let's use a different setup that clearly doesn't match + maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ + { + active: true, + start: new Date(now - 1800000 - 60000).toISOString(), // 31 min ago + end: new Date(now - 1800000).toISOString(), // 30 min ago, 1 min window + repeat: 3600000, // 1 hour repeat — next window would be in 29 min + }, + ]); + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); + }); + + it("preserves previous true result via accumulator", async () => { + const now = new Date(); + const { helper, maintenanceWindowsRepository } = createHelper(); + maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ + { + active: true, + start: new Date(now.getTime() - 1000).toISOString(), + end: new Date(now.getTime() + 1000).toISOString(), + repeat: 0, + }, + { + active: false, + start: new Date(now.getTime() - 1000).toISOString(), + end: new Date(now.getTime() + 1000).toISOString(), + repeat: 0, + }, + ]); + // Second window is inactive but first already matched — accumulator should preserve true + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); + }); + }); + + // ── evaluateMonitorAction (tested indirectly via getHeartbeatJob) ───────── + + describe("evaluateMonitorAction", () => { + const runJobAndGetDecision = async (statusResult: Record) => { + const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; + const incidentService = { handleIncident: jest.fn().mockResolvedValue(undefined) }; + const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(undefined) }; + const { helper } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, + incidentService, + notificationsService, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + await job(makeMonitor()); + return { incidentService, notificationsService }; + }; + + it("does nothing when statusChanged is false", async () => { + const { incidentService, notificationsService } = await runJobAndGetDecision({ + monitor: { id: "m1", status: "up" }, + statusChanged: false, + prevStatus: "up", + code: 200, + }); + expect(incidentService.handleIncident).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }), + expect.anything() + ); + }); + + it("creates incident and notifies when monitor goes down", async () => { + const { incidentService } = await runJobAndGetDecision({ + monitor: { id: "m1", status: "down" }, + statusChanged: true, + prevStatus: "up", + code: 500, + }); + expect(incidentService.handleIncident).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + shouldCreateIncident: true, + shouldSendNotification: true, + incidentReason: "status_down", + notificationReason: "status_change", + }), + expect.anything() + ); + }); + + it("creates incident and notifies when monitor is breached", async () => { + const { incidentService } = await runJobAndGetDecision({ + monitor: { id: "m1", status: "breached" }, + statusChanged: true, + prevStatus: "up", + code: 200, + }); + expect(incidentService.handleIncident).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + shouldCreateIncident: true, + incidentReason: "threshold_breach", + notificationReason: "threshold_breach", + }), + expect.anything() + ); + }); + + it("resolves incident when monitor recovers from down", async () => { + const { incidentService } = await runJobAndGetDecision({ + monitor: { id: "m1", status: "up" }, + statusChanged: true, + prevStatus: "down", + code: 200, + }); + expect(incidentService.handleIncident).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ shouldResolveIncident: true, shouldSendNotification: true }), + expect.anything() + ); + }); + + it("resolves incident when monitor recovers from breached", async () => { + const { incidentService } = await runJobAndGetDecision({ + monitor: { id: "m1", status: "up" }, + statusChanged: true, + prevStatus: "breached", + code: 200, + }); + expect(incidentService.handleIncident).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ shouldResolveIncident: true }), + expect.anything() + ); + }); + + it("does not create or resolve for unhandled status transitions", async () => { + const { incidentService } = await runJobAndGetDecision({ + monitor: { id: "m1", status: "paused" }, + statusChanged: true, + prevStatus: "up", + code: 200, + }); + expect(incidentService.handleIncident).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }), + expect.anything() + ); + }); + }); +}); diff --git a/server/test/unit/services/userService.test.ts b/server/test/unit/services/userService.test.ts new file mode 100644 index 000000000..51590fb38 --- /dev/null +++ b/server/test/unit/services/userService.test.ts @@ -0,0 +1,629 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { UserService } from "../../../src/service/business/userService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { User, UserRole } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeUser = (overrides?: Partial): User => ({ + id: "user-1", + firstName: "Test", + lastName: "User", + email: "test@example.com", + password: "$2a$10$hashedpassword", + isActive: true, + isVerified: true, + role: ["user"] as UserRole[], + teamId: "team-1", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, +}); + +const makeAppSettings = () => ({ + jwtSecret: "test-secret", + jwtTTL: "99d" as const, + nodeEnv: "development", + logLevel: "debug", + clientHost: "http://localhost:5173", + dbConnectionString: "mongodb://localhost:27017/test_db", + dbType: "mongodb" as const, +}); + +const createService = (overrides?: Record) => { + const logger = createMockLogger(); + const usersRepository = { + create: jest.fn().mockResolvedValue(makeUser()), + findByEmail: jest.fn().mockResolvedValue(makeUser()), + findById: jest.fn().mockResolvedValue(makeUser()), + findAll: jest.fn().mockResolvedValue([makeUser()]), + updateById: jest.fn().mockResolvedValue(makeUser()), + deleteById: jest.fn().mockResolvedValue(makeUser()), + findSuperAdmin: jest.fn().mockResolvedValue(true), + }; + const invitesRepository = { + findByTokenAndDelete: jest.fn().mockResolvedValue({ role: ["user"], teamId: "team-1", email: "invited@example.com" }), + }; + const recoveryTokensRepository = { + create: jest.fn().mockResolvedValue({ token: "recovery-token-123", email: "test@example.com" }), + findByToken: jest.fn().mockResolvedValue({ token: "recovery-token-123", email: "test@example.com" }), + deleteManyByEmail: jest.fn().mockResolvedValue(1), + }; + const settingsRepository = { + update: jest.fn().mockResolvedValue({}), + }; + const teamsRepository = { + create: jest.fn().mockResolvedValue({ id: "team-new" }), + }; + const monitorsRepository = { + findByTeamId: jest.fn().mockResolvedValue([{ id: "mon-1" }, { id: "mon-2" }]), + }; + const emailService = { + buildEmail: jest.fn().mockResolvedValue("Welcome"), + sendEmail: jest.fn().mockResolvedValue("msg-id-123"), + }; + const settingsService = { + getSettings: jest.fn().mockReturnValue(makeAppSettings()), + }; + const jobQueue = { + deleteJob: jest.fn().mockResolvedValue(undefined), + }; + const jwtMock = { + sign: jest.fn().mockReturnValue("jwt-token-123"), + }; + const cryptoMock = { + randomBytes: jest.fn().mockReturnValue({ toString: () => "random-hex-secret" }), + }; + + const defaults = { + logger, + usersRepository, + invitesRepository, + recoveryTokensRepository, + settingsRepository, + teamsRepository, + monitorsRepository, + emailService, + settingsService, + jobQueue, + jwt: jwtMock, + crypto: cryptoMock, + ...overrides, + }; + + const service = new UserService(defaults as any); + return { service, ...defaults }; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("UserService", () => { + describe("serviceName", () => { + it("returns userService", () => { + const { service } = createService(); + expect(service.serviceName).toBe("userService"); + }); + }); + + // ── issueToken ────────────────────────────────────────────────────────── + + describe("issueToken", () => { + it("signs a JWT with the payload and settings", () => { + const { service, jwt: jwtMock } = createService(); + const user = makeUser(); + const settings = makeAppSettings(); + + const token = service.issueToken(user, settings); + + expect(token).toBe("jwt-token-123"); + expect(jwtMock.sign).toHaveBeenCalledWith(user, "test-secret", { expiresIn: "99d" }); + }); + }); + + // ── registerUser ──────────────────────────────────────────────────────── + + describe("registerUser", () => { + it("registers an invited user when superadmin exists", async () => { + const { service, usersRepository, invitesRepository } = createService(); + + const result = await service.registerUser({ password: "password123" }, "invite-token", null); + + expect(invitesRepository.findByTokenAndDelete).toHaveBeenCalledWith("invite-token"); + expect(usersRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ role: ["user"], teamId: "team-1", email: "invited@example.com" }), + null + ); + expect(result.token).toBe("jwt-token-123"); + expect(result.user).toBeDefined(); + }); + + it("creates first user as superadmin with new team and JWT secret", async () => { + const { + service, + usersRepository, + teamsRepository, + settingsRepository, + crypto: cryptoMock, + } = createService({ + usersRepository: { + create: jest.fn().mockResolvedValue(makeUser({ role: ["superadmin"], teamId: "team-new" })), + findByEmail: jest.fn(), + findById: jest.fn(), + findAll: jest.fn(), + updateById: jest.fn(), + deleteById: jest.fn(), + findSuperAdmin: jest.fn().mockResolvedValue(false), + }, + }); + + const result = await service.registerUser({ email: "first@example.com", password: "password123" }, "", null); + + expect(cryptoMock.randomBytes).toHaveBeenCalledWith(64); + expect(settingsRepository.update).toHaveBeenCalledWith({ jwtSecret: "random-hex-secret" }); + expect(teamsRepository.create).toHaveBeenCalledWith("first@example.com"); + expect(result.user).toBeDefined(); + }); + + it("throws when first user has no email", async () => { + const { service } = createService({ + usersRepository: { + create: jest.fn(), + findByEmail: jest.fn(), + findById: jest.fn(), + findAll: jest.fn(), + updateById: jest.fn(), + deleteById: jest.fn(), + findSuperAdmin: jest.fn().mockResolvedValue(false), + }, + }); + + await expect(service.registerUser({ password: "pass" }, "", null)).rejects.toThrow("Email is required for first user"); + }); + + it("sends welcome email after registration", async () => { + const { service, emailService } = createService(); + + await service.registerUser({ password: "pass" }, "invite-token", null); + + expect(emailService.buildEmail).toHaveBeenCalledWith("welcomeEmailTemplate", expect.objectContaining({ name: "Test" })); + expect(emailService.sendEmail).toHaveBeenCalledWith("test@example.com", "Welcome to Uptime Monitor", "Welcome"); + }); + + it("logs warning when welcome email build returns null", async () => { + const { service, logger } = createService({ + emailService: { + buildEmail: jest.fn().mockResolvedValue(null), + sendEmail: jest.fn(), + }, + }); + + const result = await service.registerUser({ password: "pass" }, "invite-token", null); + + expect(result.user).toBeDefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "registerUser" })); + }); + + it("uses default role when invite has no role", async () => { + const { service, usersRepository } = createService({ + invitesRepository: { + findByTokenAndDelete: jest.fn().mockResolvedValue({ role: undefined, teamId: "team-1", email: "invited@example.com" }), + }, + }); + + await service.registerUser({ password: "pass" }, "invite-token", null); + + expect(usersRepository.create).toHaveBeenCalledWith(expect.objectContaining({ role: ["user"] }), null); + }); + + it("registers user without password", async () => { + const { service, usersRepository } = createService(); + + await service.registerUser({}, "invite-token", null); + + const call = (usersRepository.create as jest.Mock).mock.calls[0] as any[]; + // Password should not be hashed since it was never provided + expect(call[0].password).toBeUndefined(); + }); + + it("logs warning when sendEmail rejects (fire-and-forget)", async () => { + const { service, logger } = createService({ + emailService: { + buildEmail: jest.fn().mockResolvedValue("ok"), + sendEmail: jest.fn().mockRejectedValue(new Error("SMTP down")), + }, + }); + + await service.registerUser({ password: "pass" }, "invite-token", null); + await new Promise((r) => setTimeout(r, 10)); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "SMTP down" })); + }); + + it("logs 'Unknown error' when sendEmail rejects with non-Error", async () => { + const { service, logger } = createService({ + emailService: { + buildEmail: jest.fn().mockResolvedValue("ok"), + sendEmail: jest.fn().mockRejectedValue("string error"), + }, + }); + + await service.registerUser({ password: "pass" }, "invite-token", null); + await new Promise((r) => setTimeout(r, 10)); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error" })); + }); + + it("logs 'Unknown error' when buildEmail throws non-Error", async () => { + const { service, logger } = createService({ + emailService: { + buildEmail: jest.fn().mockRejectedValue(42), + sendEmail: jest.fn(), + }, + }); + + const result = await service.registerUser({ password: "pass" }, "invite-token", null); + + expect(result.user).toBeDefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error" })); + }); + }); + + // ── createUser ────────────────────────────────────────────────────────── + + describe("createUser", () => { + it("creates a user with hashed password and assigned team", async () => { + const { service, usersRepository } = createService(); + + const result = await service.createUser({ password: "pass", role: ["user"] }, "team-1", ["superadmin"], null); + + expect(usersRepository.create).toHaveBeenCalledWith(expect.objectContaining({ teamId: "team-1" }), null); + expect(result.password).toBe(""); + }); + + it("creates user without password when not provided", async () => { + const { service, usersRepository } = createService(); + + await service.createUser({ role: ["user"] }, "team-1", ["superadmin"], null); + + const call = (usersRepository.create as jest.Mock).mock.calls[0] as any[]; + expect(call[0].password).toBeUndefined(); + }); + + it("defaults to empty roles when userData.role is undefined", async () => { + const { service } = createService(); + + // No role provided, targetRoles defaults to [] via ?? [], loop doesn't execute, no permission error + const result = await service.createUser({ password: "pass" }, "team-1", ["user"], null); + + expect(result).toBeDefined(); + }); + + it("throws when actor cannot manage the target role", async () => { + const { service } = createService(); + + await expect(service.createUser({ role: ["superadmin"] }, "team-1", ["admin"], null)).rejects.toThrow( + "You do not have permission to assign this role" + ); + }); + }); + + // ── loginUser ─────────────────────────────────────────────────────────── + + describe("loginUser", () => { + it("returns user and token on correct password", async () => { + const { service, usersRepository } = createService(); + const bcrypt = await import("bcryptjs"); + const hashed = bcrypt.hashSync("correct-password", 10); + (usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed })); + + const result = await service.loginUser("test@example.com", "correct-password"); + + expect(result.token).toBe("jwt-token-123"); + expect(result.user.password).toBe(""); + }); + + it("throws on incorrect password", async () => { + const { service, usersRepository } = createService(); + const bcrypt = await import("bcryptjs"); + const hashed = bcrypt.hashSync("correct-password", 10); + (usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed })); + + await expect(service.loginUser("test@example.com", "wrong-password")).rejects.toThrow("Incorrect password"); + }); + }); + + // ── editUser ──────────────────────────────────────────────────────────── + + describe("editUser", () => { + it("updates user without password change", async () => { + const { service, usersRepository } = createService(); + + await service.editUser({ firstName: "Updated" }, null, "user-1", "test@example.com"); + + expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", { firstName: "Updated" }, null); + }); + + it("skips password flow when only password is provided without newPassword", async () => { + const { service, usersRepository } = createService(); + + await service.editUser({ password: "old-pass" }, null, "user-1", "test@example.com"); + + expect(usersRepository.findByEmail).not.toHaveBeenCalled(); + expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", expect.objectContaining({ password: "old-pass" }), null); + }); + + it("skips password flow when only newPassword is provided without password", async () => { + const { service, usersRepository } = createService(); + + await service.editUser({ newPassword: "new-pass" } as any, null, "user-1", "test@example.com"); + + expect(usersRepository.findByEmail).not.toHaveBeenCalled(); + }); + + it("changes password when old and new password provided", async () => { + const { service, usersRepository } = createService(); + const bcrypt = await import("bcryptjs"); + const hashed = bcrypt.hashSync("old-password", 10); + (usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed })); + + await service.editUser({ password: "old-password", newPassword: "new-password" }, null, "user-1", "test@example.com"); + + expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", expect.objectContaining({ email: "test@example.com" }), null); + const call = (usersRepository.updateById as jest.Mock).mock.calls[0] as any[]; + expect(call[1].newPassword).toBeUndefined(); + expect(call[1].password).not.toBe("old-password"); + }); + + it("throws when current password is incorrect", async () => { + const { service, usersRepository } = createService(); + const bcrypt = await import("bcryptjs"); + const hashed = bcrypt.hashSync("actual-password", 10); + (usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed })); + + await expect(service.editUser({ password: "wrong-password", newPassword: "new-pass" }, null, "user-1", "test@example.com")).rejects.toThrow( + "Incorrect current password" + ); + }); + }); + + // ── checkSuperadminExists ─────────────────────────────────────────────── + + describe("checkSuperadminExists", () => { + it("delegates to repository", async () => { + const { service, usersRepository } = createService(); + + const result = await service.checkSuperadminExists(); + + expect(result).toBe(true); + expect(usersRepository.findSuperAdmin).toHaveBeenCalled(); + }); + }); + + // ── requestRecovery ───────────────────────────────────────────────────── + + describe("requestRecovery", () => { + it("creates recovery token and sends email", async () => { + const { service, recoveryTokensRepository, emailService } = createService(); + + const result = await service.requestRecovery("test@example.com"); + + expect(recoveryTokensRepository.deleteManyByEmail).toHaveBeenCalledWith("test@example.com"); + expect(recoveryTokensRepository.create).toHaveBeenCalledWith("test@example.com"); + expect(emailService.buildEmail).toHaveBeenCalledWith( + "passwordResetTemplate", + expect.objectContaining({ + name: "Test", + email: "test@example.com", + url: "http://localhost:5173/set-new-password/recovery-token-123", + }) + ); + expect(result).toBe("msg-id-123"); + }); + + it("throws when email HTML fails to build", async () => { + const { service } = createService({ + emailService: { + buildEmail: jest.fn().mockResolvedValue(null), + sendEmail: jest.fn(), + }, + }); + + await expect(service.requestRecovery("test@example.com")).rejects.toThrow("Failed to build password reset email HTML"); + }); + }); + + // ── validateRecovery ──────────────────────────────────────────────────── + + describe("validateRecovery", () => { + it("delegates to repository findByToken", async () => { + const { service, recoveryTokensRepository } = createService(); + + await service.validateRecovery("recovery-token-123"); + + expect(recoveryTokensRepository.findByToken).toHaveBeenCalledWith("recovery-token-123"); + }); + }); + + // ── resetPassword ─────────────────────────────────────────────────────── + + describe("resetPassword", () => { + it("resets password and returns user with token", async () => { + const { service, usersRepository, recoveryTokensRepository } = createService(); + // Ensure old password doesn't match new one (bcrypt.compare returns false) + (usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: "$2a$10$differenthash" })); + + const result = await service.resetPassword("new-password", "recovery-token-123"); + + expect(recoveryTokensRepository.findByToken).toHaveBeenCalledWith("recovery-token-123"); + expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", expect.objectContaining({ password: expect.any(String) }), null); + expect(recoveryTokensRepository.deleteManyByEmail).toHaveBeenCalledWith("test@example.com"); + expect(result.token).toBe("jwt-token-123"); + expect(result.user.password).toBe(""); + }); + + it("throws when new password matches old password", async () => { + const { service, usersRepository } = createService(); + const bcrypt = await import("bcryptjs"); + const hashed = bcrypt.hashSync("same-password", 10); + (usersRepository.findByEmail as jest.Mock).mockResolvedValue(makeUser({ password: hashed })); + + await expect(service.resetPassword("same-password", "recovery-token-123")).rejects.toThrow("New password cannot be same as old password"); + }); + }); + + // ── deleteUser ────────────────────────────────────────────────────────── + + describe("deleteUser", () => { + it("deletes user and cleans up monitor jobs for superadmin", async () => { + const { service, usersRepository, jobQueue, monitorsRepository } = createService(); + + await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] }); + + expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith("team-1", {}); + expect(jobQueue.deleteJob).toHaveBeenCalledTimes(2); + expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1"); + }); + + it("handles null monitors for superadmin gracefully", async () => { + const { service, usersRepository, jobQueue } = createService({ + monitorsRepository: { findByTeamId: jest.fn().mockResolvedValue(null) }, + }); + + await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] }); + + expect(jobQueue.deleteJob).not.toHaveBeenCalled(); + expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1"); + }); + + it("skips monitor cleanup for non-superadmin", async () => { + const { service, usersRepository, monitorsRepository } = createService(); + + await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["user"] }); + + expect(monitorsRepository.findByTeamId).not.toHaveBeenCalled(); + expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1"); + }); + + it("throws when deleting a demo user", async () => { + const { service } = createService(); + + await expect(service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["demo"] })).rejects.toThrow("Demo user cannot be deleted"); + }); + }); + + // ── deleteUserById ────────────────────────────────────────────────────── + + describe("deleteUserById", () => { + it("deletes target user when actor has permission", async () => { + const { service, usersRepository, logger } = createService(); + + await service.deleteUserById({ actorId: "admin-1", actorTeamId: "team-1", actorRoles: ["superadmin"], targetUserId: "user-1" }); + + expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1"); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "User user-1 deleted by admin-1" })); + }); + + it("throws when actor tries to delete themselves", async () => { + const { service } = createService(); + + await expect( + service.deleteUserById({ actorId: "user-1", actorTeamId: "team-1", actorRoles: ["superadmin"], targetUserId: "user-1" }) + ).rejects.toThrow("Cannot delete your own account from here"); + }); + + it("throws when target is on a different team", async () => { + const { service, usersRepository } = createService(); + (usersRepository.findById as jest.Mock).mockResolvedValue(makeUser({ teamId: "team-other" })); + + await expect( + service.deleteUserById({ actorId: "admin-1", actorTeamId: "team-1", actorRoles: ["superadmin"], targetUserId: "user-1" }) + ).rejects.toThrow("User is not on your team"); + }); + + it("throws when target is a demo user", async () => { + const { service, usersRepository } = createService(); + (usersRepository.findById as jest.Mock).mockResolvedValue(makeUser({ role: ["demo"] })); + + await expect( + service.deleteUserById({ actorId: "admin-1", actorTeamId: "team-1", actorRoles: ["superadmin"], targetUserId: "user-1" }) + ).rejects.toThrow("Demo user cannot be deleted"); + }); + + it("throws when actor lacks permission to manage target role", async () => { + const { service, usersRepository } = createService(); + (usersRepository.findById as jest.Mock).mockResolvedValue(makeUser({ role: ["superadmin"] })); + + await expect( + service.deleteUserById({ actorId: "admin-1", actorTeamId: "team-1", actorRoles: ["admin"], targetUserId: "user-1" }) + ).rejects.toThrow("You do not have permission to remove this user"); + }); + }); + + // ── getAllUsers ────────────────────────────────────────────────────────── + + describe("getAllUsers", () => { + it("delegates to repository", async () => { + const { service, usersRepository } = createService(); + + const result = await service.getAllUsers(); + + expect(result).toEqual([makeUser()]); + expect(usersRepository.findAll).toHaveBeenCalled(); + }); + }); + + // ── getUserById ───────────────────────────────────────────────────────── + + describe("getUserById", () => { + it("returns user for admin", async () => { + const { service, usersRepository } = createService(); + + const result = await service.getUserById(["admin"], "user-1"); + + expect(result).toEqual(makeUser()); + expect(usersRepository.findById).toHaveBeenCalledWith("user-1"); + }); + + it("returns user for superadmin", async () => { + const { service } = createService(); + + const result = await service.getUserById(["superadmin"], "user-1"); + + expect(result).toEqual(makeUser()); + }); + + it("throws for non-admin roles", async () => { + const { service } = createService(); + + await expect(service.getUserById(["user"], "user-1")).rejects.toThrow("Insufficient permissions"); + }); + }); + + // ── editUserById ──────────────────────────────────────────────────────── + + describe("editUserById", () => { + it("delegates to repository", async () => { + const { service, usersRepository } = createService(); + + await service.editUserById("user-1", { firstName: "New" }); + + expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", { firstName: "New" }, null); + }); + }); + + // ── setPasswordByUserId ───────────────────────────────────────────────── + + describe("setPasswordByUserId", () => { + it("hashes and sets password", async () => { + const { service, usersRepository } = createService(); + + const result = await service.setPasswordByUserId("user-1", "new-password"); + + expect(usersRepository.updateById).toHaveBeenCalledWith("user-1", expect.objectContaining({ password: expect.any(String) }), null); + // Ensure the stored password is hashed, not plaintext + const call = (usersRepository.updateById as jest.Mock).mock.calls[0] as any[]; + expect(call[1].password).not.toBe("new-password"); + expect(result).toEqual(makeUser()); + }); + }); +}); diff --git a/server/test/unit/validation/maintenanceWindowValidation.test.ts b/server/test/unit/validation/maintenanceWindowValidation.test.ts new file mode 100644 index 000000000..dfede3670 --- /dev/null +++ b/server/test/unit/validation/maintenanceWindowValidation.test.ts @@ -0,0 +1,171 @@ +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); + }); +}); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts new file mode 100644 index 000000000..62fa91625 --- /dev/null +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -0,0 +1,131 @@ +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"); + }); + }); +}); 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"] }