Compare commits

..
Author SHA1 Message Date
Alexander Holliday e3ecfe6ad8 Merge pull request #3323 from bluewave-labs/develop
develop -> master
2026-02-20 12:38:33 -08:00
Alexander Holliday f4664eec16 Merge pull request #3321 from bluewave-labs/develop
develop->master
2026-02-20 12:20:49 -08:00
Alexander Holliday 56f601e34b Merge pull request #3319 from bluewave-labs/develop
develop -> master
2026-02-20 12:07:29 -08:00
Alexander Holliday 1244ff6d93 Merge pull request #3212 from bluewave-labs/develop
develop -> master
2026-01-27 13:58:48 -08:00
Alexander Holliday c0ea804c98 Merge pull request #3209 from bluewave-labs/develop
develop -> master
2026-01-27 13:40:25 -08:00
Alexander Holliday c21cf29db6 Merge pull request #3207 from bluewave-labs/develop
develop -> master
2026-01-27 13:20:18 -08:00
Alexander Holliday 3d0da70ce6 Merge pull request #3204 from bluewave-labs/develop
develop -> master
2026-01-27 13:09:47 -08:00
Alexander Holliday feb061240b Merge pull request #3042 from bluewave-labs/develop
develp->master
2025-10-27 12:09:00 -07:00
Alexander Holliday a924227805 Merge pull request #2964 from bluewave-labs/develop
develop -> master
2025-09-18 13:40:45 -07:00
Alexander Holliday 2144d3f8c0 Merge pull request #2962 from bluewave-labs/develop
develop -> master
2025-09-18 12:04:51 -07:00
Alexander Holliday fcd6786149 Merge pull request #2936 from bluewave-labs/develop
develop -> master
2025-09-08 20:43:21 -07:00
Alexander Holliday ea968ebf3c Merge pull request #2933 from bluewave-labs/develop
develop -> master
2025-09-08 20:33:35 -07:00
Alexander Holliday 8a7b852974 Merge pull request #2929 from bluewave-labs/develop
develop -> master
2025-09-08 07:24:16 -07:00
Alexander Holliday c8d3e3b70c Merge pull request #2921 from bluewave-labs/develop
develop -> master
2025-09-04 15:46:30 -07:00
Alexander Holliday 7a90a6f9f4 Merge pull request #2915 from bluewave-labs/develop
develop->master
2025-09-01 10:33:09 -07:00
336 changed files with 21626 additions and 46974 deletions

No files matched your search

-2
View File
@@ -1,2 +0,0 @@
* text=auto
* text eol=lf
+6 -7
View File
@@ -1,28 +1,27 @@
**(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 ## 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 " ## 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. ## 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 deployed the application locally.
- [ ] (Do not skip this or your PR will be closed) I have performed a self-reviewing and testing of my code. - [ ] (Do not skip this or your PR will be closed) I have performed a self-review and testing of my code.
- [ ] I have included the issue # in the PR. - [ ] I have included the issue # in the PR.
- [ ] I have added i18n support to visible strings (instead of `<div>Add</div>`, use): - [ ] I have added i18n support to visible strings (instead of `<div>Add</div>`, use):
```Javascript ```Javascript
const { t } = useTranslation(); const { t } = useTranslation();
<div>{t('add')}</div> <div>{t('add')}</div>
``` ```
- [ ] 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 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 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. - [ ] 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. - [ ] 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 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. - [ ] I took a screenshot or a video and attached to this PR if there is a UI change.
-23
View File
@@ -1,23 +0,0 @@
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
+104 -122
View File
@@ -1,138 +1,120 @@
name: POEditor Translation Synchronization (disabled) name: POEditor Translation Synchronization
# 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: on:
workflow_dispatch: # 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
jobs: jobs:
disabled: sync-translations:
if: false runs-on: ubuntu-latest
runs-on: ubuntu-latest permissions:
steps: contents: write
- run: echo "This workflow is disabled. See comments below for the original definition." issues: write
pull-requests: write
# --------------------------------------------------------------------------- steps:
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing. - name: Checkout code
# --------------------------------------------------------------------------- uses: actions/checkout@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
# on: - name: Setup Node.js
# # For manual triggering uses: actions/setup-node@v3
# workflow_dispatch: with:
# inputs: node-version: "22"
# 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: - name: Create package.json for scripts
# contents: write run: |
# pull-requests: write 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
# jobs: - name: Install dependencies
# sync-translations: run: |
# runs-on: ubuntu-latest cd .github/scripts
# permissions: npm install
# contents: write
# issues: write
# pull-requests: write
# steps: - name: Download translations from POEditor
# - name: Checkout code env:
# uses: actions/checkout@v3 POEDITOR_API: ${{ secrets.POEDITOR_API }}
# with: POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
# token: ${{ secrets.GITHUB_TOKEN }} LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
# fetch-depth: 0 EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
run: |
mkdir -p temp
node .github/scripts/download-translations.js
# - name: Setup Node.js - name: Verify translation files
# uses: actions/setup-node@v3 run: |
# with: echo "Verifying translation files..."
# node-version: "22" 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: Create package.json for scripts - name: Copy translations to project
# run: | run: |
# mkdir -p .github/scripts mkdir -p client/src/locales
# cat > .github/scripts/package.json << EOF cp -r temp/* client/src/locales/
# { echo "Translation files copied to client/src/locales/"
# "name": "poeditor-scripts",
# "version": "1.0.0",
# "private": true,
# "type": "module",
# "dependencies": {
# "axios": "^1.6.0",
# "fs-extra": "^11.1.1"
# }
# }
# EOF
# - name: Install dependencies - name: Get current date
# run: | id: date
# cd .github/scripts run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
# npm install
# - name: Download translations from POEditor - name: Format client code
# env: run: |
# POEDITOR_API: ${{ secrets.POEDITOR_API }} cd client
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }} npm ci
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }} npm run format
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
# run: |
# mkdir -p temp
# node .github/scripts/download-translations.js
# - name: Verify translation files - name: Create Pull Request
# run: | uses: peter-evans/create-pull-request@v7
# echo "Verifying translation files..." with:
# for file in temp/*.json; do token: ${{ secrets.GITHUB_TOKEN }}
# echo "Checking $file" commit-message: "feat: translations updated from POEditor"
# if [ ! -s "$file" ]; then title: "🌐 Updated Translations from POEditor"
# echo "Error: $file is empty or does not exist" body: |
# exit 1 This PR contains the latest translations from POEditor.
# 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: Copy translations to project 📅 Update Date: ${{ steps.date.outputs.date }}
# run: | 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
# mkdir -p client/src/locales
# cp -r temp/* client/src/locales/
# echo "Translation files copied to client/src/locales/"
# - name: Get current date ⚠️ Please review the translations and approve the PR if everything looks correct.
# id: date branch: translation-update-${{ github.run_number }}
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT delete-branch: true
base: develop
# - name: Format client code add-paths: |
# run: | client/src/locales/*.json
# cd client committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
# npm ci author: GitHub Action <github-actions[bot]@users.noreply.github.com>
# 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 <github-actions[bot]@users.noreply.github.com>
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
+2 -3
View File
@@ -2,7 +2,7 @@ name: Demo deploy
on: on:
push: push:
branches: [ "demo" ] branches: ["demo"]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
docker-build-and-push-client: docker-build-and-push-client:
@@ -84,8 +84,7 @@ jobs:
script: | script: |
cd checkmate cd checkmate
git pull git pull
cd docker/unified cd docker/prod
docker compose down docker compose down
docker compose pull docker compose pull
docker compose up -d docker compose up -d
docker system prune -af
+5 -5
View File
@@ -2,7 +2,7 @@ name: Staging deploy
on: on:
push: push:
branches: [ "develop" ] branches: ["develop"]
jobs: jobs:
docker-build-and-push-client: docker-build-and-push-client:
@@ -78,13 +78,13 @@ jobs:
- name: SSH into server and restart container using Docker Compose - name: SSH into server and restart container using Docker Compose
uses: appleboy/ssh-action@v1.2.2 uses: appleboy/ssh-action@v1.2.2
with: with:
host: ${{ secrets.DEMO_SERVER_HOST }} host: ${{ secrets.STAGING_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }} username: ${{ secrets.STAGING_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }} key: ${{ secrets.STAGING_SERVER_SSH_KEY }}
script: | script: |
cd checkmate cd checkmate
git pull git pull
cd docker/unified cd docker/staging
docker compose down docker compose down
docker compose pull docker compose pull
docker compose up -d docker compose up -d
+1 -3
View File
@@ -5,6 +5,4 @@
mongo mongo
node_modules/ node_modules/
docs/architecture docs/architecture
docs/reviews docs/reviews
docs/todo
docs/frontend
+1 -51
View File
@@ -105,56 +105,6 @@ client/src/
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling) - **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
- **i18n**: i18next + react-i18next (translations via PoEditor) - **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 ## Code Conventions
### Internationalization ### Internationalization
@@ -192,4 +142,4 @@ Key Mongoose models in `/server/src/db/models/`:
- **StatusPage** - Public status pages - **StatusPage** - Public status pages
- **Notification** - Alert configuration (email, Discord, Slack, webhooks) - **Notification** - Alert configuration (email, Discord, Slack, webhooks)
- **MaintenanceWindow** - Scheduled maintenance periods - **MaintenanceWindow** - Scheduled maintenance periods
- **AppSettings** - Global application settings - **AppSettings** - Global application settings
+69 -69
View File
@@ -1,69 +1,69 @@
# Contributor Covenant Code of Conduct # Contributor Covenant Code of Conduct
## Our Pledge ## 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 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. 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 ## Our Standards
Examples of behavior that contributes to a positive environment for our community include: Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people - Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences - Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback - Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - 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 - Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include: Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of any kind - 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 - Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment - Public or private harassment
- Publishing others' private information, such as a physical or email address, without their explicit permission - 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 - Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities ## 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 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. 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 ## 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. 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 ## 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. 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. All project team members are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines ## 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: 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 ### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **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. **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 ### 2. Warning
**Community Impact**: A violation through a single incident or series of actions. **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. **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 ### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **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. **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 ### 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. **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. **Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution ## 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. 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! 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!
+1 -7
View File
@@ -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: Before you dive in, make sure you have these installed:
```bash ```bash
# Check Node.js (v20+ required) # Check Node.js (v16+ required)
node --version node --version
# Check npm # Check npm
@@ -96,16 +96,10 @@ cd docker/dev
Build the Docker images: Build the Docker images:
Linux:
```bash ```bash
./build_images.sh ./build_images.sh
``` ```
Windows:
```bash
./build_images.bat
```
Run MongoDB container: Run MongoDB container:
```bash ```bash
+1 -1
View File
@@ -33,4 +33,4 @@ Ensure you execute `npm run format` before submitting your pull requests in both
### Keep PRs focused ### 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 bug 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 big or work on another feature.
+1 -1
View File
@@ -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: Si tienes alguna pregunta, sugerencia o comentario, tienes varias opciones:
- [Canal de Discord](https://discord.gg/NAb6H3UTjK) - [Canal de Discord](https://discord.gg/NAb6H3UTjK)
- [Discusiones en GitHub](https://github.com/bluewave-labs/checkmate/discussions) - [Discusiones en GitHub](https://github.com/bluewave-labs/bluewave-uptime/discussions)
- [Grupo en Reddit](https://www.reddit.com/r/CheckmateMonitoring/) - [Grupo en Reddit](https://www.reddit.com/r/CheckmateMonitoring/)
¡No dudes en hacer preguntas o compartir tus ideas, nos encantaría saber de ti! ¡No dudes en hacer preguntas o compartir tus ideas, nos encantaría saber de ti!
+24 -28
View File
@@ -14,8 +14,6 @@
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p> <p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate)
<img width="1660" alt="image" src="https://github.com/user-attachments/assets/b748f36d-a271-4965-ad0a-18bf153bbee7" /> <img width="1660" alt="image" src="https://github.com/user-attachments/assets/b748f36d-a271-4965-ad0a-18bf153bbee7" />
This repository contains both the frontend and the backend of Checkmate, an open-source, self-hosted monitoring tool for tracking server hardware, uptime, response times, and incidents in real-time with beautiful visualizations. Checkmate regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time. 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.
@@ -31,6 +29,7 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
- [📦 Demo](#demo) - [📦 Demo](#demo)
- [🔗 User's guide](#users-guide) - [🔗 User's guide](#users-guide)
- [🛠️ Installation](#installation) - [🛠️ Installation](#installation)
- [🏁 Translations](#translations)
- [🚀 Performance](#performance) - [🚀 Performance](#performance)
- [💚 Questions & Ideas](#questions--ideas) - [💚 Questions & Ideas](#questions--ideas)
- [🧩 Features](#features) - [🧩 Features](#features)
@@ -43,23 +42,17 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
## Demo ## Demo
You can see the latest build of [Checkmate](https://demo.checkmate.so/) in action. You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is uptimedemo@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).
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 ## User's guide
Usage instructions can be found [here](https://checkmate.so/docs). Usage instructions can be found [here](https://docs.checkmate.so/checkmate-2.1). It's still WIP and some of the information there might be outdated as we continuously add features weekly. Rest assured, we are doing our best! :)
## Prerequisites
- [Docker](https://www.docker.com/) installed
- [Git](https://git-scm.com/) installed
## Installation ## Installation
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs). See installation instructions in [Checkmate documentation portal](https://docs.checkmate.so/checkmate-2.1/users-guide/quickstart).
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. 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.
### Using a Custom CA ### Using a Custom CA
@@ -67,6 +60,11 @@ If you need to monitor internal HTTPS endpoints with certificates from private C
For more documentation, see the [docs directory](./docs/). 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 ## 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: 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:
@@ -82,7 +80,7 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
If you have any questions, suggestions or comments, you have several options: If you have any questions, suggestions or comments, you have several options:
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred) - [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (we check here from time to time) - [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time)
Feel free to ask questions or share your ideas - we'd love to hear from you! Feel free to ask questions or share your ideas - we'd love to hear from you!
@@ -114,15 +112,6 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
If you would like to sponsor an additional feature, [see this page](https://checkmate.so/sponsored-features). If you would like to sponsor an additional feature, [see this page](https://checkmate.so/sponsored-features).
## Monitor Lifecycle
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 ## Screenshots
<p> <p>
@@ -168,20 +157,27 @@ Here's how you can contribute:
0. Star this repo :) 0. Star this repo :)
1. Check [Contributor's guideline](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). First timers are encouraged to check `good-first-issue` tag. 1. Check [Contributor's guideline](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). First timers are encouraged to check `good-first-issue` tag.
2. Read a detailed structure of [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) if you would like to deep dive into the architecture. 2. Check [project structure](https://docs.checkmate.so/checkmate-2.1/developers-guide/general-project-structure) and [high level overview](https://bluewavelabs.gitbook.io/checkmate/developers-guide/high-level-overview).
3. Open an issue if you believe you've encountered a bug. 3. Read a detailed structure of [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) if you would like to deep dive into the architecture.
4. Check for good-first-issue's if you are a newcomer. 4. Open an issue if you believe you've encountered a bug.
5. Make a pull request to add new features/make quality-of-life improvements/fix bugs. 5. Check for good-first-issue's if you are a newcomer.
6. Check out this interactive walkthrough of the `Checkmate` codebase on CodeCanvas [here](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). To refine existing dataflow simulation or create new ones, follow the quick tutorial [here](https://docs.code-canvas.com/updating-diagram). 6. Make a pull request to add new features/make quality-of-life improvements/fix bugs.
7. Check out this interactive walkthrough of the `Checkmate` codebase on CodeCanvas [here](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). To refine existing dataflow simulation or create new ones, follow the quick tutorial [here](https://docs.code-canvas.com/updating-diagram).
<a href="https://github.com/bluewave-labs/checkmate/graphs/contributors"> <a href="https://github.com/bluewave-labs/checkmate/graphs/contributors">
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" /> <img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
</a> </a>
[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date) [![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/bluewave-uptime&Date)
## Our sponsors ## 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 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). If you would like to sponsor a feature, [see this page](https://checkmate.so/sponsored-features).
Also check other developer and contributor-friendly projects of BlueWave:
- [VerifyWise](https://github.com/bluewave-labs/verifywise), the first open source AI governance platform.
- [DataRoom](https://github.com/bluewave-labs/bluewave-dataroom), an secure file sharing application, aka dataroom.
- [Guidefox](https://github.com/bluewave-labs/guidefox), an application that helps new users learn how to use your product via hints, tours, popups and banners.
+123 -690
View File
File diff suppressed because it is too large. Load diff
+4 -7
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "3.6.1", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -13,7 +13,7 @@
"format-check": "prettier --check ." "format-check": "prettier --check ."
}, },
"lint-staged": { "lint-staged": {
"**/*": "prettier --write" "**/*": "npm run format"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
@@ -25,7 +25,6 @@
"@mui/x-charts": "7.29.1", "@mui/x-charts": "7.29.1",
"@mui/x-date-pickers": "7.29.4", "@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0", "@reduxjs/toolkit": "2.7.0",
"@types/maplibre-gl": "^1.13.2",
"axios": "^1.7.4", "axios": "^1.7.4",
"dayjs": "1.11.13", "dayjs": "1.11.13",
"flag-icons": "7.3.2", "flag-icons": "7.3.2",
@@ -34,7 +33,6 @@
"i18next": "25.4.2", "i18next": "25.4.2",
"joi": "17.13.3", "joi": "17.13.3",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"maplibre-gl": "^5.19.0",
"mui-color-input": "^7.0.0", "mui-color-input": "^7.0.0",
"pretty-bytes": "^7.1.0", "pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0", "pretty-ms": "^9.3.0",
@@ -43,7 +41,6 @@
"react-hook-form": "^7.63.0", "react-hook-form": "^7.63.0",
"react-i18next": "^15.4.0", "react-i18next": "^15.4.0",
"react-icons": "5.5.0", "react-icons": "5.5.0",
"react-map-gl": "^8.1.0",
"react-redux": "9.2.0", "react-redux": "9.2.0",
"react-router": "^6.23.0", "react-router": "^6.23.0",
"react-router-dom": "^6.23.1", "react-router-dom": "^6.23.1",
@@ -52,7 +49,7 @@
"redux-persist": "6.0.0", "redux-persist": "6.0.0",
"swr": "^2.3.6", "swr": "^2.3.6",
"vite-plugin-svgr": "^4.2.0", "vite-plugin-svgr": "^4.2.0",
"zod": "^4.3.6" "zod": "^4.1.11"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "24.5.2", "@types/node": "24.5.2",
@@ -66,6 +63,6 @@
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "6.4.1" "vite": "6.3.6"
} }
} }
-258
View File
@@ -1,258 +0,0 @@
/**
* 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<string, unknown>, prefix = ""): Record<string, string> {
const result: Record<string, string> = {};
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<string, unknown>, fullPath));
} else {
result[fullPath] = String(value);
}
}
return result;
}
/** Set a value at a dot-separated path in a nested object */
function setNestedValue(
obj: Record<string, unknown>,
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<string, unknown>;
}
current[parts[parts.length - 1]] = value;
}
// ── Build mapping indexes from en.json ───────────────────────────────────────
function buildIndexes(enFlat: Record<string, string>) {
const enKeys = Object.keys(enFlat);
// last segment -> en key paths
const lastSegIndex: Record<string, string[]> = {};
// last 2 segments -> en key paths
const suffixIndex: Record<string, string[]> = {};
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<string, string>,
langFlat: Record<string, string>,
indexes: ReturnType<typeof buildIndexes>
): { matches: KeyMatch[]; result: Record<string, unknown> } {
const { lastSegIndex, suffixIndex } = indexes;
const matches: KeyMatch[] = [];
const result: Record<string, unknown> = {};
// Build reverse lookup: for each orphan key in lang, index by last segment and suffix
const langByLastSeg: Record<string, { key: string; value: string }[]> = {};
const langBySuffix: Record<string, { key: string; value: string }[]> = {};
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();
+23 -36
View File
@@ -4,52 +4,39 @@ import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { ThemeProvider } from "@emotion/react"; import { ThemeProvider } from "@emotion/react";
import { CssBaseline, GlobalStyles } from "@mui/material"; import { CssBaseline, GlobalStyles } from "@mui/material";
import { SWRConfig } from "swr";
import { Routes } from "./Routes"; import { Routes } from "./Routes";
import AppLayout from "@/Components/layout/AppLayout"; import AppLayout from "@/Components/layout/AppLayout";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { lightTheme, darkTheme } from "@/Utils/Theme/Theme"; import { lightTheme, darkTheme } from "@/Utils/Theme/Theme";
const swrConfig = {
dedupingInterval: 5000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
shouldRetryOnError: false,
errorRetryCount: 2,
errorRetryInterval: 1000,
focusThrottleInterval: 5000,
};
function App() { function App() {
const mode = useSelector((state: RootState) => state.ui.mode); const mode = useSelector((state: RootState) => state.ui.mode);
const theme = mode === "light" ? lightTheme : darkTheme; const theme = mode === "light" ? lightTheme : darkTheme;
return ( return (
<SWRConfig value={swrConfig}> <ThemeProvider theme={theme}>
<ThemeProvider theme={theme}> <CssBaseline />
<CssBaseline /> <GlobalStyles
<GlobalStyles styles={{
styles={{ body: {
body: { backgroundColor: theme.palette.background.default,
backgroundColor: theme.palette.background.default, },
}, }}
}} />
/> <AppLayout>
<AppLayout> <Routes />
<Routes /> </AppLayout>
</AppLayout> <ToastContainer
<ToastContainer newestOnTop={true}
newestOnTop={true} theme={mode}
theme={mode} style={
style={ {
{ "--toastify-color-progress-light": "#7C8BA1",
"--toastify-color-progress-light": "#7C8BA1", "--toastify-color-progress-dark": "#7C8BA1",
"--toastify-color-progress-dark": "#7C8BA1", } as CSSProperties
} as CSSProperties }
} />
/> </ThemeProvider>
</ThemeProvider>
</SWRConfig>
); );
} }
@@ -32,7 +32,7 @@ export const HeatmapResponseTime = ({
if (!checks || checks.length === 0) return null; if (!checks || checks.length === 0) return null;
const latestChecks = checks.slice(-25); const latestChecks = checks.slice(-25).reverse();
let data: Array<CheckSnapshot | PlaceholderCheck>; let data: Array<CheckSnapshot | PlaceholderCheck>;
if (latestChecks.length !== 25) { if (latestChecks.length !== 25) {
@@ -57,8 +57,7 @@ export const HeatmapResponseTimeTooltip = ({
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)} {formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
</Typography> </Typography>
<Typography> <Typography>
{t("common.labels.responseTime")}:{" "} {t("common.labels.responseTime")}: {check.originalResponseTime.toFixed()} ms
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
</Typography> </Typography>
<Typography textTransform={"capitalize"}> <Typography textTransform={"capitalize"}>
Status:{" "} Status:{" "}
@@ -2,7 +2,6 @@ import { BaseBox } from ".";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { LAYOUT } from "@/Utils/Theme/constants";
import type { ResponsiveStyleValue } from "@mui/system"; import type { ResponsiveStyleValue } from "@mui/system";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
@@ -27,7 +26,7 @@ export const BaseChart = ({
return ( return (
<BaseBox <BaseBox
sx={{ sx={{
padding: padding ?? theme.spacing(LAYOUT.MD), padding: padding ?? theme.spacing(8),
display: "flex", display: "flex",
flex: 1, flex: 1,
width: width, width: width,
@@ -35,13 +34,13 @@ export const BaseChart = ({
}} }}
> >
<Stack <Stack
gap={theme.spacing(LAYOUT.MD)} gap={theme.spacing(8)}
flex={1} flex={1}
> >
<Stack <Stack
direction="row" direction="row"
alignItems={"center"} alignItems={"center"}
gap={theme.spacing(LAYOUT.XS)} gap={theme.spacing(4)}
> >
{icon && ( {icon && (
<BaseBox <BaseBox
@@ -3,7 +3,6 @@ import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert"; import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link"; import Link from "@mui/material/Link";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { import {
ErrorFallback, ErrorFallback,
EmptyFallback, EmptyFallback,
@@ -92,7 +91,7 @@ export const BasePage = ({
return ( return (
<Stack <Stack
spacing={theme.spacing(LAYOUT.LG)} spacing={theme.spacing(10)}
{...props} {...props}
> >
<Breadcrumb breadcrumbOverride={breadcrumbOverride} /> <Breadcrumb breadcrumbOverride={breadcrumbOverride} />
@@ -221,7 +220,7 @@ export const BaseAuthPage = ({
return ( return (
<BasePage <BasePage
breadcrumbOverride={[]} breadcrumbOverride={[]}
gap={theme.spacing(LAYOUT.MD)} gap={theme.spacing(8)}
alignItems={"center"} alignItems={"center"}
justifyContent={"center"} justifyContent={"center"}
minHeight="100vh" minHeight="100vh"
@@ -230,7 +229,7 @@ export const BaseAuthPage = ({
> >
<HeaderAuthControls <HeaderAuthControls
hideLogo hideLogo
py={theme.spacing(LAYOUT.XS)} py={theme.spacing(4)}
position={"absolute"} position={"absolute"}
top={0} top={0}
left={0} left={0}
@@ -244,14 +243,14 @@ export const BaseAuthPage = ({
<Stack alignItems={"center"}> <Stack alignItems={"center"}>
<Typography <Typography
variant="h1" variant="h1"
mb={theme.spacing(SPACING.LG)} mb={theme.spacing(2)}
> >
{title} {title}
</Typography> </Typography>
<Typography variant="h2">{subtitle}</Typography> <Typography variant="h2">{subtitle}</Typography>
</Stack> </Stack>
<Stack <Stack
gap={theme.spacing(LAYOUT.MD)} gap={theme.spacing(8)}
width={{ width={{
xs: "80%", xs: "80%",
md: "25%", md: "25%",
@@ -2,7 +2,6 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { LAYOUT } from "@/Utils/Theme/constants";
export const BulletPointCheck = ({ export const BulletPointCheck = ({
text, text,
@@ -22,7 +21,7 @@ export const BulletPointCheck = ({
return ( return (
<Stack <Stack
direction="row" direction="row"
gap={theme.spacing(LAYOUT.SM)} gap={theme.spacing(6)}
alignItems="center" alignItems="center"
> >
<Check <Check
@@ -2,7 +2,6 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { BulletPointCheck, SkeletonCard } from "@/Components/design-elements"; import { BulletPointCheck, SkeletonCard } from "@/Components/design-elements";
import { Button } from "@/Components/inputs"; import { Button } from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
@@ -28,7 +27,7 @@ export const BaseFallback = ({ children, ...props }: BaseFallbackProps) => {
lg: "50%", lg: "50%",
xl: "40%", xl: "40%",
}} }}
padding={{ xs: theme.spacing(LAYOUT.MD), md: theme.spacing(LAYOUT.XXL) }} padding={{ xs: theme.spacing(8), md: theme.spacing(16) }}
bgcolor={theme.palette.background.paper} bgcolor={theme.palette.background.paper}
border={1} border={1}
borderColor={theme.palette.divider} borderColor={theme.palette.divider}
@@ -56,7 +55,7 @@ export const ErrorFallback = ({
<BaseFallback> <BaseFallback>
<Typography <Typography
variant="h1" variant="h1"
marginY={theme.spacing(LAYOUT.XS)} marginY={theme.spacing(4)}
color={theme.palette.text.secondary} color={theme.palette.text.secondary}
> >
{title} {title}
@@ -82,7 +81,7 @@ export const EmptyFallback = ({
return ( return (
<BaseFallback> <BaseFallback>
<Stack <Stack
gap={theme.spacing(LAYOUT.MD)} gap={theme.spacing(10)}
zIndex={1} zIndex={1}
alignItems="center" alignItems="center"
> >
@@ -90,7 +89,7 @@ export const EmptyFallback = ({
<Stack <Stack
sx={{ sx={{
flexWrap: "wrap", flexWrap: "wrap",
gap: theme.spacing(SPACING.MD), gap: theme.spacing(2),
maxWidth: "1100px", maxWidth: "1100px",
width: "100%", width: "100%",
}} }}
@@ -135,7 +134,7 @@ export const EmptyMonitorFallback = ({
return ( return (
<BaseFallback> <BaseFallback>
<Stack <Stack
gap={theme.spacing(LAYOUT.MD)} gap={theme.spacing(10)}
zIndex={1} zIndex={1}
alignItems="center" alignItems="center"
> >
@@ -148,7 +147,7 @@ export const EmptyMonitorFallback = ({
<Stack <Stack
sx={{ sx={{
flexWrap: "wrap", flexWrap: "wrap",
gap: theme.spacing(SPACING.MD), gap: theme.spacing(2),
maxWidth: "1100px", maxWidth: "1100px",
width: "100%", width: "100%",
}} }}
@@ -3,7 +3,6 @@ import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
export const SplitBox = ({ export const SplitBox = ({
left, left,
right, right,
@@ -19,10 +18,10 @@ export const SplitBox = ({
bgcolor={theme.palette.background.paper} bgcolor={theme.palette.background.paper}
border={1} border={1}
borderColor={theme.palette.divider} borderColor={theme.palette.divider}
borderRadius={theme.shape.borderRadius} borderRadius={theme.spacing(2)}
> >
<Box <Box
padding={theme.spacing(LAYOUT.XXL)} padding={theme.spacing(15)}
borderRight={isSmall ? 0 : 1} borderRight={isSmall ? 0 : 1}
borderBottom={isSmall ? 1 : 0} borderBottom={isSmall ? 1 : 0}
borderColor={theme.palette.divider} borderColor={theme.palette.divider}
@@ -38,7 +37,7 @@ export const SplitBox = ({
</Box> </Box>
<Box <Box
flex={1} flex={1}
padding={theme.spacing(LAYOUT.XXL)} padding={theme.spacing(15)}
> >
{right} {right}
</Box> </Box>
@@ -61,7 +60,7 @@ export const ConfigBox = ({
return ( return (
<SplitBox <SplitBox
left={ left={
<Stack spacing={theme.spacing(LAYOUT.XS)}> <Stack spacing={theme.spacing(4)}>
<Typography <Typography
textTransform={"capitalize"} textTransform={"capitalize"}
component="h2" component="h2"
+10 -11
View File
@@ -29,7 +29,6 @@ import type { TablePaginationOwnProps } from "@mui/material/TablePagination";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState, Fragment, useEffect } from "react"; import { useState, Fragment, useEffect } from "react";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import type { SxProps, Theme } from "@mui/material/styles"; import type { SxProps, Theme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
@@ -91,20 +90,20 @@ export function DataTable<
// Return stack of cards for small screens // Return stack of cards for small screens
if (isSmall && cardsOnSmallScreens) { if (isSmall && cardsOnSmallScreens) {
return ( return (
<Stack spacing={theme.spacing(LAYOUT.XS)}> <Stack spacing={theme.spacing(4)}>
{data.map((row) => { {data.map((row) => {
const key = row.id || row._id || Math.random(); const key = row.id || row._id || Math.random();
keys.push(key); keys.push(key);
return ( return (
<Stack <Stack
onClick={() => (onRowClick ? onRowClick(row) : null)} onClick={() => (onRowClick ? onRowClick(row) : null)}
spacing={theme.spacing(LAYOUT.XS)} spacing={theme.spacing(4)}
sx={{ sx={{
borderStyle: "solid", borderStyle: "solid",
borderWidth: 1, borderWidth: 1,
borderColor: theme.palette.divider, borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
padding: theme.spacing(LAYOUT.XS), padding: theme.spacing(4),
cursor: onRowClick ? "pointer" : "default", cursor: onRowClick ? "pointer" : "default",
}} }}
key={key} key={key}
@@ -156,7 +155,7 @@ export function DataTable<
<Table <Table
sx={{ sx={{
"&.MuiTable-root :is(.MuiTableHead-root, .MuiTableBody-root) :is(th, td)": { "&.MuiTable-root :is(.MuiTableHead-root, .MuiTableBody-root) :is(th, td)": {
paddingLeft: theme.spacing(LAYOUT.MD), paddingLeft: theme.spacing(8),
}, },
"& .MuiTableCell-root": { "& .MuiTableCell-root": {
borderBottom: `1px solid ${theme.palette.divider}`, borderBottom: `1px solid ${theme.palette.divider}`,
@@ -169,13 +168,13 @@ export function DataTable<
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
fontWeight: 500, fontWeight: 500,
textTransform: "uppercase", textTransform: "uppercase",
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`, padding: `${theme.spacing(2)} ${theme.spacing(8)}`,
fontSize: theme.typography.fontSize, fontSize: theme.typography.fontSize,
}, },
"& :is(td)": { "& :is(td)": {
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
padding: `${theme.spacing(LAYOUT.MD)} ${theme.spacing(LAYOUT.MD)}`, padding: `${theme.spacing(7)} ${theme.spacing(8)}`,
fontSize: theme.typography.fontSize, fontSize: theme.typography.fontSize,
overflowX: "hidden", overflowX: "hidden",
}, },
@@ -515,7 +514,7 @@ export const Pagination = ({ ...props }: PaginationProps) => {
display: "grid", display: "grid",
gridTemplateColumns: "1fr 1fr", gridTemplateColumns: "1fr 1fr",
gridAutoRows: "auto", gridAutoRows: "auto",
rowGap: theme.spacing(SPACING.SM), rowGap: theme.spacing(1),
alignItems: "center", alignItems: "center",
justifyItems: "center", justifyItems: "center",
paddingLeft: 0, paddingLeft: 0,
@@ -557,8 +556,8 @@ const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) =>
alignItems={"center"} alignItems={"center"}
justifyContent={"center"} justifyContent={"center"}
sx={{ sx={{
py: theme.spacing(LAYOUT.XL), py: theme.spacing(12),
px: theme.spacing(LAYOUT.XS), px: theme.spacing(4),
borderWidth: 1, borderWidth: 1,
borderStyle: "solid", borderStyle: "solid",
borderColor: theme.palette.divider, borderColor: theme.palette.divider,
@@ -575,7 +574,7 @@ const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) =>
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
mb: theme.spacing(SPACING.LG), mb: theme.spacing(2),
}} }}
> >
<Icon <Icon
+3 -19
View File
@@ -11,26 +11,20 @@ import type { ResponsiveStyleValue } from "@mui/system";
interface SliderInputProps extends SliderProps { interface SliderInputProps extends SliderProps {
sx?: SliderProps["sx"]; sx?: SliderProps["sx"];
showValue?: boolean; showValue?: boolean;
formatDisplayValue?: (value: number) => string;
} }
export const SliderInput = forwardRef<HTMLSpanElement, SliderInputProps>( export const SliderInput = forwardRef<HTMLSpanElement, SliderInputProps>(
({ sx, showValue = false, formatDisplayValue, ...props }, ref) => { ({ sx, showValue = false, ...props }, ref) => {
const theme = useTheme(); const theme = useTheme();
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : []; const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
const displayValue =
showValue && formatDisplayValue && typeof props.value === "number"
? formatDisplayValue(props.value)
: props.value;
return ( return (
<Stack <Stack
gap={theme.spacing(8)} gap={theme.spacing(8)}
direction={"row"} direction={"row"}
alignItems={"center"} alignItems={"center"}
> >
{showValue && <Typography>{displayValue}</Typography>} {showValue && <Typography>{props.value}</Typography>}
<Slider <Slider
{...props} {...props}
ref={ref} ref={ref}
@@ -69,21 +63,12 @@ interface SliderWithLabelProps extends SliderProps {
fieldLabel?: string; fieldLabel?: string;
required?: boolean; required?: boolean;
showValue?: boolean; showValue?: boolean;
formatDisplayValue?: (value: number) => string;
sliderMaxWidth?: ResponsiveStyleValue<number | string>; sliderMaxWidth?: ResponsiveStyleValue<number | string>;
} }
export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>( export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>(
( (
{ { fieldLabel, required, showValue = true, value, sliderMaxWidth = "100%", ...props },
fieldLabel,
required,
showValue = true,
formatDisplayValue,
value,
sliderMaxWidth = "100%",
...props
},
ref ref
) => { ) => {
const theme = useTheme(); const theme = useTheme();
@@ -97,7 +82,6 @@ export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>
<SliderInput <SliderInput
{...props} {...props}
showValue={showValue} showValue={showValue}
formatDisplayValue={formatDisplayValue}
value={value} value={value}
ref={ref} ref={ref}
/> />
@@ -1,6 +1,9 @@
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import BackgroundSVG from "@/assets/Images/background.svg";
import type { RootState } from "@/Types/state";
import { OfflineBanner } from "@/Components/design-elements"; import { OfflineBanner } from "@/Components/design-elements";
import { setServerUnreachableCallback, get } from "@/Utils/ApiClient"; import { setServerUnreachableCallback, get } from "@/Utils/ApiClient";
@@ -10,6 +13,7 @@ interface AppLayoutProps {
const AppLayout = ({ children }: AppLayoutProps) => { const AppLayout = ({ children }: AppLayoutProps) => {
const theme = useTheme(); const theme = useTheme();
const mode = useSelector((state: RootState) => state.ui.mode);
const [serverUnreachable, setServerUnreachable] = useState(false); const [serverUnreachable, setServerUnreachable] = useState(false);
const retryIntervalRef = useRef<number | null>(null); const retryIntervalRef = useRef<number | null>(null);
@@ -43,6 +47,10 @@ const AppLayout = ({ children }: AppLayoutProps) => {
sx={{ sx={{
minHeight: "100vh", minHeight: "100vh",
backgroundColor: theme.palette.background.default, backgroundColor: theme.palette.background.default,
backgroundImage: mode === "dark" ? `url("${BackgroundSVG}")` : "none",
backgroundSize: "100% 100%",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}} }}
> >
<OfflineBanner visible={serverUnreachable} /> <OfflineBanner visible={serverUnreachable} />
@@ -5,18 +5,8 @@ import useMediaQuery from "@mui/material/useMediaQuery";
import type { MonitorType } from "@/Types/Monitor"; import type { MonitorType } from "@/Types/Monitor";
import { Typography, useTheme } from "@mui/material"; import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"]; const types = ["http", "ping", "port", "docker", "game"];
const typeDisplayNames: Record<string, string> = {
http: "HTTP",
ping: "Ping",
port: "Port",
docker: "Docker",
game: "Game",
grpc: "gRPC",
websocket: "WebSocket",
};
const statuses = ["up", "down"]; const statuses = ["up", "down"];
const states = ["active", "paused"]; const states = ["active", "paused"];
@@ -40,7 +30,6 @@ export const ControlsFilter = ({
onClearFilters: () => void; onClearFilters: () => void;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isFilterActive = const isFilterActive =
(selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== ""; (selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== "";
@@ -61,7 +50,7 @@ export const ControlsFilter = ({
key={type} key={type}
value={type} value={type}
> >
<Typography>{typeDisplayNames[type] ?? type}</Typography> <Typography textTransform={"capitalize"}>{type}</Typography>
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
@@ -99,7 +88,7 @@ export const ControlsFilter = ({
variant="contained" variant="contained"
onClick={onClearFilters} onClick={onClearFilters}
> >
{t("common.buttons.clearFilters")} Clear Filters
</Button> </Button>
)} )}
</Stack> </Stack>
@@ -1,203 +0,0 @@
import { useRef, useEffect, useState } from "react";
import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
import Map, { Marker, Popup } from "react-map-gl/maplibre";
import type { MapRef } from "react-map-gl/maplibre";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
import "maplibre-gl/dist/maplibre-gl.css";
interface GeoChecksMapProps {
geoChecks: FlatGeoCheck[];
}
export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
const mapRef = useRef<MapRef>(null);
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const mapPopupStyles = (
<GlobalStyles
styles={{
".maplibregl-popup-content": {
background: "transparent !important",
padding: "0 !important",
boxShadow: "none !important",
},
".maplibregl-popup-tip": {
display: "none",
},
".maplibregl-ctrl-attrib": {
display: "none",
},
".maplibregl-ctrl-logo": {
display: "none",
},
".maplibregl-popup-close-button": {
color: theme.palette.text.primary,
background: "transparent",
fontSize: "20px",
padding: "4px",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
},
}}
/>
);
useEffect(() => {
if (geoChecks.length === 0 || !mapRef.current) return;
const bounds = geoChecks.reduce(
(acc, check) => {
return {
minLng: Math.min(acc.minLng, check.location.longitude),
maxLng: Math.max(acc.maxLng, check.location.longitude),
minLat: Math.min(acc.minLat, check.location.latitude),
maxLat: Math.max(acc.maxLat, check.location.latitude),
};
},
{
minLng: Infinity,
maxLng: -Infinity,
minLat: Infinity,
maxLat: -Infinity,
}
);
if (bounds.minLng !== Infinity) {
mapRef.current.fitBounds(
[
[bounds.minLng, bounds.minLat],
[bounds.maxLng, bounds.maxLat],
],
{ padding: 50, duration: 1000 }
);
}
}, [geoChecks]);
const getMarkerColor = (status: boolean): string => {
return status ? theme.palette.success.main : theme.palette.error.main;
};
const formatResponseTime = (timing: number): string => {
return `${timing.toFixed(0)}ms`;
};
return (
<>
{mapPopupStyles}
<Box
height={500}
width={"100%"}
borderRadius={theme.shape.borderRadius}
overflow={"hidden"}
>
<Map
ref={mapRef}
initialViewState={{
longitude: 0,
latitude: 20,
zoom: 1.5,
}}
style={{ height: "100%", width: "100%" }}
mapStyle={
isDarkMode
? "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
}
>
{geoChecks.map((check, index) => (
<Marker
key={`${check.id}-${index}`}
longitude={check.location.longitude}
latitude={check.location.latitude}
anchor="bottom"
onClick={(e: any) => {
e.originalEvent.stopPropagation();
setSelectedCheck(check);
}}
>
<div
style={{
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: getMarkerColor(check.status),
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
cursor: "pointer",
}}
/>
</Marker>
))}
{selectedCheck && (
<Popup
longitude={selectedCheck.location.longitude}
latitude={selectedCheck.location.latitude}
anchor="top"
onClose={() => setSelectedCheck(null)}
closeOnClick={false}
>
<Box
sx={{
minWidth: 200,
p: theme.spacing(4),
bgcolor: theme.palette.background.paper,
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: 1,
}}
>
<Typography
variant="subtitle1"
fontWeight="bold"
gutterBottom
>
{selectedCheck.location.city}, {selectedCheck.location.country}
</Typography>
<Stack spacing={0.5}>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Status:
</Typography>{" "}
{selectedCheck.status ? "Up" : "Down"}
</Typography>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Status Code:
</Typography>{" "}
{selectedCheck.statusCode}
</Typography>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Response Time:
</Typography>{" "}
{formatResponseTime(selectedCheck.timings.total)}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 0.5 }}
>
{new Date(selectedCheck.createdAt).toLocaleString()}
</Typography>
</Stack>
</Box>
</Popup>
)}
</Map>
</Box>
</>
);
};
@@ -1,54 +0,0 @@
import Stack from "@mui/material/Stack";
import { Tabs, Tab } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { GeoContinent } from "@/Types/GeoCheck";
interface HeaderGeoTabsProps {
geoCheckEnabled: boolean;
locations: GeoContinent[] | undefined;
selectedLocation: GeoContinent;
onLocationChange: (location: GeoContinent) => void;
}
export const HeaderGeoTabs = ({
geoCheckEnabled,
locations,
selectedLocation,
onLocationChange,
}: HeaderGeoTabsProps) => {
const { t } = useTranslation();
const theme = useTheme();
if (!geoCheckEnabled || !locations || locations.length === 0) {
return null;
}
const handleChange = (_event: React.SyntheticEvent, newValue: GeoContinent) => {
onLocationChange(newValue);
};
return (
<Stack
spacing={{ xs: theme.spacing(8), md: 0 }}
direction={{ xs: "column", md: "row" }}
alignItems={"center"}
justifyContent={"flex-start"}
>
<Tabs
value={selectedLocation}
onChange={handleChange}
>
{locations.map((location) => (
<Tab
key={location}
label={t(
`pages.createMonitor.form.geoChecks.option.locations.options.${location}`
)}
value={location}
/>
))}
</Tabs>
</Stack>
);
};
@@ -25,7 +25,10 @@ export const MonitorStatBoxes = ({
} }
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0; const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0; const timeSinceLastFailure =
timeOfLastFailure > 0
? Date.now() - timeOfLastFailure
: Date.now() - new Date(monitorStats?.createdAt || 0).getTime();
// Determine time since last check // Determine time since last check
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0; const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
@@ -39,12 +42,6 @@ export const MonitorStatBoxes = ({
const streakTime = prettyMilliseconds(timeSinceLastFailure, options); const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options); const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
const isActive =
monitor?.status === "up" ||
monitor?.status === "paused" ||
monitor?.status === "maintenance" ||
monitor?.status === "initializing" ||
monitor?.status === "breached";
const palette = getStatusPalette(monitor?.status); const palette = getStatusPalette(monitor?.status);
return ( return (
@@ -54,12 +51,8 @@ export const MonitorStatBoxes = ({
> >
<StatBox <StatBox
palette={palette} palette={palette}
title={ title={t("pages.common.monitors.statBoxes.activeFor")}
isActive subtitle={streakTime}
? t("pages.common.monitors.statBoxes.activeFor")
: t("pages.common.monitors.statBoxes.serviceIsDown")
}
subtitle={isActive ? streakTime : ""}
/> />
<StatBox <StatBox
title={t("pages.common.monitors.statBoxes.lastCheck")} title={t("pages.common.monitors.statBoxes.lastCheck")}
@@ -12,7 +12,7 @@ import {
import { HistogramPageSpeedScoresTooltip } from "@/Components/monitors"; import { HistogramPageSpeedScoresTooltip } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import type { PageSpeedGroupedCheck } from "@/Types/Check"; import type { CheckSnapshot } from "@/Types/Check";
import type { Palette } from "@mui/material/styles"; import type { Palette } from "@mui/material/styles";
type PaletteColorKey = Extract< type PaletteColorKey = Extract<
keyof Palette, keyof Palette,
@@ -52,7 +52,7 @@ export const HistogramPageSpeedDetails = ({
checks, checks,
range, range,
}: { }: {
checks: PageSpeedGroupedCheck[]; checks: CheckSnapshot[];
range: string; range: string;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
@@ -73,7 +73,7 @@ export const HistogramPageSpeedDetails = ({
> >
<AreaChart data={checks}> <AreaChart data={checks}>
<XAxis <XAxis
dataKey={"bucketDate"} dataKey={"createdAt"}
tick={(props) => ( tick={(props) => (
<XTick <XTick
{...props} {...props}
@@ -2,7 +2,6 @@ import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { formatDateWithTz } from "@/Utils/TimeUtils"; import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { ConfigItem } from "@/Components/monitors"; import type { ConfigItem } from "@/Components/monitors";
import type { TooltipProps } from "recharts"; import type { TooltipProps } from "recharts";
@@ -31,8 +30,8 @@ export const HistogramPageSpeedScoresTooltip = ({
border: 1, border: 1,
borderColor: theme.palette.divider, borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
py: theme.spacing(SPACING.LG), py: theme.spacing(2),
px: theme.spacing(LAYOUT.XS), px: theme.spacing(4),
}} }}
> >
<Typography <Typography
@@ -55,12 +54,12 @@ export const HistogramPageSpeedScoresTooltip = ({
key={`${key}-tooltip`} key={`${key}-tooltip`}
direction="row" direction="row"
alignItems="center" alignItems="center"
gap={theme.spacing(SPACING.XXL)} gap={theme.spacing(3)}
mt={theme.spacing(SPACING.SM)} mt={theme.spacing(1)}
> >
<Box <Box
width={theme.spacing(LAYOUT.XS)} width={theme.spacing(4)}
height={theme.spacing(LAYOUT.XS)} height={theme.spacing(4)}
sx={{ borderRadius: "50%", backgroundColor: dotColor }} sx={{ borderRadius: "50%", backgroundColor: dotColor }}
/> />
<Typography <Typography
-2
View File
@@ -1,8 +1,6 @@
export * from "./ControlsFilter"; export * from "./ControlsFilter";
export * from "./MonitorStatBoxes"; export * from "./MonitorStatBoxes";
export * from "./HeaderMonitorControls"; export * from "./HeaderMonitorControls";
export * from "./HeaderGeoTabs";
export * from "./GeoChecksMap";
export * from "./charts/HistogramStatus"; export * from "./charts/HistogramStatus";
export * from "./charts/RadialAvgResponse"; export * from "./charts/RadialAvgResponse";
export * from "./charts/HistogramDetails"; export * from "./charts/HistogramDetails";
+8 -14
View File
@@ -5,7 +5,6 @@ import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu"; import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
@@ -65,9 +64,9 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
}); });
const menuItemSx = { const menuItemSx = {
gap: theme.spacing(LAYOUT.MD), gap: theme.spacing(2),
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
pl: theme.spacing(LAYOUT.XS), pl: theme.spacing(4),
"& svg": { stroke: theme.palette.text.secondary }, "& svg": { stroke: theme.palette.text.secondary },
}; };
@@ -75,9 +74,9 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
<Stack <Stack
direction="row" direction="row"
alignItems="center" alignItems="center"
py={theme.spacing(LAYOUT.XS)} py={theme.spacing(4)}
px={theme.spacing(LAYOUT.MD)} px={theme.spacing(8)}
gap={theme.spacing(LAYOUT.MD)} gap={theme.spacing(4)}
> >
<Avatar <Avatar
small small
@@ -89,7 +88,7 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
<Stack <Stack
direction="row" direction="row"
alignItems="center" alignItems="center"
gap={theme.spacing(SPACING.MD)} gap={theme.spacing(2)}
minWidth={0} minWidth={0}
flex={1} flex={1}
> >
@@ -133,15 +132,10 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
disableScrollLock disableScrollLock
anchorOrigin={{ vertical: "top", horizontal: "right" }} anchorOrigin={{ vertical: "top", horizontal: "right" }}
slotProps={{ slotProps={{
paper: { paper: { sx: { mt: theme.spacing(-4), ml: collapsed ? theme.spacing(2) : 0 } },
sx: {
mt: theme.spacing(-LAYOUT.XS),
ml: collapsed ? theme.spacing(SPACING.LG) : 0,
},
},
}} }}
MenuListProps={{ sx: { p: 2, "& li": { m: 0 } } }} MenuListProps={{ sx: { p: 2, "& li": { m: 0 } } }}
sx={{ ml: theme.spacing(LAYOUT.XS) }} sx={{ ml: theme.spacing(4) }}
> >
{collapsed && ( {collapsed && (
<Box <Box
+12 -10
View File
@@ -1,7 +1,6 @@
import { Typography, IconButton, Stack, Box } from "@mui/material"; import { Typography, IconButton, Stack, Box } from "@mui/material";
import { Icon } from "@/Components/design-elements"; import { Icon } from "@/Components/design-elements";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
@@ -35,22 +34,25 @@ export const StarPrompt = ({
return ( return (
<Stack <Stack
direction="column" direction="column"
gap={theme.spacing(LAYOUT.MD)} sx={{
padding={theme.spacing(LAYOUT.SM)} width: "100%",
width={"100%"} padding: `${theme.spacing(6)} ${theme.spacing(6)}`,
borderTop={`1px solid ${theme.palette.divider}`} borderTop: `1px solid ${theme.palette.divider}`,
borderBottom={`1px solid ${theme.palette.divider}`} borderBottom: `1px solid ${theme.palette.divider}`,
borderRadius: 0,
gap: theme.spacing(1.5),
}}
> >
<Stack <Stack
direction="row" direction="row"
justifyContent="space-between" justifyContent="space-between"
alignItems="center" alignItems="center"
width="100%" width="100%"
pl={theme.spacing(LAYOUT.XS)} pl={theme.spacing(4)}
> >
<Typography <Typography
variant="subtitle2" variant="subtitle2"
mt={theme.spacing(SPACING.XXL)} mt={theme.spacing(3)}
> >
{t("components.sidebar.starPrompt.title")} {t("components.sidebar.starPrompt.title")}
</Typography> </Typography>
@@ -76,7 +78,7 @@ export const StarPrompt = ({
<Typography <Typography
sx={{ sx={{
mb: 1, mb: 1,
px: theme.spacing(LAYOUT.XS), px: theme.spacing(4),
}} }}
> >
{t("components.sidebar.starPrompt.description")} {t("components.sidebar.starPrompt.description")}
@@ -94,7 +96,7 @@ export const StarPrompt = ({
"&:hover": { "&:hover": {
opacity: 0.8, opacity: 0.8,
}, },
pl: theme.spacing(LAYOUT.XS), pl: theme.spacing(4),
filter: mode === "dark" ? "invert(1)" : "none", filter: mode === "dark" ? "invert(1)" : "none",
}} }}
/> />
+7 -8
View File
@@ -3,7 +3,6 @@ import Backdrop from "@mui/material/Backdrop";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import List from "@mui/material/List"; import List from "@mui/material/List";
import Divider from "@mui/material/Divider"; import Divider from "@mui/material/Divider";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useSidebar } from "@/Hooks/useSidebar.js"; import { useSidebar } from "@/Hooks/useSidebar.js";
import { Logo } from "@/Components/sidebar/Logo"; import { Logo } from "@/Components/sidebar/Logo";
import { getMenu, getBottomMenu, getAccountMenu } from "@/Components/sidebar/Menu"; import { getMenu, getBottomMenu, getAccountMenu } from "@/Components/sidebar/Menu";
@@ -66,9 +65,9 @@ export const Sidebar = () => {
left={0} left={0}
minHeight={"100vh"} minHeight={"100vh"}
maxHeight={"100vh"} maxHeight={"100vh"}
paddingTop={theme.spacing(LAYOUT.SM)} paddingTop={theme.spacing(6)}
paddingBottom={theme.spacing(LAYOUT.SM)} paddingBottom={theme.spacing(6)}
gap={theme.spacing(LAYOUT.SM)} gap={theme.spacing(6)}
borderRight={`1px solid ${theme.palette.divider}`} borderRight={`1px solid ${theme.palette.divider}`}
width={width} width={width}
sx={{ sx={{
@@ -82,13 +81,13 @@ export const Sidebar = () => {
component="nav" component="nav"
disablePadding disablePadding
sx={{ sx={{
px: theme.spacing(LAYOUT.SM), px: theme.spacing(6),
flex: 1, flex: 1,
}} }}
> >
<Logo <Logo
pt={theme.spacing(LAYOUT.MD)} pt={theme.spacing(8)}
pb={theme.spacing(LAYOUT.MD)} pb={theme.spacing(10)}
/> />
{menu.map((item) => { {menu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`); const selected = location.pathname.startsWith(`/${item.path}`);
@@ -106,7 +105,7 @@ export const Sidebar = () => {
<List <List
component="nav" component="nav"
disablePadding disablePadding
sx={{ px: theme.spacing(LAYOUT.SM) }} sx={{ px: theme.spacing(6) }}
> >
{bottomMenu.map((item) => { {bottomMenu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`); const selected = location.pathname.startsWith(`/${item.path}`);
+6 -7
View File
@@ -4,7 +4,6 @@ import type { AxiosRequestConfig } from "axios";
import { get, patch, post, put, deleteOp } from "@/Utils/ApiClient"; import { get, patch, post, put, deleteOp } from "@/Utils/ApiClient";
import { useState } from "react"; import { useState } from "react";
import { useToast } from "@/Hooks/UseToast"; import { useToast } from "@/Hooks/UseToast";
import { logger } from "@/Utils/logger";
interface ApiResponse<T> { interface ApiResponse<T> {
success: boolean; success: boolean;
@@ -18,7 +17,7 @@ const fetcher = async <T>(url: string, config?: AxiosRequestConfig) => {
}; };
export const useGet = <T>( export const useGet = <T>(
url: string | null | undefined, url: string | null,
axiosConfig?: AxiosRequestConfig, axiosConfig?: AxiosRequestConfig,
swrConfig?: SWRConfiguration swrConfig?: SWRConfiguration
) => { ) => {
@@ -62,7 +61,7 @@ export const usePost = <B = any, R = any>() => {
return res.data; return res.data;
} catch (err: any) { } catch (err: any) {
const errMsg = err?.response?.data?.msg || err.message || "An error occurred"; const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
logger.error("POST request failed", err, { endpoint, body }); console.error(err);
toastError(errMsg); toastError(errMsg);
setError(errMsg); setError(errMsg);
return null; return null;
@@ -97,7 +96,7 @@ export const usePatch = <B = any, R = any>() => {
toastSuccess(res.data?.msg || "Operation successful"); toastSuccess(res.data?.msg || "Operation successful");
return res.data; return res.data;
} catch (err: any) { } catch (err: any) {
logger.error("PATCH request failed", err, { endpoint, body }); console.error(err);
const errMsg = err?.response?.data?.msg || err.message || "An error occurred"; const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg); toastError(errMsg);
setError(errMsg); setError(errMsg);
@@ -133,7 +132,7 @@ export const usePut = <B = any, R = any>() => {
toastSuccess(res.data?.msg || "Operation successful"); toastSuccess(res.data?.msg || "Operation successful");
return res.data; return res.data;
} catch (err: any) { } catch (err: any) {
logger.error("PUT request failed", err, { endpoint, body }); console.error(err);
const errMsg = err?.response?.data?.msg || err.message || "An error occurred"; const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg); toastError(errMsg);
setError(errMsg); setError(errMsg);
@@ -168,7 +167,7 @@ export const useDelete = <R = any>() => {
toastSuccess(res.data?.msg || "Operation successful"); toastSuccess(res.data?.msg || "Operation successful");
return res.data; return res.data;
} catch (err: any) { } catch (err: any) {
logger.error("DELETE request failed", err, { endpoint }); console.error(err);
const errMsg = err?.response?.data?.message || err.message || "An error occurred"; const errMsg = err?.response?.data?.message || err.message || "An error occurred";
toastError(errMsg); toastError(errMsg);
setError(errMsg); setError(errMsg);
@@ -202,7 +201,7 @@ export const useLazyGet = <R = any>() => {
}); });
return res.data; return res.data;
} catch (err: any) { } catch (err: any) {
logger.error("GET request failed", err, { endpoint }); console.error(err);
const errMsg = err?.response?.data?.msg || err.message || "An error occurred"; const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg); toastError(errMsg);
setError(errMsg); setError(errMsg);
-21
View File
@@ -14,9 +14,6 @@ const getBaseDefaults = (data?: Monitor | null) => ({
notifications: data?.notifications || [], notifications: data?.notifications || [],
statusWindowSize: data?.statusWindowSize || 5, statusWindowSize: data?.statusWindowSize || 5,
statusWindowThreshold: data?.statusWindowThreshold || 60, statusWindowThreshold: data?.statusWindowThreshold || 60,
geoCheckEnabled: data?.geoCheckEnabled ?? false,
geoCheckLocations: data?.geoCheckLocations || [],
geoCheckInterval: data?.geoCheckInterval || 300000,
}); });
export const useMonitorForm = ({ export const useMonitorForm = ({
@@ -73,16 +70,6 @@ export const useMonitorForm = ({
gameId: data?.gameId || "", gameId: data?.gameId || "",
}; };
break; break;
case "grpc":
defaults = {
...base,
type: "grpc",
url: data?.url || "",
port: data?.port || 50051,
grpcServiceName: data?.grpcServiceName || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
case "pagespeed": case "pagespeed":
defaults = { defaults = {
...base, ...base,
@@ -103,14 +90,6 @@ export const useMonitorForm = ({
selectedDisks: data?.selectedDisks || [], selectedDisks: data?.selectedDisks || [],
}; };
break; break;
case "websocket":
defaults = {
...base,
type: "websocket",
url: data?.url || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
default: default:
defaults = { defaults = {
...base, ...base,
+15 -81
View File
@@ -1,94 +1,28 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { notificationSchema } from "@/Validation/notifications"; import { notificationSchema } from "@/Validation/notifications";
import type { NotificationFormData } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification"; import type { Notification } from "@/Types/Notification";
interface UseNotificationFormOptions { interface UseNotificationFormOptions {
data?: Notification | null; data?: Notification | null;
} }
function buildDefaults(data: Notification | null): NotificationFormData {
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "telegram") {
return {
type: "telegram",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "twilio") {
return {
type: "twilio",
notificationName: data.notificationName || "",
accountSid: data.accountSid || "",
accessToken: data.accessToken || "",
phone: data.phone || "",
twilioPhoneNumber: data.twilioPhoneNumber || "",
};
}
if (data?.type === "pushover") {
return {
type: "pushover",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
notificationName: data?.notificationName || "",
address: data?.address || "",
};
}
export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => { export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => {
return useMemo(() => { return useMemo(() => {
const defaults = buildDefaults(data); const defaults =
data?.type === "matrix"
? {
type: "matrix" as const,
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
}
: {
type: (data?.type || "email") as Exclude<Notification["type"], "matrix">,
notificationName: data?.notificationName || "",
address: data?.address || "",
};
return { schema: notificationSchema, defaults }; return { schema: notificationSchema, defaults };
}, [data]); }, [data]);
}; };
+6 -18
View File
@@ -1,14 +1,14 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { settingsSchema } from "@/Validation/settings"; import { settingsSchema } from "@/Validation/settings";
import type { Settings } from "@/Types/Settings"; import type { Settings } from "@/Types/Settings";
import type { SettingsFormInput } from "@/Validation/settings"; import type { SettingsFormData } from "@/Validation/settings";
interface UseSettingsFormOptions { interface UseSettingsFormOptions {
data?: Settings | null; data?: Settings | null;
} }
export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => { export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => {
return useMemo(() => { return useMemo(() => {
const defaults: SettingsFormInput = { const defaults: SettingsFormData = {
systemEmailIgnoreTLS: data?.systemEmailIgnoreTLS ?? false, systemEmailIgnoreTLS: data?.systemEmailIgnoreTLS ?? false,
systemEmailRequireTLS: data?.systemEmailRequireTLS ?? false, systemEmailRequireTLS: data?.systemEmailRequireTLS ?? false,
systemEmailRejectUnauthorized: data?.systemEmailRejectUnauthorized ?? true, systemEmailRejectUnauthorized: data?.systemEmailRejectUnauthorized ?? true,
@@ -22,22 +22,10 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
systemEmailTLSServername: data?.systemEmailTLSServername || "", systemEmailTLSServername: data?.systemEmailTLSServername || "",
systemEmailPort: data?.systemEmailPort, systemEmailPort: data?.systemEmailPort,
globalThresholds: { globalThresholds: {
cpu: cpu: data?.globalThresholds?.cpu ?? 0,
data?.globalThresholds?.cpu && data.globalThresholds.cpu >= 1 memory: data?.globalThresholds?.memory ?? 0,
? data.globalThresholds.cpu disk: data?.globalThresholds?.disk ?? 0,
: 80, temperature: data?.globalThresholds?.temperature ?? 0,
memory:
data?.globalThresholds?.memory && data.globalThresholds.memory >= 1
? data.globalThresholds.memory
: 80,
disk:
data?.globalThresholds?.disk && data.globalThresholds.disk >= 1
? data.globalThresholds.disk
: 80,
temperature:
data?.globalThresholds?.temperature && data.globalThresholds.temperature >= 1
? data.globalThresholds.temperature
: 80,
}, },
checkTTL: data?.checkTTL ?? 30, checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "", pagespeedApiKey: "",
-2
View File
@@ -27,14 +27,12 @@ export const useStatusPageForm = ({
companyName: data?.companyName || "", companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(), url: data?.url || generateDefaultUrl(),
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
type: data?.type || ["uptime"],
color: data?.color || "#4169E1", color: data?.color || "#4169E1",
monitors: data?.monitors || [], monitors: data?.monitors || [],
isPublished: data?.isPublished ?? false, isPublished: data?.isPublished ?? false,
showCharts: data?.showCharts ?? true, showCharts: data?.showCharts ?? true,
showUptimePercentage: data?.showUptimePercentage ?? true, showUptimePercentage: data?.showUptimePercentage ?? true,
showAdminLoginLink: data?.showAdminLoginLink ?? false, showAdminLoginLink: data?.showAdminLoginLink ?? false,
showInfrastructure: data?.showInfrastructure ?? false,
customCSS: data?.customCSS || "", customCSS: data?.customCSS || "",
logo: transformLogo(data?.logo), logo: transformLogo(data?.logo),
}; };
+11 -64
View File
@@ -11,17 +11,12 @@ import { ConfigBox, BasePage } from "@/Components/design-elements";
import { UserRoles } from "@/Types/User"; import { UserRoles } from "@/Types/User";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { UserRole, User } from "@/Types/User"; import type { UserRole, User } from "@/Types/User";
import { useParams, useNavigate } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useEditUserForm } from "@/Hooks/useEditUserForm"; import { useEditUserForm } from "@/Hooks/useEditUserForm";
import { useGet, usePatch, useDelete } from "@/Hooks/UseApi"; import { useGet, usePatch } from "@/Hooks/UseApi";
import type { EditUserFormData } from "@/Validation/editUser"; import type { EditUserFormData } from "@/Validation/editUser";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { DialogInput } from "@/Components/inputs/Dialog";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
interface RoleOption { interface RoleOption {
id: UserRole; id: UserRole;
@@ -34,12 +29,6 @@ const EditUserPage = () => {
const { userId } = useParams<{ userId: string }>(); const { userId } = useParams<{ userId: string }>();
const { resolver, defaults } = useEditUserForm(); const { resolver, defaults } = useEditUserForm();
const { patch, loading: isSaving } = usePatch(); const { patch, loading: isSaving } = usePatch();
const { deleteFn, loading: isDeleting } = useDelete();
const navigate = useNavigate();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const currentUser = useSelector((state: RootState) => state.auth.user);
const isSuperAdmin = useIsSuperAdmin();
const isAdmin = useIsAdmin();
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`); const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
@@ -71,20 +60,6 @@ const EditUserPage = () => {
setValue("role", newRoles, { shouldValidate: true }); setValue("role", newRoles, { shouldValidate: true });
}; };
const canDeleteUser =
isAdmin &&
userId !== currentUser?.id &&
!user?.role?.includes("demo") &&
(isSuperAdmin || user?.role?.every((r) => r !== "admin" && r !== "superadmin"));
const handleDeleteUser = async () => {
const result = await deleteFn(`/auth/users/${userId}`);
setShowDeleteDialog(false);
if (result) {
navigate("/account", { state: { tab: "team" } });
}
};
const onSubmit = async (data: EditUserFormData) => { const onSubmit = async (data: EditUserFormData) => {
await patch(`/auth/users/${userId}`, data); await patch(`/auth/users/${userId}`, data);
}; };
@@ -190,45 +165,17 @@ const EditUserPage = () => {
</Stack> </Stack>
} }
/> />
<Stack <Button
gap={LAYOUT.XS} type="submit"
direction="row" variant="contained"
justifyContent={"flex-end"} color="primary"
width="100%" loading={isSaving}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
> >
{canDeleteUser && ( {t("common.buttons.save")}
<Button </Button>
variant="contained"
color="error"
onClick={() => setShowDeleteDialog(true)}
sx={{ minWidth: 100 }}
>
{t("common.buttons.removeUser")}
</Button>
)}
<Button
type="submit"
variant="contained"
color="primary"
loading={isSaving}
sx={{ minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
</Stack> </Stack>
</form> </form>
<DialogInput
open={showDeleteDialog}
title={t("pages.editUser.dialog.removeUser.title")}
content={t("pages.editUser.dialog.removeUser.content", {
name: `${user?.firstName} ${user?.lastName}`,
})}
onCancel={() => setShowDeleteDialog(false)}
onConfirm={handleDeleteUser}
confirmText={t("common.buttons.removeUser")}
loading={isDeleting}
/>
</BasePage> </BasePage>
); );
}; };
@@ -43,7 +43,6 @@ export const AddTeamMemberDialog = ({
const roleOptions: { value: UserRole; label: string }[] = [ const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") }, { value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") }, { value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
]; ];
const handleClose = () => { const handleClose = () => {
@@ -48,7 +48,6 @@ export const InviteTeamMemberDialog = ({
const roleOptions: { value: UserRole; label: string }[] = [ const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") }, { value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") }, { value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
]; ];
const handleGenerateToken = async (data: InviteFormData) => { const handleGenerateToken = async (data: InviteFormData) => {
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Table } from "@/Components/design-elements"; import { Table } from "@/Components/design-elements";
import type { Header } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table";
import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useIsSuperAdmin } from "@/Hooks/useIsAdmin";
import type { User } from "@/Types/User"; import type { User } from "@/Types/User";
interface TeamTableProps { interface TeamTableProps {
@@ -13,7 +13,7 @@ interface TeamTableProps {
export const TeamTable = ({ users }: TeamTableProps) => { export const TeamTable = ({ users }: TeamTableProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const isAdmin = useIsAdmin(); const isSuperAdmin = useIsSuperAdmin();
const headers: Header<User>[] = [ const headers: Header<User>[] = [
{ {
@@ -45,7 +45,7 @@ export const TeamTable = ({ users }: TeamTableProps) => {
]; ];
const handleRowClick = (row: User) => { const handleRowClick = (row: User) => {
if (isAdmin) { if (isSuperAdmin) {
navigate(`/account/team/${row.id}`); navigate(`/account/team/${row.id}`);
} }
}; };
@@ -54,7 +54,7 @@ export const TeamTable = ({ users }: TeamTableProps) => {
<Table <Table
headers={headers} headers={headers}
data={users} data={users}
onRowClick={isAdmin ? handleRowClick : undefined} onRowClick={isSuperAdmin ? handleRowClick : undefined}
/> />
); );
}; };
+7 -15
View File
@@ -1,7 +1,6 @@
import { BaseAuthPage, TextLink } from "@/Components/design-elements"; import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { Button, TextField } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
@@ -10,7 +9,7 @@ import type { LoginFormData } from "@/Validation/login";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { setAuthState } from "@/Features/Auth/authSlice"; import { setAuthState } from "@/Features/Auth/authSlice";
import { useLazyGet, usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
const LoginPage = () => { const LoginPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -18,17 +17,6 @@ const LoginPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { post, loading } = usePost(); const { post, loading } = usePost();
const [isCheckingAdmin, setIsCheckingAdmin] = useState(true);
const { get } = useLazyGet<boolean>();
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 { schema, defaults } = useLoginForm();
const { control, handleSubmit } = useForm<LoginFormData>({ const { control, handleSubmit } = useForm<LoginFormData>({
@@ -36,8 +24,6 @@ const LoginPage = () => {
defaultValues: defaults, defaultValues: defaults,
}); });
if (isCheckingAdmin) return null;
const onSubmit = async (data: LoginFormData) => { const onSubmit = async (data: LoginFormData) => {
if (loading) return; if (loading) return;
@@ -96,6 +82,12 @@ const LoginPage = () => {
linkText={t("pages.auth.login.links.forgotPassword.linkText")} linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password" href="/forgot-password"
/> />
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.register.text")}
linkText={t("pages.auth.login.links.register.linkText")}
href="/register"
/>
</BaseAuthPage> </BaseAuthPage>
); );
}; };
+11 -15
View File
@@ -1,17 +1,16 @@
import { BaseAuthPage } from "@/Components/design-elements"; import { BaseAuthPage } from "@/Components/design-elements";
import { Button, TextField } from "@/Components/inputs"; import { Button, TextField } from "@/Components/inputs";
import Alert from "@mui/material/Alert";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRegisterForm } from "@/Hooks/useRegisterForm"; import { useRegisterForm } from "@/Hooks/useRegisterForm";
import type { RegisterFormData } from "@/Validation/register"; import type { RegisterFormData } from "@/Validation/register";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLazyGet, usePost } from "@/Hooks/UseApi"; import { usePost, useGet } from "@/Hooks/UseApi";
import { setAuthState } from "@/Features/Auth/authSlice"; import { setAuthState } from "@/Features/Auth/authSlice";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import type { AuthResponse } from "@/Types/User"; import type { AuthResponse } from "@/Types/User";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef } from "react";
interface RegisterPayload { interface RegisterPayload {
user: Omit<RegisterFormData, "confirm">; user: Omit<RegisterFormData, "confirm">;
@@ -33,23 +32,21 @@ const RegisterPage = () => {
const { post: verifyToken } = usePost<{ token: string }, InviteVerifyResponse>(); const { post: verifyToken } = usePost<{ token: string }, InviteVerifyResponse>();
const hasVerified = useRef(false); const hasVerified = useRef(false);
const [isCheckingAdmin, setIsCheckingAdmin] = useState(!token); const { data: superAdminExists, isLoading: isCheckingAdmin } = useGet<boolean>(
const { get } = useLazyGet<boolean>(); token ? null : "/auth/users/superadmin"
);
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<RegisterFormData>({ const { control, handleSubmit, setError, reset } = useForm<RegisterFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
useEffect(() => {
if (superAdminExists === true) {
navigate("/login", { replace: true });
}
}, [superAdminExists, navigate]);
useEffect(() => { useEffect(() => {
if (!token || hasVerified.current) return; if (!token || hasVerified.current) return;
hasVerified.current = true; hasVerified.current = true;
@@ -95,7 +92,6 @@ const RegisterPage = () => {
title={t("pages.auth.register.title")} title={t("pages.auth.register.title")}
subtitle={t("pages.auth.register.subtitle")} subtitle={t("pages.auth.register.subtitle")}
> >
{!token && <Alert severity="info">{t("pages.auth.register.setupNotice")}</Alert>}
<Controller <Controller
name="firstName" name="firstName"
control={control} control={control}
@@ -5,6 +5,7 @@ import type { Monitor } from "@/Types/Monitor";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/TimeUtils"; import { formatDateWithTz } from "@/Utils/TimeUtils";
import { useNavigate } from "react-router";
import type { Check } from "@/Types/Check"; import type { Check } from "@/Types/Check";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
@@ -28,6 +29,7 @@ export const ChecksTable = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const navigate = useNavigate();
const getHeaders = (t: Function, uiTimezone: string) => { const getHeaders = (t: Function, uiTimezone: string) => {
const headers: Header<Check>[] = [ const headers: Header<Check>[] = [
@@ -107,6 +109,9 @@ export const ChecksTable = ({
<Table <Table
headers={headers} headers={headers}
data={checks} data={checks}
onRowClick={(row) => {
navigate(`/checks/${row.id}`);
}}
emptyViewText={t("pages.checks.table.empty")} emptyViewText={t("pages.checks.table.empty")}
/> />
<Pagination <Pagination
+17 -238
View File
@@ -1,6 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { logger } from "@/Utils/logger";
import { useParams, useLocation, useNavigate } from "react-router"; import { useParams, useLocation, useNavigate } from "react-router";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -16,7 +15,6 @@ import Divider from "@mui/material/Divider";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { HeaderDeleteControls } from "@/Components/monitors"; import { HeaderDeleteControls } from "@/Components/monitors";
import { GeoContinents } from "@/Types/GeoCheck";
import { BasePage, ConfigBox } from "@/Components/design-elements"; import { BasePage, ConfigBox } from "@/Components/design-elements";
import { import {
@@ -29,15 +27,9 @@ import {
SliderWithLabel, SliderWithLabel,
Dialog, Dialog,
} from "@/Components/inputs"; } from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi"; import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi";
import { useMonitorForm } from "@/Hooks/useMonitorForm"; import { useMonitorForm } from "@/Hooks/useMonitorForm";
import { import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor";
type Monitor,
type MonitorType,
type GamesMap,
supportsGeoCheck,
} from "@/Types/Monitor";
import type { Notification } from "@/Types/Notification"; import type { Notification } from "@/Types/Notification";
import type { MonitorFormData } from "@/Validation/monitor"; import type { MonitorFormData } from "@/Validation/monitor";
@@ -49,8 +41,6 @@ interface GeneralSettingsConfig {
showPort: boolean; showPort: boolean;
showGameSelect: boolean; showGameSelect: boolean;
showSecret: boolean; showSecret: boolean;
showGrpcServiceName: boolean;
showIgnoreTls: boolean;
} }
const getGeneralSettingsConfig = ( const getGeneralSettingsConfig = (
@@ -66,8 +56,6 @@ const getGeneralSettingsConfig = (
showPort: false, showPort: false,
showGameSelect: false, showGameSelect: false,
showSecret: false, showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
}, },
ping: { ping: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"), urlLabel: t("pages.createMonitor.form.general.option.host.label"),
@@ -77,8 +65,6 @@ const getGeneralSettingsConfig = (
showPort: false, showPort: false,
showGameSelect: false, showGameSelect: false,
showSecret: false, showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
}, },
docker: { docker: {
urlLabel: t("pages.createMonitor.form.general.option.container.label"), urlLabel: t("pages.createMonitor.form.general.option.container.label"),
@@ -88,8 +74,6 @@ const getGeneralSettingsConfig = (
showPort: false, showPort: false,
showGameSelect: false, showGameSelect: false,
showSecret: false, showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
}, },
port: { port: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"), urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -99,8 +83,6 @@ const getGeneralSettingsConfig = (
showPort: true, showPort: true,
showGameSelect: false, showGameSelect: false,
showSecret: false, showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
}, },
game: { game: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"), urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -110,19 +92,6 @@ const getGeneralSettingsConfig = (
showPort: true, showPort: true,
showGameSelect: true, showGameSelect: true,
showSecret: false, showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
},
grpc: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: true,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: true,
showIgnoreTls: true,
}, },
pagespeed: { pagespeed: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"), urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -132,8 +101,6 @@ const getGeneralSettingsConfig = (
showPort: false, showPort: false,
showGameSelect: false, showGameSelect: false,
showSecret: false, showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
}, },
hardware: { hardware: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"), urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -143,19 +110,6 @@ const getGeneralSettingsConfig = (
showPort: false, showPort: false,
showGameSelect: false, showGameSelect: false,
showSecret: true, showSecret: true,
showGrpcServiceName: false,
showIgnoreTls: false,
},
websocket: {
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: true,
}, },
}; };
return configs[type] || configs.http; return configs[type] || configs.http;
@@ -211,7 +165,6 @@ const CreateMonitorPage = () => {
const watchedType = watch("type") as MonitorType; const watchedType = watch("type") as MonitorType;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
useEffect(() => { useEffect(() => {
clearErrors(); clearErrors();
@@ -271,7 +224,7 @@ const CreateMonitorPage = () => {
}; };
const onError = (errors: unknown) => { const onError = (errors: unknown) => {
logger.debug("Monitor creation validation errors", errors); console.log(errors);
}; };
return ( return (
@@ -298,57 +251,43 @@ const CreateMonitorPage = () => {
<FormControl error={!!fieldState.error}> <FormControl error={!!fieldState.error}>
<RadioGroup <RadioGroup
{...field} {...field}
sx={{ gap: theme.spacing(LAYOUT.MD) }} sx={{ gap: theme.spacing(6) }}
> >
<RadioWithDescription <RadioWithDescription
value="http" value="http"
label={t("pages.common.monitors.monitorTypes.optionHttp")} label={t("pages.createMonitor.form.type.optionHttp")}
description={t( description={t(
"pages.createMonitor.form.type.optionHttpDescription" "pages.createMonitor.form.type.optionHttpDescription"
)} )}
/> />
<RadioWithDescription <RadioWithDescription
value="ping" value="ping"
label={t("pages.common.monitors.monitorTypes.optionPing")} label={t("pages.createMonitor.form.type.optionPing")}
description={t( description={t(
"pages.createMonitor.form.type.optionPingDescription" "pages.createMonitor.form.type.optionPingDescription"
)} )}
/> />
<RadioWithDescription <RadioWithDescription
value="docker" value="docker"
label={t("pages.common.monitors.monitorTypes.optionDocker")} label={t("pages.createMonitor.form.type.optionDocker")}
description={t( description={t(
"pages.createMonitor.form.type.optionDockerDescription" "pages.createMonitor.form.type.optionDockerDescription"
)} )}
/> />
<RadioWithDescription <RadioWithDescription
value="port" value="port"
label={t("pages.common.monitors.monitorTypes.optionPort")} label={t("pages.createMonitor.form.type.optionPort")}
description={t( description={t(
"pages.createMonitor.form.type.optionPortDescription" "pages.createMonitor.form.type.optionPortDescription"
)} )}
/> />
<RadioWithDescription <RadioWithDescription
value="game" value="game"
label={t("pages.common.monitors.monitorTypes.optionGame")} label={t("pages.createMonitor.form.type.optionGame")}
description={t( description={t(
"pages.createMonitor.form.type.optionGameDescription" "pages.createMonitor.form.type.optionGameDescription"
)} )}
/> />
<RadioWithDescription
value="grpc"
label={t("pages.common.monitors.monitorTypes.optionGrpc")}
description={t(
"pages.createMonitor.form.type.optionGrpcDescription"
)}
/>
<RadioWithDescription
value="websocket"
label={t("pages.common.monitors.monitorTypes.optionWebSocket")}
description={t(
"pages.createMonitor.form.type.optionWebSocketDescription"
)}
/>
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
)} )}
@@ -361,7 +300,7 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.general.title")} title={t("pages.createMonitor.form.general.title")}
subtitle={t(`pages.createMonitor.form.general.description.${watchedType}`)} subtitle={t(`pages.createMonitor.form.general.description.${watchedType}`)}
rightContent={ rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(8)}>
{/* URL/Host/Container field - not shown for hardware */} {/* URL/Host/Container field - not shown for hardware */}
{generalSettingsConfig.showUrl && ( {generalSettingsConfig.showUrl && (
<Controller <Controller
@@ -437,30 +376,6 @@ const CreateMonitorPage = () => {
/> />
)} )}
{/* gRPC Service Name field - only for grpc type */}
{generalSettingsConfig.showGrpcServiceName && (
<Controller
name="grpcServiceName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
type="text"
fieldLabel={t(
"pages.createMonitor.form.general.option.grpcServiceName.label"
)}
placeholder={t(
"pages.createMonitor.form.general.option.grpcServiceName.placeholder"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
)}
{/* Secret field - only for hardware type */} {/* Secret field - only for hardware type */}
{generalSettingsConfig.showSecret && ( {generalSettingsConfig.showSecret && (
<Controller <Controller
@@ -580,7 +495,7 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.thresholds.title")} title={t("pages.createMonitor.form.thresholds.title")}
subtitle={t("pages.createMonitor.form.thresholds.description")} subtitle={t("pages.createMonitor.form.thresholds.description")}
rightContent={ rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(8)}>
<Controller <Controller
name="cpuAlertThreshold" name="cpuAlertThreshold"
control={control} control={control}
@@ -662,7 +577,7 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.incidents.title")} title={t("pages.createMonitor.form.incidents.title")}
subtitle={t("pages.createMonitor.form.incidents.description")} subtitle={t("pages.createMonitor.form.incidents.description")}
rightContent={ rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(12)}>
<Controller <Controller
name="statusWindowSize" name="statusWindowSize"
control={control} control={control}
@@ -714,7 +629,7 @@ const CreateMonitorPage = () => {
(field.value ?? []).includes(n.id) (field.value ?? []).includes(n.id)
); );
return ( return (
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(4)}>
<Autocomplete <Autocomplete
multiple multiple
options={notificationOptions} options={notificationOptions}
@@ -765,9 +680,7 @@ const CreateMonitorPage = () => {
} }
/> />
{(watchedType === "http" || {watchedType === "http" && (
watchedType === "grpc" ||
watchedType === "websocket") && (
<ConfigBox <ConfigBox
title={t("pages.createMonitor.form.ignoreTls.title")} title={t("pages.createMonitor.form.ignoreTls.title")}
subtitle={t("pages.createMonitor.form.ignoreTls.description")} subtitle={t("pages.createMonitor.form.ignoreTls.description")}
@@ -779,7 +692,7 @@ const CreateMonitorPage = () => {
<Stack <Stack
direction="row" direction="row"
alignItems="center" alignItems="center"
spacing={theme.spacing(SPACING.LG)} spacing={theme.spacing(2)}
> >
<Switch <Switch
checked={field.value ?? false} checked={field.value ?? false}
@@ -800,7 +713,7 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.advanced.title")} title={t("pages.createMonitor.form.advanced.title")}
subtitle={t("pages.createMonitor.form.advanced.description")} subtitle={t("pages.createMonitor.form.advanced.description")}
rightContent={ rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(8)}>
<Controller <Controller
name="useAdvancedMatching" name="useAdvancedMatching"
control={control} control={control}
@@ -808,7 +721,7 @@ const CreateMonitorPage = () => {
<Stack <Stack
direction="row" direction="row"
alignItems="center" alignItems="center"
spacing={theme.spacing(SPACING.LG)} spacing={theme.spacing(2)}
> >
<Switch <Switch
checked={field.value ?? false} checked={field.value ?? false}
@@ -823,7 +736,7 @@ const CreateMonitorPage = () => {
)} )}
/> />
{watchedUseAdvancedMatching && ( {watchedUseAdvancedMatching && (
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(8)}>
<Controller <Controller
name="matchMethod" name="matchMethod"
control={control} control={control}
@@ -910,140 +823,6 @@ const CreateMonitorPage = () => {
/> />
)} )}
{supportsGeoCheck(watchedType) && (
<ConfigBox
title={t("pages.createMonitor.form.geoChecks.title")}
subtitle={t("pages.createMonitor.form.geoChecks.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="geoCheckEnabled"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
{t("pages.createMonitor.form.geoChecks.option.enabled.label")}
</Typography>
</Stack>
)}
/>
{watchGeoCheckEnabled && (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="geoCheckLocations"
control={control}
render={({ field }) => {
// Map continents to have 'name' property for Autocomplete
const locationOptions = GeoContinents.map((continent) => ({
id: continent,
name: t(
`pages.createMonitor.form.geoChecks.option.locations.options.${continent}`
),
}));
const selectedLocations = locationOptions.filter((loc) =>
(field.value ?? []).includes(loc.id)
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={locationOptions}
value={selectedLocations}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: typeof locationOptions) => {
field.onChange(newValue.map((loc) => loc.id));
}}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
fieldLabel={t(
"pages.createMonitor.form.geoChecks.option.locations.label"
)}
/>
{selectedLocations.length > 0 && (
<Stack
flex={1}
width="100%"
>
{selectedLocations.map((location, index) => (
<Stack
direction="row"
alignItems="center"
key={location.id}
width="100%"
>
<Typography flexGrow={1}>{location.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
(field.value ?? []).filter(
(id: string) => id !== location.id
)
);
}}
aria-label="Remove location"
>
<Trash2 size={16} />
</IconButton>
{index < selectedLocations.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
<Controller
name="geoCheckInterval"
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value ?? 300000}
fieldLabel={t(
"pages.createMonitor.form.geoChecks.option.interval.label"
)}
>
<MenuItem value={300000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.fiveMinutes"
)}
</MenuItem>
<MenuItem value={600000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.tenMinutes"
)}
</MenuItem>
<MenuItem value={900000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.fifteenMinutes"
)}
</MenuItem>
<MenuItem value={1800000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.thirtyMinutes"
)}
</MenuItem>
</Select>
)}
/>
</Stack>
)}
</Stack>
}
/>
)}
<Stack <Stack
direction="row" direction="row"
justifyContent="flex-end" justifyContent="flex-end"
@@ -3,7 +3,6 @@ import Grid from "@mui/material/Grid";
import Divider from "@mui/material/Divider"; import Divider from "@mui/material/Divider";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { BaseBox, ValueLabel } from "@/Components/design-elements"; import { BaseBox, ValueLabel } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Incident } from "@/Types/Incident"; import type { Incident } from "@/Types/Incident";
@@ -30,14 +29,14 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
} }
return ( return (
<Stack <Stack
gap={theme.spacing(LAYOUT.MD)} gap={theme.spacing(4)}
sx={sx} sx={sx}
> >
<Typography textTransform={"uppercase"}> <Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.title")} {t("pages.incidents.dialog.details.title")}
</Typography> </Typography>
<BaseBox padding={LAYOUT.MD}> <BaseBox padding={8}>
<Stack gap={theme.spacing(LAYOUT.MD)}> <Stack gap={theme.spacing(4)}>
<Typography textTransform={"uppercase"}> <Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.overview")} {t("pages.incidents.dialog.details.overview")}
</Typography> </Typography>
@@ -45,7 +44,7 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
<Grid <Grid
container container
spacing={theme.spacing(LAYOUT.MD)} spacing={theme.spacing(4)}
alignItems="center" alignItems="center"
> >
<Grid size={2}>{t("pages.incidents.dialog.details.status")}</Grid> <Grid size={2}>{t("pages.incidents.dialog.details.status")}</Grid>
@@ -76,15 +75,15 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
</Grid> </Grid>
</Stack> </Stack>
</BaseBox> </BaseBox>
<BaseBox padding={LAYOUT.MD}> <BaseBox padding={8}>
<Stack gap={theme.spacing(LAYOUT.MD)}> <Stack gap={theme.spacing(4)}>
<Typography textTransform={"uppercase"}> <Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.analysis")} {t("pages.incidents.dialog.details.analysis")}
</Typography> </Typography>
<Divider /> <Divider />
<Grid <Grid
container container
spacing={theme.spacing(LAYOUT.MD)} spacing={theme.spacing(2)}
> >
<Grid size={6}> <Grid size={6}>
<Typography>{t("pages.incidents.dialog.details.timeline")}</Typography> <Typography>{t("pages.incidents.dialog.details.timeline")}</Typography>
@@ -128,15 +127,15 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
</Stack> </Stack>
</BaseBox> </BaseBox>
{!incident.status && ( {!incident.status && (
<BaseBox padding={LAYOUT.MD}> <BaseBox padding={8}>
<Stack gap={theme.spacing(LAYOUT.XS)}> <Stack gap={theme.spacing(4)}>
<Typography textTransform={"uppercase"}> <Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.resolutionDetails")} {t("pages.incidents.dialog.details.resolutionDetails")}
</Typography> </Typography>
<Divider /> <Divider />
<Grid <Grid
container container
spacing={theme.spacing(LAYOUT.MD)} spacing={theme.spacing(2)}
alignItems="center" alignItems="center"
> >
<Grid size={2}> <Grid size={2}>
+1 -1
View File
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
const LogsPage = () => { const LogsPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<number>(1); const [activeTab, setActiveTab] = useState<number>(2);
return ( return (
<BasePage> <BasePage>
<Tabs <Tabs
+10 -11
View File
@@ -1,9 +1,7 @@
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import { logger } from "@/Utils/logger";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { BasePage, ConfigBox } from "@/Components/design-elements"; import { BasePage, ConfigBox } from "@/Components/design-elements";
import { import {
TextField, TextField,
@@ -110,7 +108,7 @@ const CreateMaintenanceWindowPage = () => {
const isLoading = isPosting || isPatching; const isLoading = isPosting || isPatching;
const onError = (errors: any) => { const onError = (errors: any) => {
logger.error("Maintenance form submission failed", undefined, { errors }); console.error("Form submission errors:", errors);
}; };
return ( return (
@@ -122,7 +120,7 @@ const CreateMaintenanceWindowPage = () => {
title={t("pages.maintenanceWindow.form.general.title")} title={t("pages.maintenanceWindow.form.general.title")}
subtitle={t("pages.maintenanceWindow.form.general.description")} subtitle={t("pages.maintenanceWindow.form.general.description")}
rightContent={ rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(8)}>
<Controller <Controller
name="name" name="name"
control={control} control={control}
@@ -172,7 +170,7 @@ const CreateMaintenanceWindowPage = () => {
title={t("pages.maintenanceWindow.form.startDate.title")} title={t("pages.maintenanceWindow.form.startDate.title")}
subtitle={t("pages.maintenanceWindow.form.startDate.description")} subtitle={t("pages.maintenanceWindow.form.startDate.description")}
rightContent={ rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(8)}>
<Controller <Controller
name="startDate" name="startDate"
control={control} control={control}
@@ -199,7 +197,7 @@ const CreateMaintenanceWindowPage = () => {
title={t("pages.maintenanceWindow.form.startTime.title")} title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")} subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={ rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(8)}>
<Controller <Controller
name="startTime" name="startTime"
control={control} control={control}
@@ -220,7 +218,7 @@ const CreateMaintenanceWindowPage = () => {
<Stack <Stack
direction="row" direction="row"
alignItems="flex-start" alignItems="flex-start"
spacing={theme.spacing(LAYOUT.MD)} spacing={theme.spacing(4)}
> >
<Controller <Controller
name="duration" name="duration"
@@ -285,7 +283,7 @@ const CreateMaintenanceWindowPage = () => {
.filter((m): m is Monitor => m !== undefined); .filter((m): m is Monitor => m !== undefined);
return ( return (
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(4)}>
<Autocomplete <Autocomplete
multiple multiple
options={monitorsList} options={monitorsList}
@@ -319,8 +317,9 @@ const CreateMaintenanceWindowPage = () => {
key={monitor.id} key={monitor.id}
direction="row" direction="row"
alignItems="center" alignItems="center"
padding={theme.spacing(LAYOUT.XS)} spacing={theme.spacing(4)}
marginTop={theme.spacing(SPACING.LG)} padding={theme.spacing(4)}
marginTop={theme.spacing(2)}
borderRadius={1} borderRadius={1}
sx={{ sx={{
border: `1px solid ${theme.palette.divider}`, border: `1px solid ${theme.palette.divider}`,
@@ -351,7 +350,7 @@ const CreateMaintenanceWindowPage = () => {
<Stack <Stack
direction="row" direction="row"
justifyContent="flex-end" justifyContent="flex-end"
spacing={theme.spacing(SPACING.LG)} spacing={theme.spacing(2)}
> >
<Button <Button
loading={isLoading} loading={isLoading}
+22 -196
View File
@@ -147,201 +147,27 @@ const NotificationsCreatePage = () => {
/> />
} }
/> />
{watchedType !== "matrix" && {watchedType !== "matrix" && (
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "twilio" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
rightContent={
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={addressConfig.fieldLabel}
placeholder={addressConfig.placeholder}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox <ConfigBox
title={t("pages.notifications.form.telegram.title")} title={addressConfig.title}
subtitle={t("pages.notifications.form.telegram.description")} subtitle={addressConfig.description}
rightContent={ rightContent={
<Stack spacing={theme.spacing(8)}> <Controller
<Controller name="address"
name="accessToken" control={control}
control={control} defaultValue={defaults.address}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""} render={({ field, fieldState }) => (
render={({ field, fieldState }) => ( <TextField
<TextField {...field}
{...field} type="text"
type="text" fieldLabel={addressConfig.fieldLabel}
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")} placeholder={addressConfig.placeholder}
placeholder={t( fullWidth
"pages.notifications.form.telegram.placeholderBotToken" error={!!fieldState.error}
)} helperText={fieldState.error?.message ?? ""}
fullWidth />
error={!!fieldState.error} )}
helperText={fieldState.error?.message ?? ""} />
/>
)}
/>
<Controller
name="address"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "pushover" && (
<ConfigBox
title={t("pages.notifications.form.pushover.title")}
subtitle={t("pages.notifications.form.pushover.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
placeholder={t(
"pages.notifications.form.pushover.placeholderAppToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
placeholder={t(
"pages.notifications.form.pushover.placeholderUserKey"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
subtitle={t("pages.notifications.form.twilio.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accountSid"
control={control}
defaultValue={"accountSid" in defaults ? defaults.accountSid : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAccountSid"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAuthToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="twilioPhoneNumber"
control={control}
defaultValue={
"twilioPhoneNumber" in defaults ? defaults.twilioPhoneNumber : ""
}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
placeholder={t(
"pages.notifications.form.twilio.placeholderFromNumber"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="phone"
control={control}
defaultValue={"phone" in defaults ? defaults.phone : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
} }
/> />
)} )}
@@ -354,7 +180,7 @@ const NotificationsCreatePage = () => {
<Controller <Controller
name="homeserverUrl" name="homeserverUrl"
control={control} control={control}
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""} defaultValue={defaults.homeserverUrl}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<TextField <TextField
{...field} {...field}
@@ -370,7 +196,7 @@ const NotificationsCreatePage = () => {
<Controller <Controller
name="roomId" name="roomId"
control={control} control={control}
defaultValue={"roomId" in defaults ? defaults.roomId : ""} defaultValue={defaults.roomId}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<TextField <TextField
{...field} {...field}
@@ -386,7 +212,7 @@ const NotificationsCreatePage = () => {
<Controller <Controller
name="accessToken" name="accessToken"
control={control} control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""} defaultValue={defaults.accessToken}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<TextField <TextField
{...field} {...field}
+4 -24
View File
@@ -1,5 +1,4 @@
import { BasePage } from "@/Components/design-elements"; import { BasePage } from "@/Components/design-elements";
import { HeaderTimeRange } from "@/Components/common";
import type { PageSpeedDetailsResponse } from "@/Types/Monitor"; import type { PageSpeedDetailsResponse } from "@/Types/Monitor";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { import {
@@ -14,36 +13,23 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet } from "@/Hooks/UseApi"; import { useGet } from "@/Hooks/UseApi";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useState, useMemo } from "react";
const PageSpeedDetails = () => { const PageSpeedDetails = () => {
const { monitorId } = useParams(); const { monitorId } = useParams();
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const theme = useTheme(); const theme = useTheme();
const [dateRange, setDateRange] = useState<string>("day");
const monitorDetailsUrl = useMemo(() => {
if (!monitorId) {
return null;
}
const params = new URLSearchParams();
params.append("dateRange", dateRange);
return `/monitors/pagespeed/details/${monitorId}?${params.toString()}`;
}, [monitorId, dateRange]);
const { const {
data: monitorData, data: monitorData,
isLoading, isLoading,
error, error,
refetch, refetch,
} = useGet<PageSpeedDetailsResponse>( } = useGet<PageSpeedDetailsResponse>(
monitorDetailsUrl, monitorId ? `/monitors/pagespeed/details/${monitorId}?dateRange=day` : null,
{}, {},
{ keepPreviousData: true, refreshInterval: 30000 } { keepPreviousData: true, refreshInterval: 30000 }
); );
const monitor = monitorData?.monitorData?.monitor; const monitor = monitorData?.monitor;
const groupedChecks = monitorData?.monitorData?.groupedChecks || [];
const monitorStats = monitorData?.monitorStats || null; const monitorStats = monitorData?.monitorStats || null;
return ( return (
@@ -61,15 +47,9 @@ const PageSpeedDetails = () => {
monitor={monitor} monitor={monitor}
monitorStats={monitorStats} monitorStats={monitorStats}
/> />
<HeaderTimeRange
isLoading={isLoading}
hasDateRange={true}
dateRange={dateRange}
setDateRange={setDateRange}
/>
<HistogramPageSpeedDetails <HistogramPageSpeedDetails
checks={groupedChecks} checks={monitor?.recentChecks || []}
range={dateRange} range="day"
/> />
<Stack <Stack
direction={{ xs: "column", md: "row" }} direction={{ xs: "column", md: "row" }}
+9 -23
View File
@@ -8,7 +8,7 @@ import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/Pa
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMemo, useState } from "react"; import { useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet, useDelete } from "@/Hooks/UseApi"; import { useGet, useDelete } from "@/Hooks/UseApi";
import type { MonitorsWithChecksResponse } from "@/Types/Monitor"; import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
@@ -20,6 +20,14 @@ const PageSpeedMonitorsPage = () => {
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const { deleteFn, loading: isDeleting } = useDelete(); const { deleteFn, loading: isDeleting } = useDelete();
const monitorsUrl = "/monitors/team/with-checks?type=pagespeed&limit=10";
const {
data: monitorsData,
isLoading: monitorsIsLoading,
error: monitorsError,
refetch,
} = useGet<MonitorsWithChecksResponse>(monitorsUrl, {}, { refreshInterval: 30000 });
const settingsUrl = "/settings"; const settingsUrl = "/settings";
const { const {
data: settingsData, data: settingsData,
@@ -34,28 +42,6 @@ const PageSpeedMonitorsPage = () => {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10); const [rowsPerPage, setRowsPerPage] = useState(10);
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "pagespeed");
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (sortField) params.append("field", sortField);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, sortField, sortOrder]);
const {
data: monitorsData,
isLoading: monitorsIsLoading,
error: monitorsError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 30000 }
);
const monitors = monitorsData?.monitors; const monitors = monitorsData?.monitors;
const monitorsCount = monitorsData?.count ?? 0; const monitorsCount = monitorsData?.count ?? 0;
const summary = monitorsData?.summary ?? null; const summary = monitorsData?.summary ?? null;
+106 -183
View File
@@ -1,15 +1,6 @@
import { BasePage, ConfigBox, TextLink } from "@/Components/design-elements"; import { BasePage, ConfigBox, TextLink } from "@/Components/design-elements";
import { Autocomplete, Select, Dialog, SwitchComponent } from "@/Components/inputs"; import { Autocomplete, Select, Dialog, SwitchComponent } from "@/Components/inputs";
import { logger } from "@/Utils/logger"; import { Stack, useTheme, MenuItem, Link, type SelectChangeEvent } from "@mui/material";
import { LAYOUT } from "@/Utils/Theme/constants";
import {
Stack,
useTheme,
MenuItem,
Link,
Alert,
type SelectChangeEvent,
} from "@mui/material";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -17,13 +8,12 @@ import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import DummyChart from "@/Pages/Settings/DummyChart"; import DummyChart from "@/Pages/Settings/DummyChart";
import { useGet, usePatch, usePost, useLazyGet } from "@/Hooks/UseApi"; import { useGet, usePatch, usePost, useLazyGet } from "@/Hooks/UseApi";
import { useToast } from "@/Hooks/UseToast";
import { useSettingsForm } from "@/Hooks/useSettingsForm"; import { useSettingsForm } from "@/Hooks/useSettingsForm";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import type { SettingsFormData, SettingsFormInput } from "@/Validation/settings"; import type { SettingsFormData } from "@/Validation/settings";
import { useState } from "react"; import { useState } from "react";
import { Controller } from "react-hook-form"; import { Controller } from "react-hook-form";
import { TextField, Button, FieldLabel, SliderWithLabel } from "@/Components/inputs"; import { TextField, Button, FieldLabel } from "@/Components/inputs";
import { Box, Typography } from "@mui/material"; import { Box, Typography } from "@mui/material";
import { useDelete } from "@/Hooks/UseApi"; import { useDelete } from "@/Hooks/UseApi";
@@ -37,7 +27,6 @@ import {
} from "@/Features/UI/uiSlice.js"; } from "@/Features/UI/uiSlice.js";
import timezones from "@/Utils/timezones.json"; import timezones from "@/Utils/timezones.json";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
interface Timezone { interface Timezone {
id: string; id: string;
@@ -55,13 +44,10 @@ export const SettingsPage = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const dispatch = useDispatch(); const dispatch = useDispatch();
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const { toastError } = useToast();
// Local state for demo monitors dialog // Local state for demo monitors dialog
const [isDemoMonitorsDialogOpen, setIsDemoMonitorsDialogOpen] = useState(false); const [isDemoMonitorsDialogOpen, setIsDemoMonitorsDialogOpen] = useState(false);
const { post: postDemoMonitors, loading: isPostingDemoMonitors } = usePost(); const { post: postDemoMonitors, loading: isPostingDemoMonitors } = usePost();
const { deleteFn: deleteAllMonitors, loading: isDeletingAllMonitors } = useDelete(); const { deleteFn: deleteAllMonitors, loading: isDeletingAllMonitors } = useDelete();
// Import monitors functionality
const { post: importMonitors, loading: isImportingMonitors } = usePost();
// Fetch settings data from API // Fetch settings data from API
const { data: fetchedSettings } = useGet<SettingsResponse>("/settings"); const { data: fetchedSettings } = useGet<SettingsResponse>("/settings");
@@ -89,7 +75,7 @@ export const SettingsPage = () => {
// Initialize form with schema and defaults // Initialize form with schema and defaults
const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings }); const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings });
const form = useForm<SettingsFormInput, unknown, SettingsFormData>({ const form = useForm<SettingsFormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
mode: "onChange", mode: "onChange",
@@ -216,36 +202,6 @@ export const SettingsPage = () => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (file.type !== "application/json") {
toastError("Please select a valid JSON file");
event.target.value = "";
}
try {
const text = await file.text();
const monitors = JSON.parse(text);
if (!Array.isArray(monitors)) {
toastError("Invalid file format: expected an array of monitors");
event.target.value = "";
return;
}
await importMonitors("/monitors/import/json", { monitors });
event.target.value = "";
} catch (error) {
toastError("Error parsing JSON file. Please check the file format.");
event.target.value = "";
}
};
const onSubmit = async (data: SettingsFormData) => { const onSubmit = async (data: SettingsFormData) => {
// Don't send pagespeedApiKey if it's already set and user hasn't clicked reset // Don't send pagespeedApiKey if it's already set and user hasn't clicked reset
const dataToSend = { ...data }; const dataToSend = { ...data };
@@ -256,7 +212,29 @@ export const SettingsPage = () => {
delete (dataToSend as any).systemEmailPassword; delete (dataToSend as any).systemEmailPassword;
} }
const result = await patch("/settings", dataToSend as SettingsFormData); // Convert undefined to empty string for backend unsetting
const processedData = Object.entries(dataToSend).reduce((acc, [key, value]) => {
const typedKey = key as keyof SettingsFormData;
if (value === undefined) {
(acc as any)[typedKey] = "";
} else if (typeof value === "object" && value !== null) {
// Handle nested objects like globalThresholds
// Convert 0 to "" for unsetting thresholds
(acc as any)[typedKey] = Object.entries(value).reduce(
(nested, [nestedKey, nestedValue]) => ({
...nested,
[nestedKey]:
nestedValue === undefined || nestedValue === 0 ? "" : nestedValue,
}),
{}
);
} else {
(acc as any)[typedKey] = value;
}
return acc;
}, {} as Partial<SettingsFormData>);
const result = await patch("/settings", processedData as SettingsFormData);
if (result?.success) { if (result?.success) {
// Update API key state from response // Update API key state from response
@@ -270,7 +248,7 @@ export const SettingsPage = () => {
}; };
const onError = (errors: unknown) => { const onError = (errors: unknown) => {
logger.debug("Form validation errors", errors); console.log("Form validation errors:", errors);
}; };
const languages = Object.keys(i18n.options.resources || {}); const languages = Object.keys(i18n.options.resources || {});
@@ -280,7 +258,7 @@ export const SettingsPage = () => {
component="form" component="form"
onSubmit={form.handleSubmit(onSubmit, onError)} onSubmit={form.handleSubmit(onSubmit, onError)}
> >
<Stack gap={theme.spacing(LAYOUT.MD)}> <Stack gap={theme.spacing(8)}>
<ConfigBox <ConfigBox
title={t("pages.settings.form.timezone.title")} title={t("pages.settings.form.timezone.title")}
subtitle={t("pages.settings.form.timezone.description")} subtitle={t("pages.settings.form.timezone.description")}
@@ -303,7 +281,7 @@ export const SettingsPage = () => {
title={t("pages.settings.form.ui.title")} title={t("pages.settings.form.ui.title")}
subtitle={t("pages.settings.form.ui.description")} subtitle={t("pages.settings.form.ui.description")}
rightContent={ rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}> <Stack gap={theme.spacing(8)}>
<Select <Select
value={mode} value={mode}
onChange={handleModeChange} onChange={handleModeChange}
@@ -442,43 +420,6 @@ export const SettingsPage = () => {
/> />
)} )}
{/* Check Retention */}
{isAdmin && (
<ConfigBox
title={t("pages.settings.form.retention.title")}
subtitle={t("pages.settings.form.retention.description")}
rightContent={
<Controller
name="checkTTL"
control={form.control}
defaultValue={defaults.checkTTL}
render={({ field }) => (
<SliderWithLabel
{...field}
fieldLabel={t("pages.settings.form.retention.option.days.label")}
min={1}
max={CHECK_TTL_SENTINEL}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
value={field.value || 30}
onChange={(_, value) => field.onChange(value)}
valueLabelDisplay="auto"
valueLabelFormat={(value: number) =>
value >= CHECK_TTL_SENTINEL
? t("pages.settings.form.retention.option.days.unlimited")
: `${value}`
}
formatDisplayValue={(value: number) =>
value >= CHECK_TTL_SENTINEL
? t("pages.settings.form.retention.option.days.unlimited")
: `${value}`
}
/>
)}
/>
}
/>
)}
{/* Global Thresholds */} {/* Global Thresholds */}
{isAdmin && ( {isAdmin && (
<ConfigBox <ConfigBox
@@ -490,16 +431,24 @@ export const SettingsPage = () => {
name="globalThresholds.cpu" name="globalThresholds.cpu"
control={form.control} control={form.control}
defaultValue={defaults.globalThresholds?.cpu} defaultValue={defaults.globalThresholds?.cpu}
render={({ field }) => ( render={({ field, fieldState }) => (
<SliderWithLabel <TextField
{...field} {...field}
value={
field.value === undefined || field.value === 0 ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
fieldLabel={t("pages.settings.form.thresholds.option.cpu.label")} fieldLabel={t("pages.settings.form.thresholds.option.cpu.label")}
min={1} type="number"
max={100} inputProps={{ min: 0 }}
sliderMaxWidth={{ xs: "100%", md: "50%" }} placeholder={t(
value={field.value || 1} "pages.settings.form.thresholds.option.cpu.placeholder"
onChange={(_, value) => field.onChange(value)} )}
valueLabelDisplay="auto" error={!!fieldState.error}
helperText={fieldState.error?.message}
/> />
)} )}
/> />
@@ -507,16 +456,24 @@ export const SettingsPage = () => {
name="globalThresholds.memory" name="globalThresholds.memory"
control={form.control} control={form.control}
defaultValue={defaults.globalThresholds?.memory} defaultValue={defaults.globalThresholds?.memory}
render={({ field }) => ( render={({ field, fieldState }) => (
<SliderWithLabel <TextField
{...field} {...field}
value={
field.value === undefined || field.value === 0 ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
fieldLabel={t("pages.settings.form.thresholds.option.memory.label")} fieldLabel={t("pages.settings.form.thresholds.option.memory.label")}
min={1} type="number"
max={100} inputProps={{ min: 0 }}
sliderMaxWidth={{ xs: "100%", md: "50%" }} placeholder={t(
value={field.value || 1} "pages.settings.form.thresholds.option.memory.placeholder"
onChange={(_, value) => field.onChange(value)} )}
valueLabelDisplay="auto" error={!!fieldState.error}
helperText={fieldState.error?.message}
/> />
)} )}
/> />
@@ -524,16 +481,24 @@ export const SettingsPage = () => {
name="globalThresholds.disk" name="globalThresholds.disk"
control={form.control} control={form.control}
defaultValue={defaults.globalThresholds?.disk} defaultValue={defaults.globalThresholds?.disk}
render={({ field }) => ( render={({ field, fieldState }) => (
<SliderWithLabel <TextField
{...field} {...field}
value={
field.value === undefined || field.value === 0 ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
fieldLabel={t("pages.settings.form.thresholds.option.disk.label")} fieldLabel={t("pages.settings.form.thresholds.option.disk.label")}
min={1} type="number"
max={100} inputProps={{ min: 0 }}
sliderMaxWidth={{ xs: "100%", md: "50%" }} placeholder={t(
value={field.value || 1} "pages.settings.form.thresholds.option.disk.placeholder"
onChange={(_, value) => field.onChange(value)} )}
valueLabelDisplay="auto" error={!!fieldState.error}
helperText={fieldState.error?.message}
/> />
)} )}
/> />
@@ -541,18 +506,26 @@ export const SettingsPage = () => {
name="globalThresholds.temperature" name="globalThresholds.temperature"
control={form.control} control={form.control}
defaultValue={defaults.globalThresholds?.temperature} defaultValue={defaults.globalThresholds?.temperature}
render={({ field }) => ( render={({ field, fieldState }) => (
<SliderWithLabel <TextField
{...field} {...field}
value={
field.value === undefined || field.value === 0 ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
fieldLabel={t( fieldLabel={t(
"pages.settings.form.thresholds.option.temperature.label" "pages.settings.form.thresholds.option.temperature.label"
)} )}
min={1} type="number"
max={150} inputProps={{ min: 0 }}
sliderMaxWidth={{ xs: "100%", md: "50%" }} placeholder={t(
value={field.value || 1} "pages.settings.form.thresholds.option.temperature.placeholder"
onChange={(_, value) => field.onChange(value)} )}
valueLabelDisplay="auto" error={!!fieldState.error}
helperText={fieldState.error?.message}
/> />
)} )}
/> />
@@ -568,7 +541,7 @@ export const SettingsPage = () => {
title={t("pages.settings.form.email.title")} title={t("pages.settings.form.email.title")}
subtitle={t("pages.settings.form.email.description")} subtitle={t("pages.settings.form.email.description")}
leftContent={ leftContent={
<Stack gap={theme.spacing(LAYOUT.MD)}> <Stack gap={theme.spacing(8)}>
<TextLink <TextLink
text={t("pages.settings.form.email.descriptionTransport")} text={t("pages.settings.form.email.descriptionTransport")}
linkText={t("pages.settings.form.email.descriptionTransportLink")} linkText={t("pages.settings.form.email.descriptionTransportLink")}
@@ -616,7 +589,7 @@ export const SettingsPage = () => {
</Stack> </Stack>
} }
rightContent={ rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}> <Stack gap={theme.spacing(10)}>
{/* Email Host */} {/* Email Host */}
<Controller <Controller
name="systemEmailHost" name="systemEmailHost"
@@ -703,7 +676,7 @@ export const SettingsPage = () => {
<Stack <Stack
direction="row" direction="row"
alignItems="center" alignItems="center"
gap={theme.spacing(LAYOUT.XS)} gap={theme.spacing(4)}
> >
<Button <Button
variant="contained" variant="contained"
@@ -778,7 +751,7 @@ export const SettingsPage = () => {
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: theme.spacing(LAYOUT.XS), gap: theme.spacing(4),
}} }}
> >
{[ {[
@@ -893,34 +866,17 @@ export const SettingsPage = () => {
{/* Export Monitors - Admin Only */} {/* Export Monitors - Admin Only */}
{isAdmin && ( {isAdmin && (
<ConfigBox <ConfigBox
title={t("pages.settings.form.importExportMonitors.title")} title={t("pages.settings.form.exportMonitors.title")}
subtitle={t("pages.settings.form.importExportMonitors.description")} subtitle={t("pages.settings.form.exportMonitors.description")}
rightContent={ rightContent={
<Stack <Box>
gap={theme.spacing(LAYOUT.MD)}
direction={"row"}
>
<input
id="monitor-import-input"
type="file"
accept=".json"
style={{ display: "none" }}
onChange={handleFileSelect}
/>
<Button
variant="contained"
onClick={() => document.getElementById("monitor-import-input")?.click()}
disabled={isImportingMonitors}
>
{t("common.buttons.importFromJSON")}
</Button>
<Button <Button
variant="contained" variant="contained"
onClick={handleExportMonitors} onClick={handleExportMonitors}
> >
{t("common.buttons.exportToJSON")} {t("common.buttons.exportToJSON")}
</Button> </Button>
</Stack> </Box>
} }
/> />
)} )}
@@ -983,46 +939,13 @@ export const SettingsPage = () => {
bottom: 0, bottom: 0,
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
borderTop: `1px solid ${theme.palette.divider}`, borderTop: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(LAYOUT.MD), padding: theme.spacing(8),
marginLeft: theme.spacing(-LAYOUT.MD), marginLeft: theme.spacing(-8),
marginRight: theme.spacing(-LAYOUT.MD), marginRight: theme.spacing(-8),
marginBottom: theme.spacing(-LAYOUT.MD), marginBottom: theme.spacing(-8),
zIndex: 1000, zIndex: 1000,
}} }}
> >
{/* Validation Error Display */}
{Object.keys(form.formState.errors).length > 0 && (
<Alert
severity="error"
sx={{ mb: 2, flexGrow: 1, mr: 2 }}
>
<Typography
variant="body2"
sx={{ fontWeight: 600, mb: 1 }}
>
{t("pages.settings.form.validation.errorMessage")}
</Typography>
<Box
component="ul"
sx={{ m: 0, pl: 2 }}
>
{Object.entries(form.formState.errors).map(([field, error]) => {
const message =
typeof error === "object" && error?.message
? error.message
: "Invalid value";
return (
<li key={field}>
<Typography variant="body2">
<strong>{field}:</strong> {message}
</Typography>
</li>
);
})}
</Box>
</Alert>
)}
<Button <Button
loading={isSaving} loading={isSaving}
type="submit" type="submit"
+18 -70
View File
@@ -1,7 +1,5 @@
import { BasePage, ConfigBox } from "@/Components/design-elements"; import { BasePage, ConfigBox } from "@/Components/design-elements";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { logger } from "@/Utils/logger";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import FormControlLabel from "@mui/material/FormControlLabel"; import FormControlLabel from "@mui/material/FormControlLabel";
@@ -21,28 +19,19 @@ import {
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useStatusPageForm } from "@/Hooks/useStatusPageForm"; import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
import type { StatusPageFormData } from "@/Validation/statusPage"; import type { StatusPageFormData } from "@/Validation/statusPage";
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi"; import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"; import type { StatusPageResponse } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import timezones from "@/Utils/timezones.json"; import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import axios from "axios"; import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls"; import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
const monitorsUrl = (() => {
const params = new URLSearchParams();
["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach(
(type) => params.append("type", type)
);
return `/monitors/team?${params.toString()}`;
})();
interface TimezoneOption { interface TimezoneOption {
_id: string; _id: string;
name: string; name: string;
@@ -58,11 +47,11 @@ const CreateStatusPage = () => {
// Fetch existing status page data when configuring // Fetch existing status page data when configuring
const { data: statusPageData, isLoading: isLoadingStatusPage } = const { data: statusPageData, isLoading: isLoadingStatusPage } =
useGet<StatusPageResponse>( useGet<StatusPageResponse>(isCreate ? null : `/status-page/${url}?type=uptime`);
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
);
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl); const { data: monitorsResponse } = useGet<Monitor[]>(
"/monitors/team?type=http&type=ping&type=port&type=docker"
);
const monitors = monitorsResponse ?? []; const monitors = monitorsResponse ?? [];
const { post, loading: isSubmittingPost } = usePost(); const { post, loading: isSubmittingPost } = usePost();
@@ -90,26 +79,8 @@ const CreateStatusPage = () => {
reset(defaults); reset(defaults);
}, [defaults, reset]); }, [defaults, reset]);
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
const computedTypes: MonitorDisplayType[] = useMemo(() => {
const selectedMonitors = (watchedMonitorIds ?? [])
.map((id) => monitors.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
const typesSet = new Set<MonitorDisplayType>();
selectedMonitors.forEach((m) => {
typesSet.add(m.type === "hardware" ? "infrastructure" : "uptime");
});
return typesSet.size ? Array.from(typesSet) : ["uptime"];
}, [watchedMonitorIds, monitors]);
useEffect(() => {
form.setValue("type", computedTypes);
}, [computedTypes]);
const onError = (errors: any) => { const onError = (errors: any) => {
logger.debug("Status page validation errors", errors); console.log(errors);
}; };
const handleDeleteClick = () => { const handleDeleteClick = () => {
@@ -130,6 +101,7 @@ const CreateStatusPage = () => {
const onSubmit = async (data: StatusPageFormData) => { const onSubmit = async (data: StatusPageFormData) => {
const fd = new FormData(); const fd = new FormData();
fd.append("type", "uptime");
fd.append("isPublished", String(data.isPublished)); fd.append("isPublished", String(data.isPublished));
if (data.companyName) fd.append("companyName", data.companyName); if (data.companyName) fd.append("companyName", data.companyName);
if (data.url) fd.append("url", data.url); if (data.url) fd.append("url", data.url);
@@ -138,16 +110,11 @@ const CreateStatusPage = () => {
fd.append("showCharts", String(data.showCharts)); fd.append("showCharts", String(data.showCharts));
fd.append("showUptimePercentage", String(data.showUptimePercentage)); fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink)); fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
data.monitors.forEach((monitorId) => { data.monitors.forEach((monitorId) => {
fd.append("monitors[]", monitorId); fd.append("monitors[]", monitorId);
}); });
data.type.forEach((type) => {
fd.append("type[]", type);
});
// Handle logo upload // Handle logo upload
if (data.logo === null) { if (data.logo === null) {
// Signal to remove the logo // Signal to remove the logo
@@ -161,7 +128,7 @@ const CreateStatusPage = () => {
fd.append("logo", imageResult.data); fd.append("logo", imageResult.data);
URL.revokeObjectURL(data.logo.data); URL.revokeObjectURL(data.logo.data);
} catch (e) { } catch (e) {
logger.error("Failed to fetch logo blob", e instanceof Error ? e : undefined); console.error("Error fetching logo blob:", e);
} }
} }
} }
@@ -198,7 +165,7 @@ const CreateStatusPage = () => {
<Stack <Stack
direction="row" direction="row"
alignItems="center" alignItems="center"
spacing={theme.spacing(SPACING.MD)} spacing={theme.spacing(2)}
> >
<Controller <Controller
name="isPublished" name="isPublished"
@@ -220,7 +187,7 @@ const CreateStatusPage = () => {
title={t("pages.statusPages.form.basicInfo.title")} title={t("pages.statusPages.form.basicInfo.title")}
subtitle={t("pages.statusPages.form.basicInfo.description")} subtitle={t("pages.statusPages.form.basicInfo.description")}
rightContent={ rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(6)}>
<Controller <Controller
name="companyName" name="companyName"
control={control} control={control}
@@ -275,7 +242,7 @@ const CreateStatusPage = () => {
}; };
return ( return (
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(4)}>
<Autocomplete <Autocomplete
multiple multiple
options={monitors} options={monitors}
@@ -323,9 +290,9 @@ const CreateStatusPage = () => {
{...provided.dragHandleProps} {...provided.dragHandleProps}
direction="row" direction="row"
alignItems="center" alignItems="center"
spacing={theme.spacing(LAYOUT.XS)} spacing={theme.spacing(4)}
padding={theme.spacing(LAYOUT.XS)} padding={theme.spacing(4)}
marginTop={theme.spacing(SPACING.LG)} marginTop={theme.spacing(2)}
borderRadius={1} borderRadius={1}
sx={{ sx={{
border: `1px solid ${theme.palette.divider}`, border: `1px solid ${theme.palette.divider}`,
@@ -334,9 +301,7 @@ const CreateStatusPage = () => {
}} }}
> >
<GripVertical size={20} /> <GripVertical size={20} />
<Typography <Typography flexGrow={1}>{monitor.name}</Typography>
flexGrow={1}
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
<IconButton <IconButton
size="small" size="small"
onClick={() => { onClick={() => {
@@ -401,7 +366,7 @@ const CreateStatusPage = () => {
title={t("pages.statusPages.form.appearance.title")} title={t("pages.statusPages.form.appearance.title")}
subtitle={t("pages.statusPages.form.appearance.description")} subtitle={t("pages.statusPages.form.appearance.description")}
rightContent={ rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(8)}>
<Stack alignItems={"center"}> <Stack alignItems={"center"}>
<Controller <Controller
name="logo" name="logo"
@@ -442,7 +407,7 @@ const CreateStatusPage = () => {
title={t("pages.statusPages.form.features.title")} title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")} subtitle={t("pages.statusPages.form.features.description")}
rightContent={ rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(2)}>
<Controller <Controller
name="showCharts" name="showCharts"
control={control} control={control}
@@ -458,23 +423,6 @@ const CreateStatusPage = () => {
/> />
)} )}
/> />
<Controller
name="showInfrastructure"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t(
"pages.statusPages.form.features.option.showInfrastructure.label"
)}
/>
)}
/>
{/* <Controller {/* <Controller
name="showUptimePercentage" name="showUptimePercentage"
control={control} control={control}
@@ -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 (
<Stack
direction="row"
justifyContent="space-between"
>
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{label}
</Typography>
<Typography variant="body2">{value}</Typography>
</Stack>
);
};
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 (
<Grid
size={isSmall ? 12 : 4}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
gap: theme.spacing(SPACING.LG),
padding: theme.spacing(LAYOUT.XS),
borderRight: isSmall ? "none" : `1px solid ${theme.palette.divider}`,
borderBottom: isSmall ? `1px solid ${theme.palette.divider}` : "none",
"&:last-child": {
borderRight: "none",
borderBottom: "none",
paddingBottom: theme.spacing(SPACING.LG),
},
}}
>
<Box
display="flex"
flexDirection="column"
alignItems="center"
>
<Gauge
progress={progress}
radius={GAUGE_RADIUS}
strokeWidth={GAUGE_STROKE_WIDTH}
/>
<Typography variant="body2">{label}</Typography>
</Box>
{details && details.length > 0 && (
<Box
width="100%"
paddingX={theme.spacing(LAYOUT.LG)}
>
{details.map((detail) => (
<MetricDetailRow
key={detail.label}
label={detail.label}
value={detail.value}
/>
))}
</Box>
)}
</Grid>
);
};
type LatestCheck = NonNullable<Monitor["recentChecks"]>[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 (
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{t("pages.statusPages.monitorsList.noData")}
</Typography>
);
}
const metrics: MetricConfig[] = [
buildCpuMetric(latestCheck, t),
buildMemoryMetric(latestCheck, t),
buildDiskMetric(latestCheck, t),
].filter((m): m is MetricConfig => m !== null);
if (metrics.length === 0) {
return (
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{t("pages.statusPages.monitorsList.noData")}
</Typography>
);
}
return (
<Grid
container
alignItems="center"
padding={theme.spacing(LAYOUT.XS)}
>
{metrics.map(({ key, label, progress, details }) => (
<MetricItem
key={key}
label={label}
progress={progress}
details={details}
/>
))}
</Grid>
);
};
@@ -5,16 +5,13 @@ import Typography from "@mui/material/Typography";
import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common"; import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common";
import { StatusLabel, BaseBox } from "@/Components/design-elements"; import { StatusLabel, BaseBox } from "@/Components/design-elements";
import { SwitchComponent } from "@/Components/inputs"; import { SwitchComponent } from "@/Components/inputs";
import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics";
import { useTheme, type Theme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { useState } from "react"; import { useState } from "react";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage"; import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
interface StatusPageMonitor extends Monitor { interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"]; checks?: Monitor["recentChecks"];
@@ -25,124 +22,29 @@ interface MonitorsListProps {
monitors: StatusPageMonitor[]; 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 (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.XS)}
mb={theme.spacing(LAYOUT.XS)}
>
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(SPACING.LG)}
mb={theme.spacing(SPACING.SM)}
>
<Typography
variant="h6"
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{monitor.name}
</Typography>
<Typography
variant="caption"
sx={getMonitorBadgeStyles(monitor.type ?? "", theme)}
>
{getMonitorTypeLabel(monitor.type, t)}
</Typography>
</Stack>
{showURL && monitor.url && (
<Typography
variant="body2"
color={theme.palette.text.secondary}
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{monitor.url}
</Typography>
)}
</Box>
<StatusLabel status={monitor.status} />
</Stack>
);
};
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 <InfrastructureMetrics monitor={monitor} />;
}
if (statusPage.showCharts === false) return null;
const checks = monitor.checks?.slice().reverse() ?? [];
return (
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1, mb: theme.spacing(SPACING.LG) }}>
{chartType === "histogram" ? (
<HistogramResponseTime
height={{ xs: 50, md: 100 }}
gap={{ xs: theme.spacing(SPACING.SM), md: theme.spacing(LAYOUT.SM) }}
checks={checks}
/>
) : (
<HeatmapResponseTime checks={checks} />
)}
</Box>
);
};
export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => { export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const showURL = useSelector((state: RootState) => state.ui?.showURL); const showURL = useSelector((state: RootState) => state.ui?.showURL);
const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram"); const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram");
return ( return (
<Stack gap={theme.spacing(LAYOUT.MD)}> <Stack gap={theme.spacing(8)}>
{statusPage.showCharts && ( {statusPage.showCharts !== false && (
<Stack <Stack
direction="row" direction={"row"}
alignItems="center" alignItems={"center"}
gap={theme.spacing(LAYOUT.SM)}
> >
<Typography>{t("pages.statusPages.monitorsList.chartTypeHeatmap")}</Typography> <Typography>{t("pages.statusPages.monitorsList.chartTypeHeatmap")}</Typography>
<SwitchComponent <SwitchComponent
dualOption dualOption
value={chartType} value={chartType}
checked={chartType === "histogram"} checked={chartType === "histogram"}
onChange={(e) => setChartType(e.target.checked ? "histogram" : "heatmap")} onChange={(e) => {
setChartType(e.target.checked ? "histogram" : "heatmap");
}}
/> />
<Typography> <Typography>
{t("pages.statusPages.monitorsList.chartTypeHistogram")} {t("pages.statusPages.monitorsList.chartTypeHistogram")}
@@ -150,22 +52,59 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
</Stack> </Stack>
)} )}
{monitors.map((monitor) => ( {monitors?.map((monitor) => {
<BaseBox return (
key={monitor.id} <BaseBox
padding={theme.spacing(LAYOUT.MD)} key={monitor.id}
> padding={theme.spacing(4)}
<MonitorHeader >
monitor={monitor} <Stack
showURL={showURL} direction="row"
/> alignItems="center"
<MonitorContent justifyContent="space-between"
monitor={monitor} gap={theme.spacing(4)}
statusPage={statusPage} mb={statusPage.showCharts !== false ? theme.spacing(4) : 0}
chartType={chartType} >
/> <Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
</BaseBox> <Typography
))} variant="h6"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{monitor.name}
</Typography>
{showURL && (
<Typography
variant="body2"
color="text.secondary"
>
{monitor.url}
</Typography>
)}
</Box>
<StatusLabel status={monitor.status} />
</Stack>
{statusPage.showCharts !== false && (
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
{chartType === "histogram" ? (
<HistogramResponseTime
height={{ xs: 50, md: 100 }}
gap={{ xs: theme.spacing(0.5), md: theme.spacing(5) }}
checks={monitor?.checks?.slice().reverse() ?? []}
/>
) : (
<HeatmapResponseTime
checks={monitor?.checks?.slice().reverse() ?? []}
/>
)}
</Box>
)}
</BaseBox>
);
})}
</Stack> </Stack>
); );
}; };
@@ -1,115 +1,35 @@
import { import { AlertTriangle, CircleCheck } from "lucide-react";
AlertTriangle,
CircleCheck,
CircleX,
Loader,
PauseCircle,
ShieldAlert,
Wrench,
} from "lucide-react";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
import type { Theme } from "@mui/material"; import type { Theme } from "@mui/material";
import type { Monitor, MonitorStatus } from "@/Types/Monitor"; import { useTheme } from "@mui/material";
import type { Monitor } from "@/Types/Monitor";
interface StatusDisplay {
icon: JSX.Element;
msg?: string;
color?: string;
}
const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => { const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => {
const monitorsStatus: StatusDisplay = { const monitorsStatus: Record<string, any> = {
icon: <AlertTriangle size={24} />, icon: <AlertTriangle size={24} />,
}; };
// Handle empty monitors array if (monitors.every((monitor) => monitor.status === "up")) {
if (monitors.length === 0) {
monitorsStatus.msg = t("pages.statusPages.statusBar.noMonitors");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <CircleX size={24} />;
return monitorsStatus;
}
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));
// All monitors in a single state
if (allOf("up")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allUp"); monitorsStatus.msg = t("pages.statusPages.statusBar.allUp");
monitorsStatus.color = theme.palette.success.main; monitorsStatus.color = theme.palette.success.main;
monitorsStatus.icon = <CircleCheck size={24} />; monitorsStatus.icon = <CircleCheck size={24} />;
} else if (allOf("breached")) { return monitorsStatus;
monitorsStatus.msg = t("pages.statusPages.statusBar.allBreached"); } else if (monitors.every((monitor) => monitor.status === "down")) {
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <ShieldAlert size={24} />;
} else if (allOf("maintenance")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allMaintenance");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <Wrench size={24} />;
} else if (allOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allDown"); monitorsStatus.msg = t("pages.statusPages.statusBar.allDown");
monitorsStatus.color = theme.palette.error.main; monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <CircleX size={24} />; return monitorsStatus;
} else if (allOf("paused")) { } else if (monitors.some((monitor) => monitor.status === "down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allPaused");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <PauseCircle size={24} />;
} else if (allOf("initializing")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allInitializing");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <Loader size={24} />;
// Breached takes highest priority in mixed states
} else if (someOf("breached") && someOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.breachedAndDown");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <ShieldAlert size={24} />;
} else if (someOf("breached")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.breached");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <ShieldAlert size={24} />;
// Maintenance combinations
} else if (someOf("maintenance") && someOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.maintenanceAndDown");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <Wrench size={24} />;
} else if (someOf("maintenance") && noneOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.maintenance");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <Wrench size={24} />;
// Degraded (some down, no maintenance/breached)
} else if (someOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.degraded"); monitorsStatus.msg = t("pages.statusPages.statusBar.degraded");
monitorsStatus.color = theme.palette.warning.main; monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <AlertTriangle size={24} />; return monitorsStatus;
// Some Paused
} else if (someOf("paused")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.partiallyPaused");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <PauseCircle size={24} />;
// Initializing
} else if (someOf("initializing")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.initializing");
monitorsStatus.color = theme.palette.info.main;
monitorsStatus.icon = <Loader size={24} />;
} else { } else {
monitorsStatus.msg = t("pages.statusPages.statusBar.unknown"); monitorsStatus.msg = t("pages.statusPages.statusBar.unknown");
monitorsStatus.color = theme.palette.warning.main; monitorsStatus.color = theme.palette.warning.main;
return monitorsStatus;
} }
return monitorsStatus;
}; };
interface StatusBarProps { interface StatusBarProps {
+3 -9
View File
@@ -23,22 +23,16 @@ const StatusPageView = () => {
const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isPublic = location.pathname.startsWith("/status/public"); const isPublic = location.pathname.startsWith("/status/public");
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null; const apiUrl = url ? `/status-page/${url}?type=uptime` : null;
const { data, isLoading, error } = useGet<StatusPageResponse>( const { data, isLoading, error } = useGet<StatusPageResponse>(apiUrl);
apiUrl,
{},
{
refreshInterval: 10000,
}
);
const statusPage = data?.statusPage; const statusPage = data?.statusPage;
const monitors = data?.monitors ?? []; const monitors = data?.monitors ?? [];
if (!statusPage) return null; if (!statusPage) return null;
if (monitors.length === 0) { if (monitors?.length === 0) {
return ( return (
<BasePage <BasePage
loading={isLoading} loading={isLoading}
@@ -97,7 +97,7 @@ export const StatusPagesTable = ({
{ {
id: "type", id: "type",
content: t("common.table.headers.type"), content: t("common.table.headers.type"),
render: (row) => row.type.join(", "), render: (row) => row.type,
}, },
{ {
id: "status", id: "status",
@@ -20,7 +20,7 @@ const getHeaders = (t: Function, uiTimezone: string) => {
}, },
{ {
id: "date", id: "date",
content: t("common.table.headers.dateTime"), content: t("pages.checks.table.headers.dateTime"),
render: (row) => { render: (row) => {
return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone); return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone);
}, },
@@ -1,107 +0,0 @@
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
import prettyMilliseconds from "pretty-ms";
const getHeaders = (t: Function, uiTimezone: string) => {
const headers: Header<FlatGeoCheck>[] = [
{
id: "status",
content: t("common.table.headers.status"),
render: (row) => {
const status = row.status ? "up" : "down";
return <StatusLabel status={status} />;
},
},
{
id: "date",
content: t("common.table.headers.dateTime"),
render: (row) => {
return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone);
},
},
{
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
return row.statusCode || "N/A";
},
},
{
id: "location",
content: t("pages.checks.table.headers.location"),
render: (row) => {
const location = row.location;
if (!location) return "N/A";
const { continent, country, city } = location;
return `${continent} - ${country}, ${city}`;
},
},
{
id: "responseTime",
content: t("common.table.headers.responseTime"),
render: (row) => {
if (!row.timings?.total) return "N/A";
return prettyMilliseconds(row.timings.total, { compact: true });
},
},
];
return headers;
};
export const GeoChecksTable = ({
geoChecks,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
geoChecks: FlatGeoCheck[];
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const headers = getHeaders(t, uiTimezone);
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
const value = Number(e.target.value);
setPage(0);
setRowsPerPage(value);
};
return (
<Box>
<Table
headers={headers}
data={geoChecks}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</Box>
);
};
+4 -95
View File
@@ -6,12 +6,9 @@ import {
RadialAvgResponse, RadialAvgResponse,
HistogramDetails, HistogramDetails,
HeaderMonitorControls, HeaderMonitorControls,
HeaderGeoTabs,
GeoChecksMap,
} from "@/Components/monitors"; } from "@/Components/monitors";
import { TrendingUp, AlertTriangle } from "lucide-react"; import { TrendingUp, AlertTriangle } from "lucide-react";
import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable"; import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable";
import { GeoChecksTable } from "@/Pages/Uptime/Details/Components/GeoChecksTable";
import { MonitorStatBoxes } from "@/Components/monitors"; import { MonitorStatBoxes } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
@@ -20,17 +17,11 @@ import { useState, useMemo } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { useGet } from "@/Hooks/UseApi"; import { useGet } from "@/Hooks/UseApi";
import { type MonitorDetailsResponse, supportsGeoCheck } from "@/Types/Monitor"; import type { MonitorDetailsResponse } from "@/Types/Monitor";
import type { ChecksResponse } from "@/Types/Check"; import type { ChecksResponse } from "@/Types/Check";
import type {
GeoChecksResult,
FlatGeoChecksResponse,
GeoContinent,
} from "@/Types/GeoCheck";
import type { RootState } from "@/Types/state"; import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils"; import { formatDateWithTz } from "@/Utils/TimeUtils";
import { t } from "i18next"; import { t } from "i18next";
import { Typography } from "@mui/material";
const certificateDateFormat = "MMM D, YYYY h A"; const certificateDateFormat = "MMM D, YYYY h A";
@@ -46,10 +37,7 @@ const UptimeDetailsPage = () => {
const [page, setPage] = useState<number>(0); const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(5); const [rowsPerPage, setRowsPerPage] = useState<number>(5);
const [geoPage, setGeoPage] = useState<number>(0);
const [geoRowsPerPage, setGeoRowsPerPage] = useState<number>(5);
const [dateRange, setDateRange] = useState<string>("recent"); const [dateRange, setDateRange] = useState<string>("recent");
const [selectedLocation, setSelectedLocation] = useState<GeoContinent>("NA");
const monitorDetailsUrl = useMemo(() => { const monitorDetailsUrl = useMemo(() => {
if (!monitorId) { if (!monitorId) {
@@ -68,7 +56,7 @@ const UptimeDetailsPage = () => {
} = useGet<MonitorDetailsResponse>( } = useGet<MonitorDetailsResponse>(
monitorDetailsUrl, monitorDetailsUrl,
{}, {},
{ refreshInterval: 10000, keepPreviousData: true, revalidateOnFocus: false } { refreshInterval: 10000, keepPreviousData: true }
); );
const monitorData = monitorDetailsData?.monitorData; const monitorData = monitorDetailsData?.monitorData;
@@ -83,11 +71,7 @@ const UptimeDetailsPage = () => {
return `/monitors/certificate/${monitorId}`; return `/monitors/certificate/${monitorId}`;
}, [monitorId, monitor?.type]); }, [monitorId, monitor?.type]);
const { data: certificateData } = useGet<CertificateResponse>( const { data: certificateData } = useGet<CertificateResponse>(certificateUrl);
certificateUrl,
{},
{ revalidateOnFocus: false }
);
const certificateExpiry = useMemo(() => { const certificateExpiry = useMemo(() => {
if (!certificateData?.certificateDate) { if (!certificateData?.certificateDate) {
@@ -118,58 +102,9 @@ const UptimeDetailsPage = () => {
const { data: checksData, isLoading: checksIsLoading } = useGet<ChecksResponse>( const { data: checksData, isLoading: checksIsLoading } = useGet<ChecksResponse>(
checksUrl, checksUrl,
{}, {},
{ keepPreviousData: true, revalidateOnFocus: false } { keepPreviousData: true }
); );
const geoChecksUrl = useMemo(() => {
if (!monitorId || !supportsGeoCheck(monitor?.type) || !monitor?.geoCheckEnabled) {
return null;
}
const params = new URLSearchParams();
params.append("dateRange", dateRange);
params.append("continent", selectedLocation);
return `/monitors/${monitorId}/geo-checks?${params.toString()}`;
}, [monitorId, monitor?.type, monitor?.geoCheckEnabled, dateRange, selectedLocation]);
const { data: geoGroupedData } = useGet<GeoChecksResult>(
geoChecksUrl,
{},
{ keepPreviousData: true, revalidateOnFocus: false }
);
const geoGroupedChecks = geoGroupedData?.groupedGeoChecks ?? [];
// Fetch paginated geo checks for the table
const geoChecksTableUrl = useMemo(() => {
if (!monitorId || !supportsGeoCheck(monitor?.type) || !monitor?.geoCheckEnabled) {
return null;
}
const params = new URLSearchParams();
params.append("sortOrder", "desc");
params.append("dateRange", dateRange);
params.append("page", String(geoPage));
params.append("rowsPerPage", String(geoRowsPerPage));
return `/geo-checks/${monitorId}?${params.toString()}`;
}, [
monitorId,
monitor?.type,
monitor?.geoCheckEnabled,
dateRange,
geoPage,
geoRowsPerPage,
]);
const { data: geoChecksTableData } = useGet<FlatGeoChecksResponse>(
geoChecksTableUrl,
{},
{ keepPreviousData: true, revalidateOnFocus: false }
);
const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
const geoLocations = monitor?.geoCheckLocations;
const checks = checksData?.checks ?? []; const checks = checksData?.checks ?? [];
const checksCount = checksData?.checksCount ?? 0; const checksCount = checksData?.checksCount ?? 0;
@@ -192,7 +127,6 @@ const UptimeDetailsPage = () => {
dateRange={dateRange} dateRange={dateRange}
setDateRange={setDateRange} setDateRange={setDateRange}
/> />
<Stack <Stack
direction={{ xs: "column", md: "row" }} direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)} gap={theme.spacing(8)}
@@ -226,31 +160,6 @@ const UptimeDetailsPage = () => {
rowsPerPage={rowsPerPage} rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage} setRowsPerPage={setRowsPerPage}
/> />
{monitor?.geoCheckEnabled && (
<>
<Typography variant="h1">Location breakdown</Typography>
<HeaderGeoTabs
geoCheckEnabled={monitor?.geoCheckEnabled ?? false}
locations={geoLocations}
selectedLocation={selectedLocation}
onLocationChange={setSelectedLocation}
/>
<HistogramDetails
checks={geoGroupedChecks}
range={dateRange}
/>
<GeoChecksTable
geoChecks={geoChecksForTable}
count={geoChecksCount}
page={geoPage}
setPage={setGeoPage}
rowsPerPage={geoRowsPerPage}
setRowsPerPage={setGeoRowsPerPage}
/>
<GeoChecksMap geoChecks={geoChecksForTable} />
</>
)}
</BasePage> </BasePage>
); );
}; };
@@ -181,7 +181,6 @@ export const MonitorTable = ({
onClick={(e) => handleSort(e, "status")} onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }} sx={{ cursor: "pointer" }}
> >
<Box width={theme.spacing(8)} />
{t("common.table.headers.status")} {t("common.table.headers.status")}
{renderSortIcon(sortField === "status")} {renderSortIcon(sortField === "status")}
</Stack> </Stack>
@@ -212,7 +211,6 @@ export const MonitorTable = ({
onClick={(e) => handleSort(e, "type")} onClick={(e) => handleSort(e, "type")}
sx={{ cursor: "pointer" }} sx={{ cursor: "pointer" }}
> >
<Box width={theme.spacing(8)} />
{t("common.table.headers.type")} {t("common.table.headers.type")}
{renderSortIcon(sortField === "type")} {renderSortIcon(sortField === "type")}
</Stack> </Stack>
+4 -6
View File
@@ -42,11 +42,11 @@ const UptimeMonitorsPage = () => {
const debouncedSearch = useDebounce<string>(search, 300); const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values // Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API // Status: "up" -> true, "down" -> false
// State: "active" -> true, "paused" -> false // State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => { const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up"; if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "down"; if (selectedStatus === "down") return "false";
return undefined; return undefined;
}, [selectedStatus]); }, [selectedStatus]);
@@ -68,9 +68,7 @@ const UptimeMonitorsPage = () => {
// Default to all types when none selected // Default to all types when none selected
const effectiveTypes = const effectiveTypes =
selectedTypes.length > 0 selectedTypes.length > 0 ? selectedTypes : ["http", "ping", "docker", "port", "game"];
? selectedTypes
: ["http", "ping", "docker", "port", "game", "grpc", "websocket"];
// Build URL for monitors with checks // Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => { const monitorsWithChecksUrl = useMemo(() => {
+8 -12
View File
@@ -1,5 +1,3 @@
export const CHECK_TTL_SENTINEL = 366;
export interface CheckMetadata { export interface CheckMetadata {
monitorId: string; monitorId: string;
teamId: string; teamId: string;
@@ -127,6 +125,9 @@ export interface Check {
timings?: CheckTimings; timings?: CheckTimings;
statusCode: number; statusCode: number;
message: string; message: string;
ack: boolean;
ackAt?: string | null;
expiry: string;
cpu?: CheckCpuInfo; cpu?: CheckCpuInfo;
memory?: CheckMemoryInfo; memory?: CheckMemoryInfo;
disk?: CheckDiskInfo[]; disk?: CheckDiskInfo[];
@@ -139,6 +140,7 @@ export interface Check {
seo?: number; seo?: number;
performance?: number; performance?: number;
audits?: CheckAudits; audits?: CheckAudits;
__v: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -149,15 +151,6 @@ export interface GroupedCheck {
totalChecks: number; totalChecks: number;
} }
export interface PageSpeedGroupedCheck {
bucketDate: string;
performance: number;
accessibility: number;
bestPractices: number;
seo: number;
totalChecks: number;
}
export interface LatestCheck { export interface LatestCheck {
status: boolean; status: boolean;
responseTime: number; responseTime: number;
@@ -241,7 +234,10 @@ export interface ChecksSummary {
downChecks: number; downChecks: number;
} }
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & { export type CheckSnapshot = Omit<
Check,
"metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt"
> & {
originalResponseTime: number; originalResponseTime: number;
}; };
-80
View File
@@ -1,80 +0,0 @@
export const GeoContinents = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
export type GeoContinent = (typeof GeoContinents)[number];
export interface GeoCheckMetadata {
monitorId: string;
teamId: string;
type: string;
}
export interface GeoCheckTimings {
total: number;
dns: number;
tcp: number;
tls: number;
firstByte: number;
download: number;
}
export interface GeoCheckLocation {
continent: GeoContinent;
region: string;
country: string;
state: string;
city: string;
longitude: number;
latitude: number;
}
export interface GeoCheckResult {
location: GeoCheckLocation;
status: boolean;
statusCode: number;
timings: GeoCheckTimings;
}
export interface GeoCheck {
id: string;
metadata: GeoCheckMetadata;
results: GeoCheckResult[];
expiry: string;
__v: number;
createdAt: string;
updatedAt: string;
}
export interface FlatGeoCheck {
id: string;
monitorId: string;
teamId: string;
type: string;
location: GeoCheckLocation;
status: boolean;
statusCode: number;
timings: GeoCheckTimings;
createdAt: string;
updatedAt: string;
}
export interface GroupedGeoCheck {
bucketDate: string;
continent: GeoContinent;
avgResponseTime: number;
totalChecks: number;
uptimePercentage: number;
}
export interface GeoChecksResult {
monitorType: string;
groupedGeoChecks: GroupedGeoCheck[];
}
export interface GeoChecksResponse {
geoChecks: GeoCheck[];
geoChecksCount: number;
}
export interface FlatGeoChecksResponse {
geoChecks: FlatGeoCheck[];
geoChecksCount: number;
}
+1 -22
View File
@@ -1,7 +1,4 @@
import type { GroupedCheck, CheckSnapshot } from "@/Types/Check"; import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
export type { GeoContinent } from "@/Types/GeoCheck";
export const MonitorTypes = [ export const MonitorTypes = [
"http", "http",
@@ -11,21 +8,10 @@ export const MonitorTypes = [
"docker", "docker",
"port", "port",
"game", "game",
"grpc",
"websocket",
"unknown", "unknown",
] as const; ] as const;
export type MonitorType = (typeof MonitorTypes)[number]; export type MonitorType = (typeof MonitorTypes)[number];
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
export const supportsGeoCheck = (type: MonitorType | undefined): boolean => {
if (!type) {
return false;
}
return GeoCheckSupportedTypes.includes(type);
};
export const MonitorStatuses = [ export const MonitorStatuses = [
"up", "up",
"down", "down",
@@ -71,11 +57,7 @@ export interface Monitor {
tempAlertCounter: number; tempAlertCounter: number;
selectedDisks: string[]; selectedDisks: string[];
gameId?: string; gameId?: string;
grpcServiceName?: string;
group: string | null; group: string | null;
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
recentChecks: CheckSnapshot[]; recentChecks: CheckSnapshot[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -130,10 +112,7 @@ export interface MonitorDetailsResponse {
} }
export interface PageSpeedDetailsResponse { export interface PageSpeedDetailsResponse {
monitorData: { monitor: MonitorWithChecks;
monitor: Monitor;
groupedChecks: PageSpeedGroupedCheck[];
};
monitorStats: MonitorStats | null; monitorStats: MonitorStats | null;
} }
-6
View File
@@ -5,10 +5,6 @@ export const NotificationChannels = [
"webhook", "webhook",
"pager_duty", "pager_duty",
"matrix", "matrix",
"teams",
"telegram",
"pushover",
"twilio",
] as const; ] as const;
export type NotificationChannel = (typeof NotificationChannels)[number]; export type NotificationChannel = (typeof NotificationChannels)[number];
@@ -23,8 +19,6 @@ export interface Notification {
homeserverUrl?: string; homeserverUrl?: string;
roomId?: string; roomId?: string;
accessToken?: string; accessToken?: string;
accountSid?: string;
twilioPhoneNumber?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
+2 -24
View File
@@ -1,31 +1,10 @@
import type { Monitor, MonitorType } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
export type MonitorDisplayType = "uptime" | "infrastructure";
export const MONITOR_TYPE_KEYS: Partial<Record<MonitorType, string>> = {
http: "pages.common.monitors.monitorTypes.optionHttp",
ping: "pages.common.monitors.monitorTypes.optionPing",
docker: "pages.common.monitors.monitorTypes.optionDocker",
port: "pages.common.monitors.monitorTypes.optionPort",
game: "pages.common.monitors.monitorTypes.optionGame",
grpc: "pages.common.monitors.monitorTypes.optionGrpc",
websocket: "pages.common.monitors.monitorTypes.optionWebSocket",
hardware: "pages.common.monitors.monitorTypes.optionHardware",
pagespeed: "pages.common.monitors.monitorTypes.optionPagespeed",
};
export const getMonitorTypeLabel = (
type: MonitorType,
t: (key: string) => string
): string => {
const key = MONITOR_TYPE_KEYS[type];
return key ? t(key) : type;
};
export interface StatusPage { export interface StatusPage {
id: string; id: string;
userId: string; userId: string;
teamId: string; teamId: string;
type: MonitorDisplayType[]; type: string;
companyName: string; companyName: string;
url: string; url: string;
timezone?: string; timezone?: string;
@@ -41,7 +20,6 @@ export interface StatusPage {
showCharts: boolean; showCharts: boolean;
showUptimePercentage: boolean; showUptimePercentage: boolean;
showAdminLoginLink: boolean; showAdminLoginLink: boolean;
showInfrastructure: boolean;
customCSS: string; customCSS: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
-6
View File
@@ -4,7 +4,6 @@ declare const __APP_VERSION__: string;
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_APP_API_V2_BASE_URL?: string; readonly VITE_APP_API_V2_BASE_URL?: string;
readonly VITE_APP_LOG_LEVEL?: "debug" | "info" | "warn" | "error";
} }
interface ImportMeta { interface ImportMeta {
@@ -18,8 +17,3 @@ declare module "*.svg?react" {
>; >;
export default ReactComponent; export default ReactComponent;
} }
declare module "*.css" {
const content: string;
export default content;
}
-2
View File
@@ -8,8 +8,6 @@ export const getMonitorPath = (type: MonitorType): string => {
port: "uptime", port: "uptime",
ping: "uptime", ping: "uptime",
game: "uptime", game: "uptime",
grpc: "uptime",
websocket: "uptime",
unknown: "uptime", unknown: "uptime",
docker: "uptime", docker: "uptime",
hardware: "infrastructure", hardware: "infrastructure",
-6
View File
@@ -12,8 +12,6 @@ export const typographyLevels = {
export const colors = { export const colors = {
gray200: "#EFEFEF", gray200: "#EFEFEF",
gray700: "#313131", gray700: "#313131",
gray900: "#151518",
gray850: "#1c1c21",
blueBlueWave: "#1570EF", blueBlueWave: "#1570EF",
}; };
@@ -33,8 +31,4 @@ export const darkPalette = {
secondary: { secondary: {
main: colors.gray700, main: colors.gray700,
}, },
background: {
default: colors.gray900,
paper: colors.gray850,
},
}; };
-17
View File
@@ -1,17 +0,0 @@
export const SPACING = {
XS: 0.5,
SM: 1,
MD: 1.5,
LG: 2,
XL: 2.5,
XXL: 3,
} as const;
export const LAYOUT = {
XS: 4,
SM: 6,
MD: 8,
LG: 10,
XL: 12,
XXL: 16,
} as const;
-137
View File
@@ -1,137 +0,0 @@
import type { LogLevel } from "@/Types/Log";
interface LogContext {
[key: string]: any;
}
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
interface ErrorLogData {
timestamp: string;
level: LogLevel;
message: string;
error?: {
message: string;
name: string;
stack?: string;
};
context?: LogContext;
url: string;
userAgent: string;
}
const configuredLevel: LogLevel = import.meta.env.VITE_APP_LOG_LEVEL || "error";
const configuredPriority = LOG_LEVEL_PRIORITY[configuredLevel];
const shouldLog = (level: LogLevel): boolean => {
return LOG_LEVEL_PRIORITY[level] >= configuredPriority;
};
const formatError = (error?: Error) => {
if (!error) return undefined;
return {
message: error.message,
name: error.name,
stack: error.stack,
};
};
const createLogData = (
level: LogLevel,
message: string,
error?: Error,
context?: LogContext
): ErrorLogData => {
return {
timestamp: new Date().toISOString(),
level,
message,
error: formatError(error),
context,
url: window.location.href,
userAgent: navigator.userAgent,
};
};
const error = (message: string, error?: Error, context?: LogContext): void => {
if (!shouldLog("error")) return;
const logData = createLogData("error", message, error, context);
if (configuredLevel === "debug") {
console.group(`ERROR: ${message}`);
if (error) {
console.error("Error:", error);
}
if (context && Object.keys(context).length > 0) {
console.log("Context:", context);
}
console.log("URL:", logData.url);
console.log("Timestamp:", logData.timestamp);
console.groupEnd();
} else {
console.error(JSON.stringify(logData));
}
};
const warn = (message: string, context?: LogContext): void => {
if (!shouldLog("warn")) return;
const logData = createLogData("warn", message, undefined, context);
if (configuredLevel === "debug") {
console.group(`WARN: ${message}`);
if (context && Object.keys(context).length > 0) {
console.log("Context:", context);
}
console.groupEnd();
} else {
console.warn(JSON.stringify(logData));
}
};
const debug = (message: string, data?: any): void => {
if (!shouldLog("debug")) return;
console.group(`DEBUG: ${message}`);
if (data !== undefined) {
console.log(data);
}
console.groupEnd();
};
const info = (message: string, data?: any): void => {
if (!shouldLog("info")) return;
if (configuredLevel === "debug") {
console.log(`INFO: ${message}`, data);
} else {
const logData = {
timestamp: new Date().toISOString(),
level: "info" as LogLevel,
message,
data,
};
console.log(JSON.stringify(logData));
}
};
export interface ILogger {
error(message: string, error?: Error, context?: LogContext): void;
warn(message: string, context?: LogContext): void;
debug(message: string, data?: any): void;
info(message: string, data?: any): void;
}
export const logger: ILogger = {
error,
warn,
debug,
info,
};
-30
View File
@@ -1,5 +1,4 @@
import { z } from "zod"; import { z } from "zod";
import { GeoContinents } from "@/Types/GeoCheck";
// URL schema with custom error message // URL schema with custom error message
const urlSchema = z.url({ message: "Please enter a valid URL" }); const urlSchema = z.url({ message: "Please enter a valid URL" });
@@ -21,12 +20,6 @@ const baseSchema = z.object({
.number({ message: "Threshold percentage is required" }) .number({ message: "Threshold percentage is required" })
.min(1, "Incident percentage must be at least 1") .min(1, "Incident percentage must be at least 1")
.max(100, "Incident percentage must be at most 100"), .max(100, "Incident percentage must be at most 100"),
geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z
.number()
.min(300000, "Interval must be at least 5 minutes")
.optional(),
}); });
// HTTP monitor schema // HTTP monitor schema
@@ -73,18 +66,6 @@ const gameSchema = baseSchema.extend({
gameId: z.string().min(1, "Game type is required"), gameId: z.string().min(1, "Game type is required"),
}); });
// gRPC monitor schema
const grpcSchema = baseSchema.extend({
type: z.literal("grpc"),
url: z.string().min(1, "Host is required"),
port: z
.number()
.min(1, "Port must be at least 1")
.max(65535, "Port must be at most 65535"),
grpcServiceName: z.string().optional(),
ignoreTlsErrors: z.boolean(),
});
// PageSpeed monitor schema // PageSpeed monitor schema
const pagespeedSchema = baseSchema.extend({ const pagespeedSchema = baseSchema.extend({
type: z.literal("pagespeed"), type: z.literal("pagespeed"),
@@ -115,13 +96,6 @@ const hardwareSchema = baseSchema.extend({
selectedDisks: z.array(z.string()), selectedDisks: z.array(z.string()),
}); });
// WebSocket monitor schema
const websocketSchema = baseSchema.extend({
type: z.literal("websocket"),
url: z.string().min(1, "WebSocket URL is required"),
ignoreTlsErrors: z.boolean(),
});
// Discriminated union of all monitor types // Discriminated union of all monitor types
export const monitorSchema = z.discriminatedUnion("type", [ export const monitorSchema = z.discriminatedUnion("type", [
httpSchema, httpSchema,
@@ -129,10 +103,8 @@ export const monitorSchema = z.discriminatedUnion("type", [
portSchema, portSchema,
dockerSchema, dockerSchema,
gameSchema, gameSchema,
grpcSchema,
pagespeedSchema, pagespeedSchema,
hardwareSchema, hardwareSchema,
websocketSchema,
]); ]);
export type MonitorFormData = z.infer<typeof monitorSchema>; export type MonitorFormData = z.infer<typeof monitorSchema>;
@@ -144,8 +116,6 @@ export {
portSchema, portSchema,
dockerSchema, dockerSchema,
gameSchema, gameSchema,
grpcSchema,
pagespeedSchema, pagespeedSchema,
hardwareSchema, hardwareSchema,
websocketSchema,
}; };
-29
View File
@@ -45,31 +45,6 @@ const matrixSchema = baseSchema.extend({
accessToken: z.string().min(1, "Access token is required"), accessToken: z.string().min(1, "Access token is required"),
}); });
const teamsSchema = baseSchema.extend({
type: z.literal("teams"),
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
});
const telegramSchema = baseSchema.extend({
type: z.literal("telegram"),
address: z.string().min(1, "Chat ID is required"),
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", [ export const notificationSchema = z.discriminatedUnion("type", [
emailSchema, emailSchema,
slackSchema, slackSchema,
@@ -77,10 +52,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
webhookSchema, webhookSchema,
pagerDutySchema, pagerDutySchema,
matrixSchema, matrixSchema,
teamsSchema,
telegramSchema,
pushoverSchema,
twilioSchema,
]); ]);
export type NotificationFormData = z.infer<typeof notificationSchema>; export type NotificationFormData = z.infer<typeof notificationSchema>;
+37 -20
View File
@@ -1,4 +1,3 @@
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
import { z } from "zod"; import { z } from "zod";
export const settingsSchema = z.object({ export const settingsSchema = z.object({
@@ -7,50 +6,68 @@ export const settingsSchema = z.object({
systemEmailRejectUnauthorized: z.boolean(), systemEmailRejectUnauthorized: z.boolean(),
systemEmailConnectionHost: z systemEmailConnectionHost: z
.string() .string()
.transform((val) => (val.trim() === "" ? null : val.trim())) .transform((val) => (val.trim() === "" ? undefined : val.trim()))
.optional(), .optional(),
systemEmailSecure: z.boolean().optional(), systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(), systemEmailPool: z.boolean().optional(),
showURL: z.boolean().optional(), showURL: z.boolean().optional(),
checkTTL: z checkTTL: z.number().int().min(1, "Please enter a value").max(365, "Maximum 365 days"),
.number()
.int()
.min(1, "Please enter a value")
.max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`),
pagespeedApiKey: z pagespeedApiKey: z
.string() .string()
.transform((val) => (val.trim() === "" ? null : val.trim())) .transform((val) => (val.trim() === "" ? undefined : val.trim()))
.optional(), .optional(),
systemEmailHost: z systemEmailHost: z
.string() .string()
.regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address") .regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address")
.transform((val) => (val.trim() === "" ? null : val.trim())) .transform((val) => (val.trim() === "" ? undefined : val.trim()))
.optional(), .optional(),
systemEmailPort: z.number().int().min(1).max(65535).optional(), systemEmailPort: z.number().int().min(1).max(65535).optional(),
systemEmailAddress: z systemEmailAddress: z
.string()
.email("Please enter a valid email address") .email("Please enter a valid email address")
.or(z.literal("")) .or(z.literal(""))
.transform((val) => (val === "" ? null : val.toLowerCase().trim())) .transform((val) => (val === "" ? undefined : val.toLowerCase().trim()))
.optional(), .optional(),
systemEmailUser: z systemEmailUser: z
.string() .string()
.transform((val) => (val.trim() === "" ? null : val.trim())) .transform((val) => (val.trim() === "" ? undefined : val.trim()))
.optional(), .optional(),
systemEmailPassword: z systemEmailPassword: z
.string() .string()
.transform((val) => (val.trim() === "" ? null : val.trim())) .transform((val) => (val.trim() === "" ? undefined : val.trim()))
.optional(), .optional(),
systemEmailTLSServername: z systemEmailTLSServername: z
.string() .string()
.transform((val) => (val.trim() === "" ? null : val.trim())) .transform((val) => (val.trim() === "" ? undefined : val.trim()))
.optional(),
globalThresholds: z
.object({
cpu: z
.number()
.int()
.min(1, "Please enter a valid value")
.max(100, "Maximum value is 100")
.optional(),
memory: z
.number()
.int()
.min(1, "Please enter a valid value")
.max(100, "Maximum value is 100")
.optional(),
disk: z
.number()
.int()
.min(1, "Please enter a valid value")
.max(100, "Maximum value is 100")
.optional(),
temperature: z
.number()
.int()
.min(1, "Please enter a valid value")
.max(150, "Maximum value is 150")
.optional(),
})
.optional(), .optional(),
globalThresholds: z.object({
cpu: z.number().int().min(1).max(100),
memory: z.number().int().min(1).max(100),
disk: z.number().int().min(1).max(100),
temperature: z.number().int().min(1).max(150),
}),
}); });
export type SettingsFormInput = z.input<typeof settingsSchema>;
export type SettingsFormData = z.infer<typeof settingsSchema>; export type SettingsFormData = z.infer<typeof settingsSchema>;
-4
View File
@@ -14,16 +14,12 @@ export const statusPageSchema = z.object({
"URL can only contain lowercase letters, numbers, and hyphens" "URL can only contain lowercase letters, numbers, and hyphens"
), ),
timezone: z.string().optional(), timezone: z.string().optional(),
type: z
.array(z.enum(["uptime", "infrastructure"]))
.min(1, "At least one type is required"),
color: z.string().min(1, "Color is required"), color: z.string().min(1, "Color is required"),
monitors: z.array(z.string()).min(1, "At least one monitor is required"), monitors: z.array(z.string()).min(1, "At least one monitor is required"),
isPublished: z.boolean(), isPublished: z.boolean(),
showCharts: z.boolean(), showCharts: z.boolean(),
showUptimePercentage: z.boolean(), showUptimePercentage: z.boolean(),
showAdminLoginLink: z.boolean(), showAdminLoginLink: z.boolean(),
showInfrastructure: z.boolean(),
customCSS: z.string().optional(), customCSS: z.string().optional(),
logo: z logo: z
.object({ .object({
+1 -8
View File
@@ -288,13 +288,7 @@ const logoImageValidation = joi
.optional(); // Make entire object optional .optional(); // Make entire object optional
const statusPageValidation = joi.object({ const statusPageValidation = joi.object({
type: joi type: joi.string().valid("uptime").required(),
.alternatives()
.try(
joi.string().valid("uptime", "infrastructure"),
joi.array().items(joi.string().valid("uptime", "infrastructure"))
)
.required(),
isPublished: joi.bool(), isPublished: joi.bool(),
companyName: joi companyName: joi
.string() .string()
@@ -323,7 +317,6 @@ const statusPageValidation = joi.object({
showUptimePercentage: joi.boolean(), showUptimePercentage: joi.boolean(),
showCharts: joi.boolean(), showCharts: joi.boolean(),
showAdminLoginLink: joi.boolean(), showAdminLoginLink: joi.boolean(),
showInfrastructure: joi.boolean(),
}); });
const settingsValidation = joi.object({ const settingsValidation = joi.object({
+1085 -1282
View File
File diff suppressed because it is too large. Load diff
+1085 -1282
View File
File diff suppressed because it is too large. Load diff
+1107 -1272
View File
File diff suppressed because it is too large. Load diff
+17 -149
View File
@@ -44,10 +44,7 @@
"addDemo": "Add demo monitors", "addDemo": "Add demo monitors",
"removeMonitors": "Remove monitors", "removeMonitors": "Remove monitors",
"sendTestEmail": "Send test email", "sendTestEmail": "Send test email",
"exportToJSON": "Export to JSON", "exportToJSON": "Export to JSON"
"importFromJSON": "Import from JSON",
"clearFilters": "Clear filters",
"removeUser": "Remove user"
}, },
"charts": { "charts": {
"labels": { "labels": {
@@ -117,8 +114,7 @@
"type": "Type", "type": "Type",
"url": "Url", "url": "Url",
"interval": "Interval", "interval": "Interval",
"active": "Active", "active": "Active"
"responseTime": "Response time"
} }
} }
}, },
@@ -343,7 +339,6 @@
"register": { "register": {
"title": "Welcome back to Checkmate!", "title": "Welcome back to Checkmate!",
"subtitle": "Sign up to get started", "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" "submit": "Register"
}, },
"forgotPassword": { "forgotPassword": {
@@ -376,8 +371,8 @@
"table": { "table": {
"empty": "No down checks in this time range", "empty": "No down checks in this time range",
"headers": { "headers": {
"statusCode": "Status code", "dateTime": "Date & time",
"location": "Location" "statusCode": "Status code"
} }
} }
}, },
@@ -398,28 +393,16 @@
"activeFor": "Active for", "activeFor": "Active for",
"certificateExpiry": "Certificate expiry", "certificateExpiry": "Certificate expiry",
"lastCheck": "Last check", "lastCheck": "Last check",
"lastResponseTime": "Last response time", "lastResponseTime": "Last response time"
"serviceIsDown": "Service is down"
}, },
"status": { "status": {
"down": "down", "down": "down",
"breached": "threshold exceeded", "breached": "breached",
"initializing": "initializing", "initializing": "initializing",
"maintenance": "maintenance", "maintenance": "maintenance",
"paused": "paused", "paused": "paused",
"total": "total", "total": "total",
"up": "up" "up": "up"
},
"monitorTypes": {
"optionDocker": "Docker",
"optionGame": "Game",
"optionHttp": "HTTP(S)",
"optionPing": "Ping",
"optionPort": "Port",
"optionPagespeed": "PageSpeed",
"optionHardware": "Infrastructure",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
} }
} }
}, },
@@ -497,14 +480,6 @@
"port": { "port": {
"label": "Port to monitor", "label": "Port to monitor",
"placeholder": 80 "placeholder": 80
},
"grpcServiceName": {
"label": "Service name",
"placeholder": "e.g. my.service.v1 (leave empty for overall health)"
},
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
} }
}, },
"title": "General settings", "title": "General settings",
@@ -515,8 +490,6 @@
"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).", "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.",
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.", "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."
} }
}, },
@@ -547,12 +520,15 @@
}, },
"type": { "type": {
"description": "Select the type of check to perform", "description": "Select the type of check to perform",
"optionDocker": "Docker",
"optionDockerDescription": "Use Docker to monitor if a container is running.", "optionDockerDescription": "Use Docker to monitor if a container is running.",
"optionGame": "Game",
"optionGameDescription": "Monitor if a specific game server is online.", "optionGameDescription": "Monitor if a specific game server is online.",
"optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.", "optionHttp": "HTTP(S)",
"optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.",
"optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.", "optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.",
"optionPing": "Ping",
"optionPingDescription": "Use ICMP Ping to monitor if a server is online.", "optionPingDescription": "Use ICMP Ping to monitor if a server is online.",
"optionPort": "Port",
"optionPortDescription": "Monitor if a specific port on a server is open.", "optionPortDescription": "Monitor if a specific port on a server is open.",
"title": "Type" "title": "Type"
}, },
@@ -574,36 +550,6 @@
} }
} }
}, },
"geoChecks": {
"title": "Geo-Distributed Checks",
"description": "Run checks from multiple geographic locations to monitor global availability and performance.",
"option": {
"enabled": {
"label": "Enable geo-distributed checks"
},
"locations": {
"label": "Locations",
"placeholder": "Select locations",
"options": {
"EU": "Europe",
"NA": "North America",
"AS": "Asia",
"SA": "South America",
"AF": "Africa",
"OC": "Oceania"
}
},
"interval": {
"label": "Check interval",
"value": {
"fiveMinutes": "5 minutes",
"tenMinutes": "10 minutes",
"fifteenMinutes": "15 minutes",
"thirtyMinutes": "30 minutes"
}
}
}
},
"url": { "url": {
"title": "Monitor IP/URL on Status Page", "title": "Monitor IP/URL on Status Page",
"description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.",
@@ -633,12 +579,6 @@
"title": "Roles", "title": "Roles",
"description": "Assign roles to the user. Multiple roles can be selected." "description": "Assign roles to the user. Multiple roles can be selected."
} }
},
"dialog": {
"removeUser": {
"title": "Remove user",
"content": "Are you sure you want to remove {{name}} from your team? This action cannot be undone."
}
} }
}, },
"incidents": { "incidents": {
@@ -679,6 +619,7 @@
}, },
"filters": { "filters": {
"allMonitors": "All monitors", "allMonitors": "All monitors",
"clearFilters": "Clear filters",
"monitor": "Monitor", "monitor": "Monitor",
"resolutionType": "Resolution type", "resolutionType": "Resolution type",
"resolutionTypes": { "resolutionTypes": {
@@ -953,34 +894,6 @@
"description": "Select the type of notification channel to create.", "description": "Select the type of notification channel to create.",
"optionType": "Type", "optionType": "Type",
"title": "Channel type" "title": "Channel type"
},
"telegram": {
"title": "Telegram configuration",
"description": "Configure your Telegram bot to send notifications.",
"optionBotToken": "Bot token",
"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": { "table": {
@@ -1075,7 +988,7 @@
}, },
"stats": { "stats": {
"title": "Monitor history", "title": "Monitor history",
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.", "description": "Define how long you want to retain historical data. You can also clear all existing data.",
"option": { "option": {
"clear": { "clear": {
"label": "Clear all stats. This is irreversible." "label": "Clear all stats. This is irreversible."
@@ -1086,16 +999,6 @@
"description": "This cannot be undone" "description": "This cannot be undone"
} }
}, },
"retention": {
"title": "Check retention",
"description": "Set how long check data is retained before being automatically cleaned up.",
"option": {
"days": {
"label": "Retention period (days)",
"unlimited": "Unlimited"
}
}
},
"thresholds": { "thresholds": {
"title": "Global Thresholds", "title": "Global Thresholds",
"description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.", "description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.",
@@ -1183,18 +1086,12 @@
} }
}, },
"exportMonitors": { "exportMonitors": {
"title": "Export monitors" "title": "Export monitors",
}, "description": "Export your monitors data as a JSON file for backup or transfer."
"importExportMonitors": {
"title": "Import / Export monitors",
"description": "Import or export your monitors data as a JSON file for backup or transfer."
}, },
"about": { "about": {
"title": "About", "title": "About",
"developedBy": "Developed by Bluewave Labs" "developedBy": "Developed by Bluewave Labs"
},
"validation": {
"errorMessage": "Please fix the following validation errors:"
} }
} }
}, },
@@ -1212,38 +1109,12 @@
}, },
"monitorsList": { "monitorsList": {
"chartTypeHeatmap": "Heatmap", "chartTypeHeatmap": "Heatmap",
"chartTypeHistogram": "Histogram", "chartTypeHistogram": "Histogram"
"noData": "No data available",
"infrastructure": {
"title": "Infrastructure",
"cpu": "CPU",
"memory": "RAM",
"memoryText": "Memory",
"disk": "Disk",
"usage": "Usage",
"used": "Used",
"total": "Total"
},
"uptime": {
"title": "Uptime",
"responseTime": "Response time"
}
}, },
"statusBar": { "statusBar": {
"allUp": "All systems operational",
"allBreached": "All systems have exceeded thresholds",
"allMaintenance": "All systems are under maintenance",
"allDown": "All systems are down", "allDown": "All systems are down",
"allPaused": "All systems are paused", "allUp": "All systems operational",
"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", "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" "unknown": "Unable to determine system status"
}, },
"table": { "table": {
@@ -1330,9 +1201,6 @@
}, },
"showAdminLoginLink": { "showAdminLoginLink": {
"label": "Show admin login link" "label": "Show admin login link"
},
"showInfrastructure": {
"label": "Show infrastructure metrics"
} }
} }
} }
+1085 -1282
View File
File diff suppressed because it is too large. Load diff
+1085 -1282
View File
File diff suppressed because it is too large. Load diff
+1107 -1272
View File
File diff suppressed because it is too large. Load diff
+1086 -1272
View File
File diff suppressed because it is too large. Load diff
+1107 -1272
View File
File diff suppressed because it is too large. Load diff
+1107 -1272
View File
File diff suppressed because it is too large. Load diff
+1107 -1272
View File
File diff suppressed because it is too large. Load diff
+1085 -1282
View File
File diff suppressed because it is too large. Load diff
+792 -1259
View File
File diff suppressed because it is too large. Load diff
+792 -1259
View File
File diff suppressed because it is too large. Load diff
+1099 -1272
View File
File diff suppressed because it is too large. Load diff
+1089 -1300
View File
File diff suppressed because it is too large. Load diff
+1 -3
View File
@@ -3,13 +3,11 @@ import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr"; import svgr from "vite-plugin-svgr";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { readFileSync } from "fs";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
export default defineConfig(({}) => { export default defineConfig(({}) => {
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "package.json"), "utf-8")); let version = "3.4.0";
const version = pkg.version;
return { return {
base: "/", base: "/",
Loaded 100 of 336 files, more files were not shown because too many files have changed in this diff. Show more