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



-[](https://www.bestpractices.dev/projects/9901)
[](https://deepwiki.com/bluewave-labs/checkmate)
An open source uptime and infrastructure monitoring application
-
+[](https://www.pikapods.com/pods?run=checkmate)
+
+
+
+
+
This repository contains both the frontend and the backend of Checkmate, an open-source, self-hosted monitoring tool for tracking server hardware, uptime, response times, and incidents in real-time with beautiful visualizations. Checkmate regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.
@@ -22,14 +26,11 @@ Checkmate also has an agent, called [Capture](https://github.com/bluewave-labs/c
Checkmate has been stress-tested with 1000+ active monitors without any particular issues or performance bottlenecks.
-**If you would like to sponsor a feature, [see this link](https://checkmate.so/sponsored-features).**
-
## 📚 Table of contents
- [📦 Demo](#demo)
- [🔗 User's guide](#users-guide)
- [🛠️ Installation](#installation)
-- [🏁 Translations](#translations)
- [🚀 Performance](#performance)
- [💚 Questions & Ideas](#questions--ideas)
- [🧩 Features](#features)
@@ -37,12 +38,13 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
- [🏗️ Tech stack](#tech-stack)
- [🔗 A few links](#a-few-links)
- [🤝 Contributing](#contributing)
-- [💰 Our sponsors](#our-sponsors)
## Demo
-You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is demouser@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
+You can see the latest build of [Checkmate](https://demo.checkmate.so/) in action.
+
+The username is demouser@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
## User's guide
@@ -56,7 +58,7 @@ Usage instructions can be found [here](https://checkmate.so/docs).
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
-Alternatively, you can also use [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
+Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
### Using a Custom CA
@@ -64,11 +66,6 @@ If you need to monitor internal HTTPS endpoints with certificates from private C
For more documentation, see the [docs directory](./docs/).
-
-## Translations
-
-If you would like to use Checkmate in your language, please [go to this page](https://poeditor.com/join/project/lRUoGZFCsJ) and register for the language you would like to translate Checkmate to.
-
## Performance
Thanks to extensive optimizations, Checkmate operates with an exceptionally small memory footprint, requiring minimal memory and CPU resources. Here’s the memory usage of a Node.js instance running on a server that monitors 323 servers every minute:
@@ -91,30 +88,26 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
## Features
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
-- Website monitoring
+- Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server
- Page speed monitoring
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
- Selective disk monitoring with mountpoint selection
-- Docker monitoring
-- Ping monitoring
-- SSL monitoring
-- Port monitoring
-- Game server monitoring (3.0)
- Incidents at a glance
-- Status pages
-- E-mail, Webhooks, Discord and Slack notifications
+- Status pages with 4 beautiful themes
+- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- Scheduled maintenance
- JSON query monitoring
-- Multi-language support for English, German, Japanese, Portuguese (Brazil), Russian, Turkish, Ukrainian, Vietnamese, Chinese (Traditional, Taiwan)
+- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
-**Short term roadmap:**
-- Plugins that will help Checkmate get any information from a remote service (e.g database, etc)
-- Better notifications
-- Network monitoring
-- ..and a few more features
+## Monitor Lifecycle
-If you would like to sponsor an additional feature, [see this page](https://checkmate.so/sponsored-features).
+1. A monitor executes a check (HTTP / ping / port / hardware via Capture agent)
+2. The result is stored (success/failure + response time)
+3. Recent check results are evaluated against the monitor's configured status change threshold
+4. If the monitor's status change threshold is met and the current status is not equal to the previous status, the monitor's state changes (e.g. `initializing`, `up`, `down`, `breached`)
+5. Upon a state change: an incident is either created or resolved, depending on the monitor's current status
+6. Notifications are triggered based on configuration
## Screenshots
@@ -173,8 +166,3 @@ Here's how you can contribute:
[](https://star-history.com/#bluewave-labs/Checkmate&Date)
-## Our sponsors
-
-Thanks to [Gitbook](https://gitbook.io/) for giving us a free tier for their documentation platform, and [Poeditor](https://poeditor.com/) providing us a free account to use their i18n services. If you would like to sponsor Checkmate, please send an email to hello@bluewavelabs.ca
-
-If you would like to sponsor a feature, [see this page](https://checkmate.so/sponsored-features).
diff --git a/client/.eslintrc.cjs b/client/.eslintrc.cjs
index 86d523a6f..f8bd3d31f 100644
--- a/client/.eslintrc.cjs
+++ b/client/.eslintrc.cjs
@@ -1,3 +1,104 @@
+// MUI sx keys that have a native (system) prop equivalent.
+// Flagged when found inside an sx={{ ... }} object literal — see frontend-conventions.md rule #1.
+const SX_NATIVE_EQUIVALENT_KEYS = [
+ "color",
+ "bgcolor",
+ "backgroundColor",
+ "borderRadius",
+ "border",
+ "borderTop",
+ "borderBottom",
+ "borderLeft",
+ "borderRight",
+ "width",
+ "height",
+ "minHeight",
+ "maxWidth",
+ "minWidth",
+ "maxHeight",
+ "p",
+ "pt",
+ "pb",
+ "px",
+ "py",
+ "pl",
+ "pr",
+ "m",
+ "mt",
+ "mb",
+ "mx",
+ "my",
+ "ml",
+ "mr",
+ "display",
+ "textAlign",
+ "fontWeight",
+ "fontSize",
+ "lineHeight",
+ "gap",
+];
+
+// MUI components that accept system props as top-level attributes.
+// sx={{ color, p, mt, ... }} on these can be hoisted into native props.
+// Dialog*, Drawer*, Menu*, Tab*, Toolbar etc. only accept sx — don't flag those.
+const SX_HOISTABLE_COMPONENTS = [
+ "Box",
+ "Stack",
+ "Typography",
+ "Grid",
+ "Container",
+ "Paper",
+ "Card",
+ "CardContent",
+ "CardActions",
+ "CardHeader",
+ "Divider",
+ "Link",
+ "Chip",
+];
+
+const sxKeySelector = SX_HOISTABLE_COMPONENTS.flatMap((component) =>
+ SX_NATIVE_EQUIVALENT_KEYS.map(
+ (k) =>
+ `JSXOpeningElement[name.name='${component}'] > JSXAttribute[name.name='sx'] > JSXExpressionContainer > ObjectExpression > Property[key.name='${k}']`
+ )
+).join(", ");
+
+const FRONTEND_CONVENTION_RULES = {
+ // Rule #2 — color/bgcolor/borderColor as a string shorthand like "text.secondary".
+ // Catches: — should be color={theme.palette.text.secondary}.
+ "no-restricted-syntax": [
+ "warn",
+ {
+ selector:
+ "JSXAttribute[name.name=/^(color|bgcolor|borderColor)$/] > Literal[value=/\\./]",
+ message:
+ 'Use the full theme path instead of a string shorthand. e.g. color={theme.palette.text.secondary} not color="text.secondary". See docs/frontend-conventions.md rule #2.',
+ },
+ {
+ // Rule #1 — sx={{ key: ... }} where key has a native prop equivalent.
+ selector: sxKeySelector,
+ message:
+ "Hoist this key out of `sx` onto the component as a native MUI prop (e.g. color={...}, mt={...}, width={...}). See docs/frontend-conventions.md rule #1.",
+ },
+ {
+ // Rule #4 — importing the singleton theme from the Theme module.
+ // Catches: import { theme } from "@/Utils/Theme/Theme" — should be useTheme() inside the component.
+ selector:
+ "ImportDeclaration[source.value='@/Utils/Theme/Theme'] > ImportSpecifier[imported.name='theme']",
+ message:
+ "Don't import { theme } from @/Utils/Theme/Theme — use the useTheme() hook inside the component. See docs/frontend-conventions.md rule #4.",
+ },
+ {
+ // Rule #3 (subset) — Typography fontSize={13} is the body default; drop it.
+ selector:
+ "JSXOpeningElement[name.name='Typography'] JSXAttribute[name.name='fontSize'] > JSXExpressionContainer > Literal[value=13]",
+ message:
+ "fontSize={13} is the Typography body default — drop the prop. For other sizes use typographyLevels tokens. See docs/frontend-conventions.md rule #3.",
+ },
+ ],
+};
+
module.exports = {
root: true,
env: { browser: true, es2020: true },
@@ -15,9 +116,43 @@ module.exports = {
"react/jsx-no-target-blank": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"react/no-unescaped-entities": "off",
+ ...FRONTEND_CONVENTION_RULES,
},
globals: {
__APP_VERSION__: "readonly",
process: "readonly",
},
+ overrides: [
+ {
+ files: ["**/*.ts", "**/*.tsx"],
+ parser: "@typescript-eslint/parser",
+ parserOptions: { ecmaVersion: "latest", sourceType: "module" },
+ extends: [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:react/recommended",
+ "plugin:react/jsx-runtime",
+ "plugin:react-hooks/recommended",
+ ],
+ plugins: ["@typescript-eslint", "react-refresh"],
+ rules: {
+ "react/jsx-no-target-blank": "off",
+ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
+ "react/no-unescaped-entities": "off",
+ "no-unused-vars": "off",
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
+ ],
+ // TS handles these; eslint duplicates create noise on existing files.
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/ban-ts-comment": "off",
+ "@typescript-eslint/ban-types": "off",
+ "@typescript-eslint/no-empty-function": "off",
+ "@typescript-eslint/no-empty-interface": "off",
+ "@typescript-eslint/no-non-null-assertion": "off",
+ ...FRONTEND_CONVENTION_RULES,
+ },
+ },
+ ],
};
diff --git a/client/NOTICE b/client/NOTICE
new file mode 100644
index 000000000..a72e9c36f
--- /dev/null
+++ b/client/NOTICE
@@ -0,0 +1,18 @@
+Checkmate
+Copyright (c) BlueWave Labs
+
+This product includes assets developed by third parties.
+
+----------------------------------------------------------------------
+
+King chess-piece silhouette used in the Checkmate logo
+(client/src/assets/icons/checkmate-icon.svg and
+ client/public/checkmate_favicon.svg)
+is adapted from "Chess klt45.svg" by Cburnett, available at:
+ https://commons.wikimedia.org/wiki/File:Chess_klt45.svg
+Licensed under Creative Commons Attribution-ShareAlike 3.0 Unported
+(CC BY-SA 3.0): https://creativecommons.org/licenses/by-sa/3.0/
+
+The original artwork has been recolored from the white-with-black-stroke
+chess piece to the Checkmate brand green (#13715B) for use as a logo.
+Modifications: stroke color change only; geometry unchanged.
diff --git a/client/package-lock.json b/client/package-lock.json
index 7462d95d7..e2f33e645 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "client",
- "version": "0.0.0",
+ "version": "3.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
- "version": "0.0.0",
+ "version": "3.7.1",
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
@@ -18,9 +18,8 @@
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
"@types/maplibre-gl": "^1.13.2",
- "axios": "^1.7.4",
+ "axios": "^1.15.2",
"dayjs": "1.11.13",
- "flag-icons": "7.3.2",
"html2canvas": "^1.4.1",
"human-interval": "2.0.1",
"i18next": "25.4.2",
@@ -50,6 +49,8 @@
"@types/node": "24.5.2",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
+ "@typescript-eslint/eslint-plugin": "^7.18.0",
+ "@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
@@ -58,7 +59,7 @@
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
- "vite": "6.4.1"
+ "vite": "^6.4.2"
}
},
"node_modules/@babel/code-frame": {
@@ -2351,6 +2352,238 @@
"version": "0.0.6",
"license": "MIT"
},
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
+ "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "7.18.0",
+ "@typescript-eslint/type-utils": "7.18.0",
+ "@typescript-eslint/utils": "7.18.0",
+ "@typescript-eslint/visitor-keys": "7.18.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.3.1",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^7.0.0",
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz",
+ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "7.18.0",
+ "@typescript-eslint/types": "7.18.0",
+ "@typescript-eslint/typescript-estree": "7.18.0",
+ "@typescript-eslint/visitor-keys": "7.18.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz",
+ "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "7.18.0",
+ "@typescript-eslint/visitor-keys": "7.18.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz",
+ "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/typescript-estree": "7.18.0",
+ "@typescript-eslint/utils": "7.18.0",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz",
+ "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz",
+ "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/types": "7.18.0",
+ "@typescript-eslint/visitor-keys": "7.18.0",
+ "debug": "^4.3.4",
+ "globby": "^11.1.0",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz",
+ "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "@typescript-eslint/scope-manager": "7.18.0",
+ "@typescript-eslint/types": "7.18.0",
+ "@typescript-eslint/typescript-estree": "7.18.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz",
+ "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "7.18.0",
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"dev": true,
@@ -2558,6 +2791,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/array-union": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/array.prototype.findlast": {
"version": "1.2.5",
"dev": true,
@@ -2682,14 +2925,14 @@
}
},
"node_modules/axios": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
- "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
+ "version": "1.15.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
+ "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
- "proxy-from-env": "^1.1.0"
+ "proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-plugin-macros": {
@@ -2996,9 +3239,9 @@
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
- "version": "1.10.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
- "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+ "version": "1.10.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
+ "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"license": "ISC",
"engines": {
"node": ">= 6"
@@ -3273,6 +3516,19 @@
"node": ">=6"
}
},
+ "node_modules/dir-glob": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+ "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/doctrine": {
"version": "3.0.0",
"dev": true,
@@ -3815,6 +4071,36 @@
"node": ">=6.0.0"
}
},
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"dev": true,
@@ -3891,10 +4177,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/flag-icons": {
- "version": "7.3.2",
- "license": "MIT"
- },
"node_modules/flat-cache": {
"version": "3.2.0",
"dev": true,
@@ -3909,12 +4191,16 @@
}
},
"node_modules/flatted": {
- "version": "3.3.3",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
- "version": "1.15.11",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -4175,6 +4461,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/globby": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+ "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-union": "^2.1.0",
+ "dir-glob": "^3.0.1",
+ "fast-glob": "^3.2.9",
+ "ignore": "^5.2.0",
+ "merge2": "^1.4.1",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"license": "MIT",
@@ -5010,7 +5317,9 @@
}
},
"node_modules/lodash": {
- "version": "4.17.23",
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.merge": {
@@ -5142,6 +5451,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -5157,9 +5476,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5568,7 +5887,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.3",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -5599,7 +5920,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.6",
+ "version": "8.5.12",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
+ "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"funding": [
{
"type": "opencollective",
@@ -5691,14 +6014,19 @@
"license": "MIT"
},
"node_modules/protocol-buffers-schema": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
- "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
+ "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
- "version": "1.1.0",
- "license": "MIT"
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -6398,6 +6726,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/slash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
@@ -6798,6 +7136,19 @@
"node": ">=8.0"
}
},
+ "node_modules/ts-api-utils": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
+ "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.2.0"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"license": "0BSD"
@@ -7029,9 +7380,9 @@
}
},
"node_modules/vite": {
- "version": "6.4.1",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
- "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
+ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
@@ -7312,9 +7663,9 @@
"license": "ISC"
},
"node_modules/yaml": {
- "version": "2.8.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
- "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"devOptional": true,
"license": "ISC",
"bin": {
diff --git a/client/package.json b/client/package.json
index 2e6b5cfcb..8f7e70820 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,13 +1,14 @@
{
"name": "client",
"private": true,
- "version": "0.0.0",
+ "version": "3.7.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build-dev": "tsc -b && vite build --mode development",
- "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
+ "lint": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives",
+ "lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"format": "prettier --write .",
"format-check": "prettier --check ."
@@ -26,9 +27,8 @@
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
"@types/maplibre-gl": "^1.13.2",
- "axios": "^1.7.4",
+ "axios": "^1.15.2",
"dayjs": "1.11.13",
- "flag-icons": "7.3.2",
"html2canvas": "^1.4.1",
"human-interval": "2.0.1",
"i18next": "25.4.2",
@@ -58,6 +58,8 @@
"@types/node": "24.5.2",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
+ "@typescript-eslint/eslint-plugin": "^7.18.0",
+ "@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
@@ -66,6 +68,6 @@
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
- "vite": "6.4.1"
+ "vite": "^6.4.2"
}
}
diff --git a/client/public/checkmate_favicon.svg b/client/public/checkmate_favicon.svg
index e0e6bf6f4..e1701573a 100644
--- a/client/public/checkmate_favicon.svg
+++ b/client/public/checkmate_favicon.svg
@@ -1,49 +1,11 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ Checkmate
+ King silhouette adapted from Cburnett's chess set (Wikimedia Commons, CC BY-SA 3.0).
+
+
+
+
+
+
diff --git a/client/scripts/migrate-locales.ts b/client/scripts/migrate-locales.ts
new file mode 100644
index 000000000..721321882
--- /dev/null
+++ b/client/scripts/migrate-locales.ts
@@ -0,0 +1,258 @@
+/**
+ * Locale Migration Script
+ *
+ * Restructures all non-English locale files to match en.json's nested key structure.
+ * Recovers translations via safe key-matching strategies, uses English placeholders
+ * for unmatched keys.
+ *
+ * Usage: npx tsx client/scripts/migrate-locales.ts
+ *
+ * SAFETY: en.json is NEVER modified. Originals are backed up before overwriting.
+ */
+
+import fs from "fs";
+import path from "path";
+
+const LOCALES_DIR = path.resolve(import.meta.dirname, "../src/locales");
+const BACKUP_DIR = path.join(LOCALES_DIR, "backup");
+const REPORT_PATH = path.join(LOCALES_DIR, "migration-report.json");
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+/** Flatten a nested object into { "a.b.c": value } pairs */
+function flattenKeys(obj: Record, prefix = ""): Record {
+ const result: Record = {};
+ for (const [key, value] of Object.entries(obj)) {
+ const fullPath = prefix ? `${prefix}.${key}` : key;
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
+ Object.assign(result, flattenKeys(value as Record, fullPath));
+ } else {
+ result[fullPath] = String(value);
+ }
+ }
+ return result;
+}
+
+/** Set a value at a dot-separated path in a nested object */
+function setNestedValue(
+ obj: Record,
+ keyPath: string,
+ value: string
+): void {
+ const parts = keyPath.split(".");
+ let current = obj;
+ for (let i = 0; i < parts.length - 1; i++) {
+ const part = parts[i];
+ if (!(part in current) || typeof current[part] !== "object") {
+ current[part] = {};
+ }
+ current = current[part] as Record;
+ }
+ current[parts[parts.length - 1]] = value;
+}
+
+// ── Build mapping indexes from en.json ───────────────────────────────────────
+
+function buildIndexes(enFlat: Record) {
+ const enKeys = Object.keys(enFlat);
+
+ // last segment -> en key paths
+ const lastSegIndex: Record = {};
+ // last 2 segments -> en key paths
+ const suffixIndex: Record = {};
+
+ for (const keyPath of enKeys) {
+ const parts = keyPath.split(".");
+ const lastSeg = parts[parts.length - 1].toLowerCase();
+ if (!lastSegIndex[lastSeg]) lastSegIndex[lastSeg] = [];
+ lastSegIndex[lastSeg].push(keyPath);
+
+ if (parts.length >= 2) {
+ const suffix = parts.slice(-2).join(".").toLowerCase();
+ if (!suffixIndex[suffix]) suffixIndex[suffix] = [];
+ suffixIndex[suffix].push(keyPath);
+ }
+ }
+
+ return { lastSegIndex, suffixIndex };
+}
+
+// ── Mapping logic ────────────────────────────────────────────────────────────
+
+type MatchStrategy = "exact" | "suffix-2" | "unique-name" | "english-placeholder";
+
+interface KeyMatch {
+ enKey: string;
+ value: string;
+ strategy: MatchStrategy;
+}
+
+function buildMigratedLocale(
+ enFlat: Record,
+ langFlat: Record,
+ indexes: ReturnType
+): { matches: KeyMatch[]; result: Record } {
+ const { lastSegIndex, suffixIndex } = indexes;
+ const matches: KeyMatch[] = [];
+ const result: Record = {};
+
+ // Build reverse lookup: for each orphan key in lang, index by last segment and suffix
+ const langByLastSeg: Record = {};
+ const langBySuffix: Record = {};
+
+ for (const [langKey, langValue] of Object.entries(langFlat)) {
+ const parts = langKey.split(".");
+ const lastSeg = parts[parts.length - 1].toLowerCase();
+ if (!langByLastSeg[lastSeg]) langByLastSeg[lastSeg] = [];
+ langByLastSeg[lastSeg].push({ key: langKey, value: langValue });
+
+ if (parts.length >= 2) {
+ const suffix = parts.slice(-2).join(".").toLowerCase();
+ if (!langBySuffix[suffix]) langBySuffix[suffix] = [];
+ langBySuffix[suffix].push({ key: langKey, value: langValue });
+ }
+ }
+
+ for (const enKey of Object.keys(enFlat)) {
+ const enValue = enFlat[enKey];
+ const parts = enKey.split(".");
+ const lastSeg = parts[parts.length - 1].toLowerCase();
+ const suffix = parts.length >= 2 ? parts.slice(-2).join(".").toLowerCase() : null;
+
+ // Strategy 1: Exact path match
+ if (langFlat[enKey] !== undefined) {
+ const value = langFlat[enKey];
+ setNestedValue(result, enKey, value);
+ matches.push({ enKey, value, strategy: "exact" });
+ continue;
+ }
+
+ // Strategy 2: 2-segment suffix match (lang key suffix matches en key suffix, both unique)
+ if (suffix) {
+ const enCandidates = suffixIndex[suffix];
+ const langCandidates = langBySuffix[suffix];
+ if (
+ enCandidates &&
+ enCandidates.length === 1 &&
+ langCandidates &&
+ langCandidates.length === 1
+ ) {
+ const value = langCandidates[0].value;
+ setNestedValue(result, enKey, value);
+ matches.push({ enKey, value, strategy: "suffix-2" });
+ continue;
+ }
+ }
+
+ // Strategy 3: Unique last-segment match
+ const enCandidates = lastSegIndex[lastSeg];
+ const langCandidates = langByLastSeg[lastSeg];
+ if (
+ enCandidates &&
+ enCandidates.length === 1 &&
+ langCandidates &&
+ langCandidates.length === 1
+ ) {
+ const value = langCandidates[0].value;
+ setNestedValue(result, enKey, value);
+ matches.push({ enKey, value, strategy: "unique-name" });
+ continue;
+ }
+
+ // No match — use English placeholder
+ setNestedValue(result, enKey, enValue);
+ matches.push({ enKey, value: enValue, strategy: "english-placeholder" });
+ }
+
+ return { matches, result };
+}
+
+// ── Main ─────────────────────────────────────────────────────────────────────
+
+function main() {
+ // Load en.json
+ const enPath = path.join(LOCALES_DIR, "en.json");
+ const en = JSON.parse(fs.readFileSync(enPath, "utf8"));
+ const enFlat = flattenKeys(en);
+ const enKeyCount = Object.keys(enFlat).length;
+ const indexes = buildIndexes(enFlat);
+
+ console.log(`English: ${enKeyCount} keys (source of truth — NOT modified)\n`);
+
+ // Find all non-English locale files
+ const localeFiles = fs
+ .readdirSync(LOCALES_DIR)
+ .filter(
+ (f: string) =>
+ f.endsWith(".json") && f !== "en.json" && f !== "migration-report.json"
+ );
+
+ if (localeFiles.length === 0) {
+ console.log("No locale files to migrate.");
+ return;
+ }
+
+ // Create backup directory
+ if (!fs.existsSync(BACKUP_DIR)) {
+ fs.mkdirSync(BACKUP_DIR, { recursive: true });
+ }
+
+ const report: Record<
+ string,
+ {
+ total: number;
+ exact: number;
+ suffix: number;
+ uniqueName: number;
+ placeholder: number;
+ }
+ > = {};
+
+ for (const file of localeFiles) {
+ const lang = file.replace(".json", "");
+ const filePath = path.join(LOCALES_DIR, file);
+
+ // Backup original
+ const backupPath = path.join(BACKUP_DIR, file);
+ fs.copyFileSync(filePath, backupPath);
+
+ // Load and flatten
+ const langData = JSON.parse(fs.readFileSync(filePath, "utf8"));
+ const langFlat = flattenKeys(langData);
+
+ // Migrate
+ const { matches, result } = buildMigratedLocale(enFlat, langFlat, indexes);
+
+ // Write migrated file
+ fs.writeFileSync(filePath, JSON.stringify(result, null, "\t") + "\n", "utf8");
+
+ // Compute stats
+ const stats = {
+ total: enKeyCount,
+ exact: matches.filter((m) => m.strategy === "exact").length,
+ suffix: matches.filter((m) => m.strategy === "suffix-2").length,
+ uniqueName: matches.filter((m) => m.strategy === "unique-name").length,
+ placeholder: matches.filter((m) => m.strategy === "english-placeholder").length,
+ };
+ report[lang] = stats;
+
+ const translated = stats.exact + stats.suffix + stats.uniqueName;
+ const pct = ((translated / enKeyCount) * 100).toFixed(1);
+ console.log(
+ `${lang.padEnd(8)} ${String(translated).padStart(4)} translated (${pct}%) | ` +
+ `exact: ${stats.exact} suffix: ${stats.suffix} name: ${stats.uniqueName} ` +
+ `placeholder: ${stats.placeholder}`
+ );
+ }
+
+ // Write report
+ fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, "\t") + "\n", "utf8");
+
+ const totalLangs = localeFiles.length;
+ console.log(
+ `\nMigrated ${totalLangs} locale files. Originals backed up to locales/backup/`
+ );
+ console.log(`Report written to ${REPORT_PATH}`);
+}
+
+main();
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 15c94dbb5..5a1a36455 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -33,6 +33,14 @@ function App() {
body: {
backgroundColor: theme.palette.background.default,
},
+ ".Toastify__progress-bar": {
+ height: 2,
+ bottom: 0,
+ top: "auto",
+ },
+ ".Toastify__progress-bar--bg": {
+ display: "none",
+ },
}}
/>
@@ -41,12 +49,23 @@ function App() {
diff --git a/client/src/Components/common/charts/HeatmapResponseTime.tsx b/client/src/Components/common/charts/HeatmapResponseTime.tsx
index f2aed0b7a..9beb92f37 100644
--- a/client/src/Components/common/charts/HeatmapResponseTime.tsx
+++ b/client/src/Components/common/charts/HeatmapResponseTime.tsx
@@ -5,7 +5,7 @@ import { getResponseColor } from "@/Utils/DataUtils";
import { HeatmapResponseTimeTooltip } from "@/Components/common/charts/HeatmapResponseTimeTooltip";
import type { SxProps } from "@mui/material/styles";
import type { ResponsiveStyleValue } from "@mui/system";
-
+import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface PlaceholderCheck {
status: "placeholder";
responseTime: 0;
@@ -32,11 +32,11 @@ export const HeatmapResponseTime = ({
if (!checks || checks.length === 0) return null;
- const latestChecks = checks.slice(-25);
+ const latestChecks = checks.slice(-MAX_RECENT_CHECKS);
let data: Array;
- if (latestChecks.length !== 25) {
- const placeholders = Array(25 - latestChecks.length).fill({
+ if (latestChecks.length !== MAX_RECENT_CHECKS) {
+ const placeholders = Array(MAX_RECENT_CHECKS - latestChecks.length).fill({
status: "placeholder" as const,
});
data = [...latestChecks, ...placeholders];
@@ -44,13 +44,13 @@ export const HeatmapResponseTime = ({
data = latestChecks;
}
return (
-
+
@@ -82,50 +82,50 @@ export const HeatmapResponseTime = ({
check={check}
>
diff --git a/client/src/Components/common/charts/HistogramResponseTime.tsx b/client/src/Components/common/charts/HistogramResponseTime.tsx
index 1ef400daf..ba2bc12ff 100644
--- a/client/src/Components/common/charts/HistogramResponseTime.tsx
+++ b/client/src/Components/common/charts/HistogramResponseTime.tsx
@@ -7,6 +7,7 @@ import type { CheckSnapshot } from "@/Types/Check";
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
+import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
interface HistogramResponseTimeProps {
checks: CheckSnapshot[];
@@ -57,8 +58,11 @@ export const HistogramResponseTime = ({
if (!Array.isArray(checks) || checks.length === 0) return null;
const data =
- checks.length !== 25
- ? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })]
+ checks.length !== MAX_RECENT_CHECKS
+ ? [
+ ...checks,
+ ...Array(MAX_RECENT_CHECKS - checks.length).fill({ status: "placeholder" }),
+ ]
: checks;
const chartHeight = typeof height === "number" ? `${height}px` : height;
@@ -86,17 +90,17 @@ export const HistogramResponseTime = ({
{data.map((check, index) => {
@@ -108,23 +112,23 @@ export const HistogramResponseTime = ({
: theme.palette.error.light;
const bar = (
@@ -133,8 +137,8 @@ export const HistogramResponseTime = ({
return isPlaceholder ? (
{bar}
diff --git a/client/src/Components/design-elements/BaseChart.tsx b/client/src/Components/design-elements/BaseChart.tsx
index de87395eb..c6d8298a7 100644
--- a/client/src/Components/design-elements/BaseChart.tsx
+++ b/client/src/Components/design-elements/BaseChart.tsx
@@ -8,7 +8,7 @@ import type { ResponsiveStyleValue } from "@mui/system";
import { useTheme } from "@mui/material/styles";
type BaseChartProps = React.PropsWithChildren<{
- icon: React.ReactNode;
+ icon?: React.ReactNode;
title: string;
width?: number | string;
maxWidth?: number | string;
@@ -17,7 +17,6 @@ type BaseChartProps = React.PropsWithChildren<{
export const BaseChart = ({
children,
- icon,
title,
width = "100%",
maxWidth = "100%",
@@ -38,34 +37,12 @@ export const BaseChart = ({
gap={theme.spacing(LAYOUT.MD)}
flex={1}
>
-
- {icon && (
-
- {icon}
-
- )}
- {title}
-
+ {title}
+
{children}
diff --git a/client/src/Components/design-elements/BasePage.tsx b/client/src/Components/design-elements/BasePage.tsx
index 691e770dc..c955fb6b7 100644
--- a/client/src/Components/design-elements/BasePage.tsx
+++ b/client/src/Components/design-elements/BasePage.tsx
@@ -1,18 +1,19 @@
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
-import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link";
-import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
+import { LAYOUT } from "@/Utils/Theme/constants";
import {
ErrorFallback,
EmptyFallback,
EmptyMonitorFallback,
- BaseFallback,
} from "@/Components/design-elements/Fallback";
+import { EmptyState } from "@/Components/design-elements/EmptyState";
+import { NoticeBanner } from "@/Components/design-elements/NoticeBanner";
import { Breadcrumb } from "@/Components/design-elements/Breadcrumb";
+import { PageHeader } from "@/Components/design-elements/PageHeader";
import CircularProgress from "@mui/material/CircularProgress";
-import { HeaderAuthControls } from "@/Pages/Auth/components/HeaderAuthControls";
+import { LanguageSelector, SwitchTheme } from "@/Components/inputs";
import type { StackProps } from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
@@ -21,16 +22,15 @@ import { Link as RouterLink } from "react-router-dom";
import { Typography } from "@mui/material";
export const PageSpeedKeyPriorityFallback = () => {
+ const theme = useTheme();
+ const { t } = useTranslation();
return (
-
-
-
+
{
),
}}
/>
-
-
-
+
+ }
+ />
);
};
@@ -55,6 +58,9 @@ interface BasePageProps extends StackProps {
error?: boolean;
children: React.ReactNode;
breadcrumbOverride?: string[];
+ headerKey?: string;
+ backTo?: string;
+ backLabel?: string;
}
export const BasePage = ({
@@ -62,9 +68,13 @@ export const BasePage = ({
error,
children,
breadcrumbOverride,
+ headerKey,
+ backTo,
+ backLabel,
...props
}: BasePageProps) => {
const theme = useTheme();
+ const { t } = useTranslation();
if (loading) {
return (
@@ -95,7 +105,16 @@ export const BasePage = ({
spacing={theme.spacing(LAYOUT.LG)}
{...props}
>
-
+ {headerKey ? (
+
+ ) : (
+
+ )}
{children}
);
@@ -105,11 +124,12 @@ interface BasePageWithStatesProps extends StackProps {
loading: boolean;
error: any;
totalCount: number;
- bullets: string[] | unknown;
+ description?: string;
page: string;
actionButtonText: string;
actionLink: string;
children: React.ReactNode;
+ headerKey?: string;
}
export const BasePageWithStates = ({
@@ -117,10 +137,11 @@ export const BasePageWithStates = ({
error,
totalCount,
page,
- bullets,
+ description,
actionButtonText,
actionLink,
children,
+ headerKey,
...props
}: BasePageWithStatesProps) => {
const showLoading = loading && totalCount === 0;
@@ -128,7 +149,7 @@ export const BasePageWithStates = ({
if (!loading && totalCount === 0) {
return (
{children}
@@ -155,6 +177,7 @@ interface MonitorBasePageWithStatesProps extends StackProps {
actionLink?: string;
children: React.ReactNode;
priorityFallback?: React.ReactNode;
+ headerKey?: string;
}
export const MonitorBasePageWithStates = ({
@@ -165,6 +188,7 @@ export const MonitorBasePageWithStates = ({
actionLink,
children,
priorityFallback,
+ headerKey,
...props
}: MonitorBasePageWithStatesProps) => {
const { t } = useTranslation();
@@ -172,15 +196,7 @@ export const MonitorBasePageWithStates = ({
const showLoading = loading && totalCount === 0;
if (priorityFallback) {
- return (
-
- {priorityFallback}
-
- );
+ return <>{priorityFallback}>;
}
if (!loading && totalCount === 0) {
@@ -188,7 +204,7 @@ export const MonitorBasePageWithStates = ({
@@ -199,6 +215,7 @@ export const MonitorBasePageWithStates = ({
{children}
@@ -215,51 +232,163 @@ export const BaseAuthPage = ({
title,
subtitle,
children,
- ...props
+ component,
+ onSubmit,
}: BaseAuthPageProps) => {
const theme = useTheme();
+ const { t } = useTranslation();
return (
-
-
-
-
-
-
-
+
- {title}
-
- {subtitle}
+
+
+
+
+
+
+
+
+
+ {t("common.appName")}
+
+
+
+
+ {title}
+
+
+ {subtitle}
+
+
+ {children}
+
- {children}
+
+
+
+ {t("pages.auth.brandPanel.eyebrow")}
+
+
+ {t("pages.auth.brandPanel.tagline")}
+
+
+ {t("pages.auth.brandPanel.description")}
+
+
+
+ v{__APP_VERSION__}
+
-
+
);
};
diff --git a/client/src/Components/design-elements/EmptyState.tsx b/client/src/Components/design-elements/EmptyState.tsx
new file mode 100644
index 000000000..a8850d516
--- /dev/null
+++ b/client/src/Components/design-elements/EmptyState.tsx
@@ -0,0 +1,158 @@
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { useTheme } from "@mui/material/styles";
+import { Link as RouterLink } from "react-router-dom";
+import { Button } from "@/Components/inputs";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { EmptyStateIllustration } from "./EmptyStateIllustration";
+
+import type { ReactNode } from "react";
+
+interface EmptyStateProps {
+ title: string;
+ description?: ReactNode;
+ actionText?: string;
+ actionTo?: string;
+ onAction?: () => void;
+ secondaryText?: string;
+ secondaryHref?: string;
+ compact?: boolean;
+ fullscreen?: boolean;
+ alert?: ReactNode;
+ children?: ReactNode;
+}
+
+export const EmptyState = ({
+ title,
+ description,
+ actionText,
+ actionTo,
+ onAction,
+ secondaryText,
+ secondaryHref,
+ compact = false,
+ fullscreen = false,
+ alert,
+ children,
+}: EmptyStateProps) => {
+ const theme = useTheme();
+
+ if (compact) {
+ return (
+
+
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+ {alert && (
+
+ {alert}
+
+ )}
+ {(actionText || secondaryText) && (
+
+ {actionText && (actionTo || onAction) && (
+
+ {actionText}
+
+ )}
+ {secondaryText && secondaryHref && (
+
+ {secondaryText} →
+
+ )}
+
+ )}
+ {children}
+
+ );
+};
diff --git a/client/src/Components/design-elements/EmptyStateIllustration.tsx b/client/src/Components/design-elements/EmptyStateIllustration.tsx
new file mode 100644
index 000000000..d4557bc9a
--- /dev/null
+++ b/client/src/Components/design-elements/EmptyStateIllustration.tsx
@@ -0,0 +1,197 @@
+import { useTheme } from "@mui/material/styles";
+
+interface EmptyStateIllustrationProps {
+ width?: number | string;
+ height?: number | string;
+}
+
+const PALETTE = {
+ light: {
+ grid: "#CBD5E1",
+ backFill: "#F5F7FA",
+ backStroke: "#D5DBE3",
+ backInner: "#B6C2CF",
+ frontFill: "#EAF5F0",
+ frontStroke: "#7DBFAA",
+ frontInner: "#4DAF94",
+ },
+ dark: {
+ grid: "#3A4452",
+ backFill: "#475569",
+ backStroke: "#64748B",
+ backInner: "#CBD5E1",
+ frontFill: "#0F5D4A",
+ frontStroke: "#4DAF94",
+ frontInner: "#B5E2D2",
+ },
+};
+
+export const EmptyStateIllustration = ({
+ width = 160,
+ height = 120,
+}: EmptyStateIllustrationProps) => {
+ const theme = useTheme();
+ const p = theme.palette.mode === "dark" ? PALETTE.dark : PALETTE.light;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/Components/design-elements/Fallback.tsx b/client/src/Components/design-elements/Fallback.tsx
index 003bda647..989c2bed4 100644
--- a/client/src/Components/design-elements/Fallback.tsx
+++ b/client/src/Components/design-elements/Fallback.tsx
@@ -1,12 +1,8 @@
import Stack from "@mui/material/Stack";
-import Typography from "@mui/material/Typography";
-import { BulletPointCheck, SkeletonCard } from "@/Components/design-elements";
-import { Button } from "@/Components/inputs";
-import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
-
-import { useNavigate } from "react-router";
-import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { EmptyState } from "./EmptyState";
+
import type { StackProps } from "@mui/material/Stack";
interface BaseFallbackProps extends StackProps {
@@ -15,30 +11,13 @@ interface BaseFallbackProps extends StackProps {
export const BaseFallback = ({ children, ...props }: BaseFallbackProps) => {
const theme = useTheme();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
-
return (
-
{children}
);
@@ -51,126 +30,56 @@ export const ErrorFallback = ({
title: string;
subtitle: string;
}) => {
- const theme = useTheme();
return (
-
-
- {title}
-
- {subtitle}
-
+
);
};
export const EmptyFallback = ({
title,
- bullets,
+ description,
actionButtonText,
actionLink,
}: {
title: string;
- bullets: any;
+ description?: string;
actionButtonText: string;
actionLink: string;
}) => {
- const theme = useTheme();
- const navigate = useNavigate();
return (
-
-
- {title}
-
- {Array.isArray(bullets) &&
- bullets?.map((bullet: string) => (
-
- ))}
-
-
- navigate(actionLink)}
- >
- {actionButtonText}
-
-
-
-
+
);
};
export const EmptyMonitorFallback = ({
- page,
title,
- bullets,
+ description,
actionButtonText,
actionLink,
}: {
page: string;
title: string;
- bullets: any;
+ description?: string;
actionButtonText: string;
actionLink: string;
}) => {
- const theme = useTheme();
- const navigate = useNavigate();
return (
-
-
-
- {title}
-
-
- {Array.isArray(bullets) &&
- bullets?.map((bullet: string, index: number) => (
-
- ))}
-
-
- navigate(actionLink)}
- >
- {actionButtonText}
-
-
-
-
+
);
};
diff --git a/client/src/Components/design-elements/Gauge.tsx b/client/src/Components/design-elements/Gauge.tsx
index fda42df3c..a377a5925 100644
--- a/client/src/Components/design-elements/Gauge.tsx
+++ b/client/src/Components/design-elements/Gauge.tsx
@@ -114,6 +114,7 @@ export const DetailGauge = ({
upperValue,
lowerLabel,
lowerValue,
+ maxWidth = 225,
}: {
title: string;
progress: number;
@@ -121,13 +122,14 @@ export const DetailGauge = ({
upperValue?: string | number;
lowerLabel?: string;
lowerValue?: string | number;
+ maxWidth?: number;
}) => {
const theme = useTheme();
return (
{
return (
{
= {
+ info: "ⓘ",
+ warning: "⚠",
+ error: "✕",
+};
+
+interface NoticeBannerProps {
+ severity?: Severity;
+ children: React.ReactNode;
+}
+
+export const NoticeBanner = ({ severity = "info", children }: NoticeBannerProps) => {
+ const theme = useTheme();
+ const tone = theme.palette[severity].main;
+ return (
+
+
+ {SEVERITY_GLYPH[severity]}
+
+
+ {children}
+
+
+ );
+};
diff --git a/client/src/Components/design-elements/PageHeader.tsx b/client/src/Components/design-elements/PageHeader.tsx
new file mode 100644
index 000000000..84b0f61c6
--- /dev/null
+++ b/client/src/Components/design-elements/PageHeader.tsx
@@ -0,0 +1,164 @@
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import Box from "@mui/material/Box";
+import Tooltip from "@mui/material/Tooltip";
+import { Link as RouterLink } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { useTheme } from "@mui/material/styles";
+import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
+
+import type { ReactNode } from "react";
+
+const EXTERNAL_LINKS = {
+ support: "https://discord.com/invite/NAb6H3UTjK",
+ docs: "https://checkmate.so/docs",
+ changelog: "https://github.com/bluewave-labs/checkmate/releases",
+};
+
+interface PageHeaderProps {
+ title: string;
+ description?: ReactNode;
+ backTo?: string;
+ backLabel?: string;
+}
+
+export const PageHeader = ({
+ title,
+ description,
+ backTo,
+ backLabel,
+}: PageHeaderProps) => {
+ const theme = useTheme();
+ const { t } = useTranslation();
+
+ const linkItems: { key: keyof typeof EXTERNAL_LINKS; icon: ReactNode }[] = [
+ {
+ key: "support",
+ icon: (
+
+ ),
+ },
+ {
+ key: "docs",
+ icon: (
+
+ ),
+ },
+ {
+ key: "changelog",
+ icon: (
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ {backTo && backLabel && (
+
+
+ {backLabel}
+
+ )}
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+ {linkItems.map((item, idx) => (
+
+
+ {item.icon}
+
+
+ ))}
+
+
+ );
+};
diff --git a/client/src/Components/design-elements/SplitBox.tsx b/client/src/Components/design-elements/SplitBox.tsx
index 08f245178..3cb0637b8 100644
--- a/client/src/Components/design-elements/SplitBox.tsx
+++ b/client/src/Components/design-elements/SplitBox.tsx
@@ -53,7 +53,7 @@ export const ConfigBox = ({
rightContent,
}: {
title: string;
- subtitle: string;
+ subtitle: React.ReactNode;
leftContent?: React.ReactNode;
rightContent: React.ReactNode;
}) => {
@@ -63,9 +63,9 @@ export const ConfigBox = ({
left={
{title}
diff --git a/client/src/Components/design-elements/StatBox.tsx b/client/src/Components/design-elements/StatBox.tsx
index 01a8e9835..954120fe5 100644
--- a/client/src/Components/design-elements/StatBox.tsx
+++ b/client/src/Components/design-elements/StatBox.tsx
@@ -69,7 +69,12 @@ export const StatBox = ({
>
- {title}
+
+ {title}
+
{tooltip && (
{
overflow: "hidden",
position: "relative",
flex: 1,
- padding: theme.spacing(4),
+ padding: theme.spacing(6),
...sx,
}}
>
@@ -65,9 +65,13 @@ const StatusBox = ({
{label}
diff --git a/client/src/Components/design-elements/StatusCodeLabel.tsx b/client/src/Components/design-elements/StatusCodeLabel.tsx
new file mode 100644
index 000000000..f088550f0
--- /dev/null
+++ b/client/src/Components/design-elements/StatusCodeLabel.tsx
@@ -0,0 +1,40 @@
+import { useTranslation } from "react-i18next";
+import {
+ formatStatusCode,
+ getStatusCodeTooltip,
+ getStatusCodeValueType,
+} from "@/Utils/statusCode";
+import { ValueLabel } from "./StatusLabel";
+import { Tooltip } from "./Tooltip";
+
+export type StatusCodeLabelProps = {
+ statusCode: number | null | undefined;
+ message?: string | null;
+ fallback?: string;
+};
+
+export const StatusCodeLabel = ({
+ statusCode,
+ message,
+ fallback = "N/A",
+}: StatusCodeLabelProps) => {
+ const { t } = useTranslation();
+ if (!statusCode) {
+ return <>{fallback}>;
+ }
+
+ const text = formatStatusCode(statusCode, t);
+ const tooltip = getStatusCodeTooltip(statusCode, message, t);
+ const body = (
+
+ );
+ if (!tooltip) return body;
+ return (
+
+ {body}
+
+ );
+};
diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx
index 505f79fea..a02f9be8f 100644
--- a/client/src/Components/design-elements/Table.tsx
+++ b/client/src/Components/design-elements/Table.tsx
@@ -18,10 +18,9 @@ import {
ChevronsRight,
ChevronLeft,
ChevronRight,
- Coffee,
Ellipsis,
- PartyPopper,
} from "lucide-react";
+import { EmptyState } from "./EmptyState";
import TablePagination from "@mui/material/TablePagination";
import type { TablePaginationOwnProps } from "@mui/material/TablePagination";
@@ -36,6 +35,9 @@ import useMediaQuery from "@mui/material/useMediaQuery";
export type Header = {
id: number | string;
content: React.ReactNode;
+ mobileLabel?: React.ReactNode;
+ hideMobileLabel?: boolean;
+ align?: "left" | "center" | "right" | "justify" | "inherit";
onClick?: (event: React.MouseEvent, row: T) => void;
render: (row: T) => React.ReactNode;
};
@@ -110,6 +112,24 @@ export function DataTable<
key={key}
>
{headers.map((header) => {
+ if (header.hideMobileLabel) {
+ return (
+
+
+ {header.render(row)}
+
+
+ );
+ }
+
return (
- {header.content}
+ {header.mobileLabel ?? header.content}
{
return (
{header.content}
@@ -218,7 +243,7 @@ export function DataTable<
{headers.map((header, index) => {
return (
header.onClick!(e, row) : undefined
@@ -548,49 +573,18 @@ export const Pagination = ({ ...props }: PaginationProps) => {
);
};
-const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) => {
- const theme = useTheme();
+const EmptyView = ({ text }: { text?: string; positive?: boolean }) => {
const { t } = useTranslation();
- const Icon = positive ? PartyPopper : Coffee;
+ const theme = useTheme();
return (
-
-
-
-
-
- {text ?? t("common.table.empty")}
-
-
+
+
);
};
diff --git a/client/src/Components/design-elements/Tooltip.tsx b/client/src/Components/design-elements/Tooltip.tsx
index f83b1331c..f9bbbb92b 100644
--- a/client/src/Components/design-elements/Tooltip.tsx
+++ b/client/src/Components/design-elements/Tooltip.tsx
@@ -16,16 +16,15 @@ export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) =>
slotProps={{
tooltip: {
sx: {
- background:
+ backgroundColor:
theme.palette.mode === "dark"
- ? "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)"
- : "linear-gradient(135deg, #2c3e50 0%, #1a252f 100%)",
- backgroundColor: "transparent",
- color: "#ffffff",
+ ? theme.palette.grey[900]
+ : theme.palette.grey[800],
+ color: theme.palette.common.white,
fontSize: "13px",
padding: `${theme.spacing(4)} ${theme.spacing(5)}`,
borderRadius: `${theme.shape.borderRadius}px`,
- boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
+ boxShadow: theme.shadows[6],
},
},
}}
diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx
index 45114116b..130ccd831 100644
--- a/client/src/Components/design-elements/index.tsx
+++ b/client/src/Components/design-elements/index.tsx
@@ -1,5 +1,8 @@
export * from "./Breadcrumb";
+export * from "./PageHeader";
export * from "./BasePage";
+export * from "./EmptyState";
+export * from "./EmptyStateIllustration";
export * from "./Fallback";
export * from "./SkeletonCard";
export * from "./BulletPointCheck";
@@ -19,6 +22,8 @@ export * from "./Gauge";
export * from "./Tabs";
export * from "./SplitBox";
export * from "./TextLink";
+export * from "./NoticeBanner";
export * from "./OfflineBanner";
export * from "./Avatar";
+export * from "./StatusCodeLabel";
export * from "./StrategyBadge";
diff --git a/client/src/Components/inputs/Button.tsx b/client/src/Components/inputs/Button.tsx
index 54f776075..4eae654b2 100644
--- a/client/src/Components/inputs/Button.tsx
+++ b/client/src/Components/inputs/Button.tsx
@@ -1,19 +1,43 @@
import Button from "@mui/material/Button";
import type { ButtonProps } from "@mui/material/Button";
-import { useTheme } from "@mui/material/styles";
+import { useTheme, darken } from "@mui/material/styles";
+import { HOVER } from "@/Utils/Theme/constants";
+
+const PALETTE_COLORS = [
+ "primary",
+ "secondary",
+ "error",
+ "warning",
+ "info",
+ "success",
+] as const;
+type PaletteColor = (typeof PALETTE_COLORS)[number];
export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
const theme = useTheme();
- const outlinedSx =
+
+ const hoverOverrides: Record = { boxShadow: "none" };
+
+ if (props.variant === "outlined") {
+ hoverOverrides.borderColor = theme.palette.text.secondary;
+ }
+
+ if (props.variant === "contained") {
+ const colorKey = (props.color ?? "primary") as PaletteColor;
+ const palette = theme.palette[colorKey];
+ if (palette && typeof palette === "object" && "main" in palette) {
+ hoverOverrides.backgroundColor = darken(palette.main, HOVER.DARKEN);
+ }
+ }
+
+ const variantSx =
props.variant === "outlined"
? {
color: theme.palette.text.primary,
borderColor: theme.palette.divider,
- "&:hover": {
- borderColor: theme.palette.text.secondary,
- },
}
: {};
+
return (
{
textOverflow: "ellipsis",
overflow: "hidden",
boxShadow: "none",
- "&:hover": {
- boxShadow: "none",
- },
- ...outlinedSx,
+ "&:hover": hoverOverrides,
+ ...variantSx,
...sx,
}}
/>
diff --git a/client/src/Components/inputs/Dialog.tsx b/client/src/Components/inputs/Dialog.tsx
index b0c924f55..02d164abd 100644
--- a/client/src/Components/inputs/Dialog.tsx
+++ b/client/src/Components/inputs/Dialog.tsx
@@ -2,10 +2,12 @@ import Dialog from "@mui/material/Dialog";
import type { DialogProps } from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
-import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
+import Typography from "@mui/material/Typography";
+import Box from "@mui/material/Box";
import { Button } from "@/Components/inputs";
-import { typographyLevels } from "@/Utils/Theme/Palette";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { ReactNode } from "react";
@@ -16,7 +18,6 @@ export const DialogInput = ({
onConfirm,
onCancel,
confirmColor = "primary",
- cancelColor = "error",
loading = false,
cancelText,
confirmText,
@@ -31,7 +32,6 @@ export const DialogInput = ({
onConfirm?(item: any): any;
onCancel?(item: any): any;
confirmColor?: "error" | "primary";
- cancelColor?: "error" | "primary";
loading?: boolean;
cancelText?: string;
confirmText?: string;
@@ -41,22 +41,58 @@ export const DialogInput = ({
additionalButtons?: ReactNode;
}) => {
const { t } = useTranslation();
+ const theme = useTheme();
return (
{
+ if (reason !== "backdropClick" && onCancel) onCancel(undefined);
+ }}
>
- {title && {title} }
-
- {content && {content} }
- {children}
-
-
+ {title && (
+
+
+ {title}
+
+ {content && (
+
+ {content}
+
+ )}
+
+ )}
+ {children && (
+
+ {children}
+
+ )}
+
{cancelText ?? t("common.buttons.cancel")}
diff --git a/client/src/Components/inputs/FieldLabel.tsx b/client/src/Components/inputs/FieldLabel.tsx
index 448545a19..1c1bc0a8c 100644
--- a/client/src/Components/inputs/FieldLabel.tsx
+++ b/client/src/Components/inputs/FieldLabel.tsx
@@ -15,9 +15,9 @@ export const FieldLabel = ({ children, required = false, htmlFor }: FieldLabelPr
return (
handleFile(e.target.files?.[0])}
style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }}
/>
-
+
= {
- cs: "cz",
- ja: "jp",
- uk: "ua",
- vi: "vn",
+export const languageNames: Record = {
+ en: "English",
+ de: "Deutsch",
+ ca: "Català",
+ es: "Español",
+ fr: "Français",
+ "pt-BR": "Português (BR)",
+ cs: "Čeština",
+ fi: "Suomi",
+ tr: "Türkçe",
+ ru: "Русский",
+ uk: "Українська",
+ ar: "العربية",
+ th: "ไทย",
+ vi: "Tiếng Việt",
+ ja: "日本語",
+ "zh-CN": "简体中文",
+ "zh-TW": "繁體中文",
};
export const LanguageSelector = () => {
const { i18n } = useTranslation();
- const theme = useTheme();
const { language = "en" } = useSelector((state: RootState) => state.ui);
const dispatch = useDispatch();
const handleChange = (event: any) => {
@@ -35,64 +43,15 @@ export const LanguageSelector = () => {
value={language}
onChange={handleChange}
size="small"
- // sx={{
- // minWidth: 80,
- // "& .MuiSelect-select": {
- // display: "flex",
- // alignItems: "center",
- // justifyContent: "center",
- // },
- // "& .MuiSelect-icon": {
- // alignSelf: "center",
- // },
- // }}
>
- {languages.map((lang) => {
- let parsedLang = lang === "en" ? "gb" : lang;
-
- if (parsedLang.includes("-")) {
- parsedLang = parsedLang.split("-")[1].toLowerCase();
- }
-
- parsedLang = langMap[parsedLang] || parsedLang;
-
- const flag = parsedLang ? `fi fi-${parsedLang}` : null;
-
- return (
-
-
-
- {flag && }
-
-
- {lang}
-
-
-
- );
- })}
+ {languages.map((lang) => (
+
+ {languageNames[lang] ?? lang}
+
+ ))}
);
};
diff --git a/client/src/Components/inputs/Radio.tsx b/client/src/Components/inputs/Radio.tsx
index ad8dc3374..01e968188 100644
--- a/client/src/Components/inputs/Radio.tsx
+++ b/client/src/Components/inputs/Radio.tsx
@@ -1,52 +1,13 @@
import Radio from "@mui/material/Radio";
import type { RadioProps } from "@mui/material/Radio";
import { useTheme } from "@mui/material/styles";
-import { Circle, CircleDot } from "lucide-react";
import FormControlLabel from "@mui/material/FormControlLabel";
import Typography from "@mui/material/Typography";
interface RadioInputProps extends RadioProps {}
export const RadioInput = ({ ...props }: RadioInputProps) => {
- const theme = useTheme();
- return (
-
- }
- checkedIcon={
-
- }
- sx={{
- padding: 0,
- mt: theme.spacing(0.5),
- color: theme.palette.text.secondary,
- "&.Mui-checked": {
- color: theme.palette.primary.main,
- "& svg circle": {
- fill: theme.palette.primary.main,
- },
- },
- "& .MuiSvgIcon-root": {
- fontSize: 16,
- },
- "& svg": {
- stroke: "currentColor",
- },
- "& svg path, & svg line, & svg polyline, & svg rect, & svg circle": {
- stroke: "currentColor",
- fill: "none",
- },
- }}
- />
- );
+ return ;
};
export const RadioWithDescription = ({
@@ -79,7 +40,6 @@ export const RadioWithDescription = ({
backgroundColor: theme.palette.background.paper,
},
"& .MuiButtonBase-root": {
- p: 0,
mr: theme.spacing(6),
},
}}
diff --git a/client/src/Components/inputs/Select.tsx b/client/src/Components/inputs/Select.tsx
index 190307ec3..92a57860a 100644
--- a/client/src/Components/inputs/Select.tsx
+++ b/client/src/Components/inputs/Select.tsx
@@ -11,15 +11,14 @@ interface SelectInputProps extends Omit, "label"> {
fieldLabel?: string;
required?: boolean;
placeholder?: string;
- placeholderColor?: string;
}
const SelectInputInner = (
- { fieldLabel, required, placeholder, placeholderColor, ...props }: SelectInputProps,
+ { fieldLabel, required, placeholder, ...props }: SelectInputProps,
ref: React.ForwardedRef
) => {
const theme = useTheme();
- const emptyPlaceholderColor = placeholderColor || theme.palette.text.disabled;
+ const emptyPlaceholderColor = theme.palette.text.secondary;
const renderValue = (selected: unknown) => {
const isMultiple = Boolean((props as { multiple?: boolean }).multiple);
diff --git a/client/src/Components/inputs/Switch.tsx b/client/src/Components/inputs/Switch.tsx
index 05be73a24..06ed5bc5f 100644
--- a/client/src/Components/inputs/Switch.tsx
+++ b/client/src/Components/inputs/Switch.tsx
@@ -21,24 +21,16 @@ export const SwitchComponent = forwardRef
diff --git a/client/src/Components/inputs/TextInput.tsx b/client/src/Components/inputs/TextInput.tsx
index 718b60327..13277be72 100644
--- a/client/src/Components/inputs/TextInput.tsx
+++ b/client/src/Components/inputs/TextInput.tsx
@@ -5,6 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
+import { LAYOUT } from "@/Utils/Theme/constants";
interface TextInputProps extends Omit {
fieldLabel?: string;
@@ -12,39 +13,53 @@ interface TextInputProps extends Omit {
}
export const TextInput = forwardRef(function TextInput(
- { fieldLabel, required, ...props },
+ { fieldLabel, required, sx, ...props },
ref
) {
const theme = useTheme();
+ const innerSx = {
+ width: "100%",
+ "& .MuiOutlinedInput-root": {
+ borderRadius: theme.shape.borderRadius,
+ height: 34,
+ fontSize: typographyLevels.base,
+ overflow: "hidden",
+ },
+ "& .MuiOutlinedInput-notchedOutline": {
+ borderColor: theme.palette.divider,
+ },
+ "&:hover .MuiOutlinedInput-notchedOutline": {
+ borderColor: theme.palette.divider,
+ },
+ "& .MuiFormHelperText-root": {
+ marginLeft: 0,
+ marginRight: 0,
+ marginTop: theme.spacing(1),
+ },
+ "& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
+ {
+ WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
+ WebkitTextFillColor: `${theme.palette.text.primary} !important`,
+ caretColor: theme.palette.text.primary,
+ transition: "background-color 5000s ease-in-out 0s",
+ },
+ };
+
const input = (
);
if (fieldLabel) {
return (
-
+
{fieldLabel}
{input}
diff --git a/client/src/Components/inputs/ToggleButton.tsx b/client/src/Components/inputs/ToggleButton.tsx
index 40b4309b8..a2b392357 100644
--- a/client/src/Components/inputs/ToggleButton.tsx
+++ b/client/src/Components/inputs/ToggleButton.tsx
@@ -3,8 +3,17 @@ import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import type { ToggleButtonProps } from "@mui/material/ToggleButton";
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
import { useTheme } from "@mui/material/styles";
+import { alpha } from "@mui/material/styles";
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
+ const theme = useTheme();
+ const isDark = theme.palette.mode === "dark";
+ const selectedBg = isDark
+ ? alpha(theme.palette.primary.main, 0.22)
+ : alpha(theme.palette.primary.main, 0.06);
+ const selectedHoverBg = isDark
+ ? alpha(theme.palette.primary.main, 0.3)
+ : alpha(theme.palette.primary.main, 0.1);
return (
{
textOverflow: "ellipsis",
overflow: "hidden",
boxShadow: "none",
+ color: theme.palette.text.secondary,
"&:hover": {
boxShadow: "none",
+ backgroundColor: theme.palette.action.hover,
+ },
+ "&.Mui-selected": {
+ backgroundColor: selectedBg,
+ color: theme.palette.text.primary,
+ fontWeight: 500,
+ "&:hover": {
+ backgroundColor: selectedHoverBg,
+ },
},
...sx,
}}
@@ -34,7 +53,6 @@ export const ToggleButtonGroupInput = ({ sx, ...props }: ToggleButtonGroupProps)
sx={{
"& .MuiToggleButtonGroup-grouped": {
borderColor: theme.palette.divider,
-
borderRadius: 0,
"&:first-of-type": {
borderTopLeftRadius: 2,
diff --git a/client/src/Components/monitors/BulkActionsBar.tsx b/client/src/Components/monitors/BulkActionsBar.tsx
new file mode 100644
index 000000000..aa520745f
--- /dev/null
+++ b/client/src/Components/monitors/BulkActionsBar.tsx
@@ -0,0 +1,102 @@
+import React from "react";
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import Paper from "@mui/material/Paper";
+import Slide from "@mui/material/Slide";
+import IconButton from "@mui/material/IconButton";
+import { X } from "lucide-react";
+
+import { useTranslation } from "react-i18next";
+import { useTheme } from "@mui/material/styles";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { useSidebar } from "@/Hooks/useSidebar";
+
+interface BulkActionsBarProps {
+ selectedCount: number;
+ onCancel: () => void;
+ children?: React.ReactNode;
+}
+
+export const BulkActionsBar = ({
+ selectedCount,
+ onCancel,
+ children,
+}: BulkActionsBarProps) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const isOpen = selectedCount > 0;
+
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
+ const { width, collapsedWidth, transition } = useSidebar();
+
+ return (
+
+
+
+
+
+ {t("pages.common.monitors.actions.bulkSelected", {
+ count: selectedCount,
+ })}
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+};
diff --git a/client/src/Components/monitors/GeoChecksMap.tsx b/client/src/Components/monitors/GeoChecksMap.tsx
index 3f9429853..e013f2235 100644
--- a/client/src/Components/monitors/GeoChecksMap.tsx
+++ b/client/src/Components/monitors/GeoChecksMap.tsx
@@ -3,6 +3,8 @@ import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
import Map, { Marker, Popup } from "react-map-gl/maplibre";
import type { MapRef } from "react-map-gl/maplibre";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
+import { useTranslation } from "react-i18next";
+import { formatStatusCode } from "@/Utils/statusCode";
import "maplibre-gl/dist/maplibre-gl.css";
interface GeoChecksMapProps {
@@ -14,6 +16,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
const [selectedCheck, setSelectedCheck] = useState(null);
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
+ const { t } = useTranslation();
const mapPopupStyles = (
{
>
Status Code:
{" "}
- {selectedCheck.statusCode}
+ {formatStatusCode(selectedCheck.statusCode, t)}
0
- ? Date.now() - timeOfLastFailure
- : Date.now() - new Date(monitorStats?.createdAt || 0).getTime();
+ const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
// Determine time since last check
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
@@ -42,6 +39,12 @@ export const MonitorStatBoxes = ({
const streakTime = prettyMilliseconds(timeSinceLastFailure, 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);
return (
@@ -51,8 +54,12 @@ export const MonitorStatBoxes = ({
>
>
|
|
@@ -75,13 +75,14 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
}}
>
{msg[palette]}
{`${avg?.toFixed()}ms`}
diff --git a/client/src/Components/monitors/index.tsx b/client/src/Components/monitors/index.tsx
index efba79a08..46532706f 100644
--- a/client/src/Components/monitors/index.tsx
+++ b/client/src/Components/monitors/index.tsx
@@ -14,3 +14,4 @@ export * from "./charts/HistogramPageSpeedDetails";
export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
+export * from "./BulkActionsBar";
diff --git a/client/src/Components/sidebar/Authfooter.tsx b/client/src/Components/sidebar/Authfooter.tsx
index 49df78270..34f76d7ce 100644
--- a/client/src/Components/sidebar/Authfooter.tsx
+++ b/client/src/Components/sidebar/Authfooter.tsx
@@ -68,7 +68,12 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
gap: theme.spacing(LAYOUT.MD),
borderRadius: theme.shape.borderRadius,
pl: theme.spacing(LAYOUT.XS),
- "& svg": { stroke: theme.palette.text.secondary },
+ "& svg": {
+ height: 16,
+ width: 16,
+ opacity: 0.81,
+ stroke: theme.palette.text.secondary,
+ },
};
return (
diff --git a/client/src/Components/sidebar/Logo.tsx b/client/src/Components/sidebar/Logo.tsx
index d7ab2c751..1bb25bd0a 100644
--- a/client/src/Components/sidebar/Logo.tsx
+++ b/client/src/Components/sidebar/Logo.tsx
@@ -6,6 +6,7 @@ import { toggleSidebar } from "@/Features/UI/uiSlice.js";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import useSidebar from "@/Hooks/useSidebar";
+import KingIcon from "@/assets/icons/checkmate-icon.svg?react";
export const Logo = (props: StackProps) => {
const { t } = useTranslation();
@@ -23,19 +24,21 @@ export const Logo = (props: StackProps) => {
sx={{ cursor: "pointer" }}
{...props}
>
-
- C
-
+
+
{
{
name: t("components.sidebar.menu.logs"),
path: "logs",
- icon: ,
+ icon: ,
},
{
name: t("components.sidebar.menu.settings"),
diff --git a/client/src/Components/sidebar/NavItem.tsx b/client/src/Components/sidebar/NavItem.tsx
index 756b10d60..24f2c5778 100644
--- a/client/src/Components/sidebar/NavItem.tsx
+++ b/client/src/Components/sidebar/NavItem.tsx
@@ -22,7 +22,9 @@ export const NavItem = ({
}) => {
const { collapsed } = useSidebar();
const theme = useTheme();
- const iconStroke = selected ? theme.palette.primary.main : theme.palette.text.secondary;
+ const iconStroke = selected
+ ? theme.palette.sidebar.accent
+ : theme.palette.text.secondary;
const buttonBgColor = selected ? theme.palette.action.selected : "transparent";
const buttonBgHoverColor = selected
@@ -79,7 +81,7 @@ export const NavItem = ({
},
".MuiListItemButton-root:hover &": {
"& svg path, & svg line, & svg polyline, & svg rect, & svg circle": {
- stroke: theme.palette.primary.main,
+ stroke: theme.palette.sidebar.accent,
},
},
}}
diff --git a/client/src/Components/sidebar/index.tsx b/client/src/Components/sidebar/index.tsx
index f4c6527eb..d2d759c6b 100644
--- a/client/src/Components/sidebar/index.tsx
+++ b/client/src/Components/sidebar/index.tsx
@@ -6,7 +6,7 @@ import Divider from "@mui/material/Divider";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useSidebar } from "@/Hooks/useSidebar.js";
import { Logo } from "@/Components/sidebar/Logo";
-import { getMenu, getBottomMenu, getAccountMenu } from "@/Components/sidebar/Menu";
+import { getMenu, getAccountMenu } from "@/Components/sidebar/Menu";
import { NavItem } from "@/Components/sidebar/NavItem";
import { StarPrompt } from "@/Components/sidebar/StarPrompt";
import { AuthFooter } from "@/Components/sidebar/Authfooter";
@@ -20,7 +20,7 @@ import { setCollapsed } from "@/Features/UI/uiSlice";
const URL_MAP: Record = {
support: "https://discord.com/invite/NAb6H3UTjK",
discussions: "https://github.com/bluewave-labs/checkmate/discussions",
- docs: "https://bluewavelabs.gitbook.io/checkmate",
+ docs: "https://checkmate.so/docs",
changelog: "https://github.com/bluewave-labs/checkmate/releases",
};
@@ -33,7 +33,6 @@ export const Sidebar = () => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const menu = getMenu(t);
- const bottomMenu = getBottomMenu(t);
const accountMenu = getAccountMenu(t);
useEffect(() => {
@@ -103,23 +102,6 @@ export const Sidebar = () => {
})}
-
- {bottomMenu.map((item) => {
- const selected = location.pathname.startsWith(`/${item.path}`);
- return (
- handleNavClick(item.path)}
- />
- );
- })}
-
) => {
- state.timezone = action.payload.timezone;
+ if (action.payload.timezone) {
+ state.timezone = action.payload.timezone;
+ }
},
setLanguage: (state, action: PayloadAction) => {
state.language = action.payload;
diff --git a/client/src/Hooks/UseApi.ts b/client/src/Hooks/UseApi.ts
index 84fd1b547..543a9f165 100644
--- a/client/src/Hooks/UseApi.ts
+++ b/client/src/Hooks/UseApi.ts
@@ -57,7 +57,11 @@ export const usePost = () => {
...config?.headers,
},
});
- toastSuccess(res.data?.msg || "Operation successful");
+ if (res.data?.success === false) {
+ toastError(res.data?.msg || "Operation failed");
+ } else {
+ toastSuccess(res.data?.msg || "Operation successful");
+ }
return res.data;
} catch (err: any) {
diff --git a/client/src/Hooks/UseToast.ts b/client/src/Hooks/UseToast.ts
index 8823aa675..72e7b18c9 100644
--- a/client/src/Hooks/UseToast.ts
+++ b/client/src/Hooks/UseToast.ts
@@ -4,8 +4,9 @@ export const useToast = () => {
const showToast = (message: string, options?: ToastOptions) => {
toast.dismiss();
const baseStyle: React.CSSProperties = {
- whiteSpace: "pre-line",
- wordBreak: "break-word",
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
};
toast(message, {
...options,
diff --git a/client/src/Hooks/useBulkMonitorActions.ts b/client/src/Hooks/useBulkMonitorActions.ts
new file mode 100644
index 000000000..268137392
--- /dev/null
+++ b/client/src/Hooks/useBulkMonitorActions.ts
@@ -0,0 +1,94 @@
+import { useState, useEffect } from "react";
+import axios from "axios";
+import { post } from "@/Utils/ApiClient";
+import { useToast } from "@/Hooks/UseToast";
+import { useTranslation } from "react-i18next";
+import { logger } from "@/Utils/logger";
+import type { Monitor } from "@/Types/Monitor";
+
+interface ApiResponse {
+ success: boolean;
+ msg: string;
+ data: Monitor[];
+}
+
+interface UseBulkMonitorActionsReturn {
+ selectedRows: string[];
+ setSelectedRows: (rows: string[]) => void;
+ handleBulkPause: () => Promise;
+ handleBulkResume: () => Promise;
+ handleCancelSelection: () => void;
+}
+
+export const useBulkMonitorActions = (
+ refetch: () => void,
+ page?: number
+): UseBulkMonitorActionsReturn => {
+ const [selectedRows, setSelectedRows] = useState([]);
+ const { toastSuccess, toastError, toastInfo } = useToast();
+ const { t } = useTranslation();
+
+ // Clear selection when page changes
+ useEffect(() => {
+ setSelectedRows([]);
+ }, [page]);
+
+ const executeBulkAction = async (pause: boolean) => {
+ try {
+ const res = await post("/monitors/bulk/pause", {
+ monitorIds: selectedRows,
+ pause,
+ });
+
+ const affectedCount = res.data?.data?.length ?? 0;
+
+ if (affectedCount === 0) {
+ const key = pause
+ ? "pages.common.monitors.bulkPause.alreadyPaused"
+ : "pages.common.monitors.bulkPause.alreadyRunning";
+ toastInfo(t(key, { count: selectedRows.length }));
+ } else {
+ const key = pause
+ ? "pages.common.monitors.bulkPause.paused"
+ : "pages.common.monitors.bulkPause.resumed";
+ toastSuccess(t(key, { count: affectedCount }));
+ }
+
+ setSelectedRows([]);
+ refetch();
+ } catch (err: unknown) {
+ let errMsg = "An error occurred";
+
+ if (axios.isAxiosError(err)) {
+ errMsg = err.response?.data?.msg || err.message || errMsg;
+ } else if (err instanceof Error) {
+ errMsg = err.message;
+ }
+
+ logger.error("Bulk pause/resume failed", err instanceof Error ? err : undefined, {
+ pause,
+ });
+ toastError(errMsg);
+ }
+ };
+
+ const handleBulkPause = async () => {
+ await executeBulkAction(true);
+ };
+
+ const handleBulkResume = async () => {
+ await executeBulkAction(false);
+ };
+
+ const handleCancelSelection = () => {
+ setSelectedRows([]);
+ };
+
+ return {
+ selectedRows,
+ setSelectedRows,
+ handleBulkPause,
+ handleBulkResume,
+ handleCancelSelection,
+ };
+};
diff --git a/client/src/Hooks/useClientPagination.ts b/client/src/Hooks/useClientPagination.ts
new file mode 100644
index 000000000..93bb5256a
--- /dev/null
+++ b/client/src/Hooks/useClientPagination.ts
@@ -0,0 +1,37 @@
+import { useState } from "react";
+
+export const useClientPagination = (rows: T[], initialRowsPerPage = 10) => {
+ const [page, setPage] = useState(0);
+ const [rowsPerPage, setRowsPerPage] = useState(initialRowsPerPage);
+
+ const handlePageChange = (
+ _e: React.MouseEvent | null,
+ newPage: number
+ ) => {
+ setPage(newPage);
+ };
+
+ const handleRowsPerPageChange = (
+ e: React.ChangeEvent
+ ) => {
+ setPage(0);
+ setRowsPerPage(Number(e.target.value));
+ };
+
+ const pagedRows = rows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
+
+ return {
+ page,
+ rowsPerPage,
+ pagedRows,
+ paginationProps: {
+ component: "div" as const,
+ count: rows.length,
+ page,
+ rowsPerPage,
+ onPageChange: handlePageChange,
+ onRowsPerPageChange: handleRowsPerPageChange,
+ itemsOnPage: pagedRows.length,
+ },
+ };
+};
diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts
index ac13854b3..990768b96 100644
--- a/client/src/Hooks/useMonitorForm.ts
+++ b/client/src/Hooks/useMonitorForm.ts
@@ -113,6 +113,15 @@ export const useMonitorForm = ({
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
+ case "dns":
+ defaults = {
+ ...base,
+ type: "dns",
+ url: data?.url || "",
+ dnsServer: data?.dnsServer || "",
+ dnsRecordType: data?.dnsRecordType || "A",
+ };
+ break;
default:
defaults = {
...base,
diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts
index c45773db7..d08f17548 100644
--- a/client/src/Hooks/useNotificationForm.ts
+++ b/client/src/Hooks/useNotificationForm.ts
@@ -60,6 +60,24 @@ function buildDefaults(data: Notification | null): NotificationFormData {
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",
diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts
index 4b0768d79..643e9caca 100644
--- a/client/src/Hooks/useSettingsForm.ts
+++ b/client/src/Hooks/useSettingsForm.ts
@@ -18,6 +18,7 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
systemEmailHost: data?.systemEmailHost || "",
systemEmailUser: data?.systemEmailUser || "",
systemEmailAddress: data?.systemEmailAddress || "",
+ systemEmailDisplayName: data?.systemEmailDisplayName || "",
systemEmailConnectionHost: data?.systemEmailConnectionHost || "localhost",
systemEmailTLSServername: data?.systemEmailTLSServername || "",
systemEmailPort: data?.systemEmailPort,
diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts
index 0011ea761..681a3da6c 100644
--- a/client/src/Hooks/useStatusPageForm.ts
+++ b/client/src/Hooks/useStatusPageForm.ts
@@ -1,7 +1,12 @@
import { useMemo } from "react";
import { statusPageSchema, type StatusPageFormData } from "@/Validation/statusPage";
import type { StatusPage } from "@/Types/StatusPage";
+import {
+ DEFAULT_STATUS_PAGE_THEME,
+ DEFAULT_STATUS_PAGE_THEME_MODE,
+} from "@/Types/StatusPage";
import type { Monitor } from "@/Types/Monitor";
+import { resolveTimezone } from "@/Utils/TimeUtils";
interface UseStatusPageFormOptions {
data?: StatusPage | null;
@@ -26,9 +31,9 @@ export const useStatusPageForm = ({
const defaults: StatusPageFormData = {
companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(),
- timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
+ timezone: resolveTimezone(data?.timezone),
type: data?.type || ["uptime"],
- color: data?.color || "#4169E1",
+ color: data?.color || "#13715B",
monitors: data?.monitors || [],
isPublished: data?.isPublished ?? false,
showCharts: data?.showCharts ?? true,
@@ -36,6 +41,8 @@ export const useStatusPageForm = ({
showAdminLoginLink: data?.showAdminLoginLink ?? false,
showInfrastructure: data?.showInfrastructure ?? false,
customCSS: data?.customCSS || "",
+ theme: data?.theme ?? DEFAULT_STATUS_PAGE_THEME,
+ themeMode: data?.themeMode ?? DEFAULT_STATUS_PAGE_THEME_MODE,
logo: transformLogo(data?.logo),
};
diff --git a/client/src/Pages/Account/TabTeam.tsx b/client/src/Pages/Account/TabTeam.tsx
index 4eb2f8a97..ac82ab703 100644
--- a/client/src/Pages/Account/TabTeam.tsx
+++ b/client/src/Pages/Account/TabTeam.tsx
@@ -1,15 +1,18 @@
import { Stack } from "@mui/material";
import { useTheme } from "@mui/material";
import { useState, useMemo } from "react";
+import { useTranslation } from "react-i18next";
import { HeaderTeamControls } from "./components/HeaderTeamControls";
import { TeamTable } from "./components/TeamTable";
import { InviteTeamMemberDialog } from "./components/InviteTeamMemberDialog";
import { AddTeamMemberDialog } from "./components/AddTeamMemberDialog";
+import { EmptyState } from "@/Components/design-elements";
import { useGet } from "@/Hooks/UseApi";
import type { User, UserRole } from "@/Types/User";
export const TabTeam = () => {
const theme = useTheme();
+ const { t } = useTranslation();
const [filter, setFilter] = useState("");
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
@@ -33,6 +36,9 @@ export const TabTeam = () => {
refetch();
};
+ const totalUsers = users?.length ?? 0;
+ const noUsers = users !== undefined && totalUsers === 0;
+
return (
{
onInviteClick={handleOpenInviteDialog}
onAddMemberClick={handleOpenAddMemberDialog}
/>
-
+ {noUsers ? (
+
+ ) : (
+
+ )}
{
@@ -81,38 +83,40 @@ export const AddTeamMemberDialog = ({
maxWidth="sm"
fullWidth
>
-
- (
-
- )}
- />
- (
-
- )}
- />
+
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
)}
/>
- (
-
- )}
- />
- (
-
- )}
- />
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
);
diff --git a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx
index e7c42a1ac..e23f24e53 100644
--- a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx
+++ b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx
@@ -9,6 +9,7 @@ import type { UserRole } from "@/Types/User";
import { useInviteForm } from "@/Hooks/useInviteForm";
import type { InviteFormData } from "@/Validation/invite";
import { usePost } from "@/Hooks/UseApi";
+import { LAYOUT } from "@/Utils/Theme/constants";
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST;
@@ -48,6 +49,7 @@ export const InviteTeamMemberDialog = ({
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
+ { value: "demo", label: t("common.auth.roles.demo") },
];
const handleGenerateToken = async (data: InviteFormData) => {
@@ -94,10 +96,7 @@ export const InviteTeamMemberDialog = ({
}
>
-
+
{
const { t } = useTranslation();
const navigate = useNavigate();
const isAdmin = useIsAdmin();
+ const { pagedRows, paginationProps } = useClientPagination(users);
const headers: Header[] = [
{
@@ -51,10 +54,13 @@ export const TeamTable = ({ users }: TeamTableProps) => {
};
return (
-
+ <>
+
+ {users.length > 0 && }
+ >
);
};
diff --git a/client/src/Pages/Account/index.tsx b/client/src/Pages/Account/index.tsx
index fbe8db64b..ac05cd519 100644
--- a/client/src/Pages/Account/index.tsx
+++ b/client/src/Pages/Account/index.tsx
@@ -25,7 +25,7 @@ const Account = ({ open = "profile" }: AccountProps) => {
}, [open]);
return (
-
+
setActiveTab(newValue)}
diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx
index ec95a0ce3..9de5f1df5 100644
--- a/client/src/Pages/Auth/Login/index.tsx
+++ b/client/src/Pages/Auth/Login/index.tsx
@@ -1,6 +1,7 @@
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { Button, TextField } from "@/Components/inputs";
+import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
@@ -9,7 +10,7 @@ import type { LoginFormData } from "@/Validation/login";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { setAuthState } from "@/Features/Auth/authSlice";
-import { usePost } from "@/Hooks/UseApi";
+import { useLazyGet, usePost } from "@/Hooks/UseApi";
const LoginPage = () => {
const { t } = useTranslation();
@@ -17,6 +18,17 @@ const LoginPage = () => {
const navigate = useNavigate();
const { post, loading } = usePost();
+ const [isCheckingAdmin, setIsCheckingAdmin] = useState(true);
+ const { get } = useLazyGet();
+
+ useEffect(() => {
+ get("/auth/users/superadmin").then((res) => {
+ if (res?.data === false) navigate("/register", { replace: true });
+ else setIsCheckingAdmin(false);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
const { schema, defaults } = useLoginForm();
const { control, handleSubmit } = useForm({
@@ -24,6 +36,8 @@ const LoginPage = () => {
defaultValues: defaults,
});
+ if (isCheckingAdmin) return null;
+
const onSubmit = async (data: LoginFormData) => {
if (loading) return;
@@ -82,12 +96,6 @@ const LoginPage = () => {
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password"
/>
-
);
};
diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx
index a2a439023..92c712bda 100644
--- a/client/src/Pages/Auth/Register/index.tsx
+++ b/client/src/Pages/Auth/Register/index.tsx
@@ -1,16 +1,17 @@
import { BaseAuthPage } from "@/Components/design-elements";
import { Button, TextField } from "@/Components/inputs";
+import Alert from "@mui/material/Alert";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRegisterForm } from "@/Hooks/useRegisterForm";
import type { RegisterFormData } from "@/Validation/register";
import { useTranslation } from "react-i18next";
-import { usePost, useGet } from "@/Hooks/UseApi";
+import { useLazyGet, usePost } from "@/Hooks/UseApi";
import { setAuthState } from "@/Features/Auth/authSlice";
import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import type { AuthResponse } from "@/Types/User";
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
interface RegisterPayload {
user: Omit;
@@ -32,21 +33,23 @@ const RegisterPage = () => {
const { post: verifyToken } = usePost<{ token: string }, InviteVerifyResponse>();
const hasVerified = useRef(false);
- const { data: superAdminExists, isLoading: isCheckingAdmin } = useGet(
- token ? null : "/auth/users/superadmin"
- );
+ const [isCheckingAdmin, setIsCheckingAdmin] = useState(!token);
+ const { get } = useLazyGet();
+
+ useEffect(() => {
+ if (token) return;
+ get("/auth/users/superadmin").then((res) => {
+ if (res?.data === true) navigate("/login", { replace: true });
+ else setIsCheckingAdmin(false);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
const { control, handleSubmit, setError, reset } = useForm({
resolver: zodResolver(schema),
defaultValues: defaults,
});
- useEffect(() => {
- if (superAdminExists === true) {
- navigate("/login", { replace: true });
- }
- }, [superAdminExists, navigate]);
-
useEffect(() => {
if (!token || hasVerified.current) return;
hasVerified.current = true;
@@ -92,6 +95,23 @@ const RegisterPage = () => {
title={t("pages.auth.register.title")}
subtitle={t("pages.auth.register.subtitle")}
>
+ {!token && (
+ ({
+ fontSize: 13,
+ lineHeight: 1.55,
+ color: theme.palette.text.secondary,
+ backgroundColor: theme.palette.action.hover,
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: 1,
+ "& .MuiAlert-message": { padding: 0 },
+ })}
+ >
+ {t("pages.auth.register.setupNotice")}
+
+ )}
state.ui.timezone);
- const getHeaders = (t: Function, uiTimezone: string) => {
+ const getHeaders = () => {
const headers: Header[] = [
{
id: "monitorName",
@@ -63,13 +67,10 @@ export const ChecksTable = ({
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
- const code = row.statusCode;
- if (!code) return "N/A";
- const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative";
return (
-
);
},
@@ -85,7 +86,7 @@ export const ChecksTable = ({
return headers;
};
- const headers = getHeaders(t, uiTimezone);
+ const headers = getHeaders();
const handlePageChange = (
_e: React.MouseEvent | null,
diff --git a/client/src/Pages/Checks/index.tsx b/client/src/Pages/Checks/index.tsx
index 108547f3e..93364af44 100644
--- a/client/src/Pages/Checks/index.tsx
+++ b/client/src/Pages/Checks/index.tsx
@@ -1,6 +1,7 @@
import Stack from "@mui/material/Stack";
import {
BasePage,
+ EmptyState,
TotalChecksBox,
UpChecksBox,
DownChecksBox,
@@ -102,8 +103,19 @@ const Checks = () => {
const downChecks = summaryResponse?.downChecks || 0;
const upChecks = totalChecks - (summaryResponse?.downChecks || 0);
+ const noMonitors = !isLoadingMonitors && (monitorsResponse?.length ?? 0) === 0;
+ if (noMonitors) {
+ return (
+
+ );
+ }
+
return (
-
+
{
"pages.createMonitor.form.type.optionWebSocketDescription"
)}
/>
+
)}
@@ -370,7 +411,20 @@ const CreateMonitorPage = () => {
+ ),
+ }}
+ />
+ }
rightContent={
{/* URL/Host/Container field - not shown for hardware */}
@@ -518,6 +572,74 @@ const CreateMonitorPage = () => {
)}
/>
)}
+ {generalSettingsConfig.showDnsServer && (
+ (
+
+ )}
+ />
+ )}
+ {generalSettingsConfig.showDnsRecordType && (
+ (
+
+
+ {t("pages.createMonitor.form.general.option.dnsRecordType.value.A")}
+
+
+ {t(
+ "pages.createMonitor.form.general.option.dnsRecordType.value.AAAA"
+ )}
+
+
+ {t(
+ "pages.createMonitor.form.general.option.dnsRecordType.value.CNAME"
+ )}
+
+
+ {t(
+ "pages.createMonitor.form.general.option.dnsRecordType.value.MX"
+ )}
+
+
+ {t(
+ "pages.createMonitor.form.general.option.dnsRecordType.value.NS"
+ )}
+
+
+ {t(
+ "pages.createMonitor.form.general.option.dnsRecordType.value.TXT"
+ )}
+
+
+ )}
+ />
+ )}
{
/>
{
+ const theme = useTheme();
+
+ return (
+
+ {children}
+
+ );
+};
+
+const Cell = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
const { t } = useTranslation();
const theme = useTheme();
@@ -28,27 +46,24 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
if (!incident) {
return null;
}
+
return (
-
- {t("pages.incidents.dialog.details.title")}
-
+ {t("pages.incidents.dialog.details.title")}
-
- {t("pages.incidents.dialog.details.overview")}
-
-
-
+ {t("pages.incidents.dialog.details.overview")}
- {t("pages.incidents.dialog.details.status")}
+
+ | {t("pages.incidents.dialog.details.status")} |
+
{
{monitor && (
<>
- {t("pages.incidents.dialog.details.monitor")}
+
+ | {t("pages.incidents.dialog.details.monitor")} |
+
- {monitor.name ?? "N/A"}
+ {monitor.name ?? "N/A"} |
- {t("pages.incidents.dialog.details.url")}
+ {t("pages.incidents.dialog.details.url")} |
- {monitor.url ?? "N/A"}
+ {monitor.url ?? "N/A"} |
>
)}
@@ -78,51 +95,53 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
-
- {t("pages.incidents.dialog.details.analysis")}
-
-
+ {t("pages.incidents.dialog.details.analysis")}
- {t("pages.incidents.dialog.details.timeline")}
+ {t("pages.incidents.dialog.details.timeline")} |
- {t("pages.incidents.dialog.details.detailsLabel")}
+ {t("pages.incidents.dialog.details.detailsLabel")} |
-
+
-
+
- {t("pages.incidents.dialog.details.startedAt")}
+ {t("pages.incidents.dialog.details.startedAt")} |
-
+ |
{formatDateWithTz(incident.startTime, "D MMM YYYY, h:mm A", uiTimezone)}
- |
+
- {t("pages.incidents.dialog.details.statusCode")}
+ {t("pages.incidents.dialog.details.statusCode")} |
- {incident.statusCode ?? "N/A"}
+
+
+ |
- {t("pages.incidents.dialog.details.downtime")}
+ {t("pages.incidents.dialog.details.downtime")} |
- {getIncidentsDuration(incident)}
+ {getIncidentsDuration(incident)} |
- {t("pages.incidents.dialog.details.message")}
+ {t("pages.incidents.dialog.details.message")} |
- {incident.message ?? "N/A"}
+ {incident.message ?? "N/A"} |
@@ -130,60 +149,53 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
{!incident.status && (
-
+
{t("pages.incidents.dialog.details.resolutionDetails")}
-
-
+
- {t("pages.incidents.dialog.details.resolvedAt")}
+ {t("pages.incidents.dialog.details.resolvedAt")} |
-
+ |
{incident.endTime
? formatDateWithTz(incident.endTime, "D MMM YYYY, h:mm A", uiTimezone)
: "N/A"}
- |
+
-
- {t("pages.incidents.dialog.details.resolutionType")}
-
+ {t("pages.incidents.dialog.details.resolutionType")} |
-
+ |
{incident.resolutionType
? t(
`pages.incidents.dialog.details.resolutionTypes.${incident.resolutionType}`
)
: "N/A"}
- |
+
{incident.resolvedBy && (
<>
-
- {t("pages.incidents.dialog.details.resolvedBy")}
-
+ {t("pages.incidents.dialog.details.resolvedBy")} |
-
- {incident.resolvedByEmail ?? incident.resolvedBy}
-
+ {incident.resolvedByEmail ?? incident.resolvedBy} |
>
)}
{incident.comment && (
<>
- {t("pages.incidents.dialog.details.comment")}
+ {t("pages.incidents.dialog.details.comment")} |
- {incident.comment}
+ {incident.comment} |
>
)}
diff --git a/client/src/Pages/Incidents/Components/CardSummary.tsx b/client/src/Pages/Incidents/Components/CardSummary.tsx
index 938455808..60dadaa74 100644
--- a/client/src/Pages/Incidents/Components/CardSummary.tsx
+++ b/client/src/Pages/Incidents/Components/CardSummary.tsx
@@ -1,8 +1,7 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
-import Divider from "@mui/material/Divider";
import Grid from "@mui/material/Grid";
-import { BaseBox, ValueLabel } from "@/Components/design-elements";
+import { BaseBox, Icon, ValueLabel } from "@/Components/design-elements";
import { CircleCheck, TriangleAlert, Bell, Wrench, Globe } from "lucide-react";
import Box from "@mui/material/Box";
@@ -25,6 +24,7 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => {
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(2)}
+ minHeight={32}
>
{
gap={theme.spacing(2)}
>
{icon}
- {label}
+ {label}
{value}
@@ -67,15 +67,11 @@ export const SummaryCard = ({
>
{title}
-
{children}
@@ -97,10 +93,13 @@ export const SummaryCardActiveIncidents = ({
const activeCount = summary.totalActive;
const hasActive = activeCount > 0;
const color = hasActive ? theme.palette.error.main : theme.palette.success.main;
- const icon = hasActive ? (
-
- ) : (
-
+ const icon = (
+
+
+
);
const msg = t("pages.incidents.summaryCard.activeIncidents.active", {
count: activeCount,
@@ -133,10 +132,7 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) =>
container
alignItems="center"
spacing={2}
- sx={{
- width: "100%",
- py: theme.spacing(0.5),
- }}
+ sx={{ width: "100%", minHeight: 32 }}
>
gap: theme.spacing(2),
}}
>
-
+
size={{ xs: 12, md: 6, lg: 4 }}
sx={{
textAlign: { xs: "left", md: "right" },
- fontWeight: 500,
+ fontWeight: 600,
}}
>
{duration}
@@ -190,22 +184,17 @@ export const SummaryCardLatestIncidents = ({
summary,
}: SummaryCardLatestIncidentsProps) => {
const { t } = useTranslation();
- const theme = useTheme();
const latestIncidents = summary?.latestIncidents ?? [];
return (
-
- {latestIncidents.slice(0, 3).map((incident, index) => (
-
-
- {index < latestIncidents.length - 1 && (
-
- )}
-
- ))}
-
+ {latestIncidents.slice(0, 3).map((incident) => (
+
+ ))}
);
};
@@ -224,17 +213,17 @@ export const SummaryCardStats = ({ summary }: SummaryCardStatsProps) => {
return (
}
+ icon={ }
label={t("pages.incidents.summaryCard.incidentStats.totalIncidents")}
value={summary?.total || 0}
/>
}
+ icon={ }
label={t("pages.incidents.summaryCard.incidentStats.mostAffectedMonitor")}
value={mostAffected}
/>
}
+ icon={ }
label={t("pages.incidents.summaryCard.incidentStats.avgResolutionTime")}
value={
summary.total > 0
diff --git a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx
index a0f4eb15a..af7cba105 100644
--- a/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx
+++ b/client/src/Pages/Incidents/Components/ControlsIncidentFilter.tsx
@@ -75,7 +75,7 @@ export const ControlsIncidentFilter = ({
variant="contained"
onClick={onClearFilters}
>
- {t("pages.incidents.filters.clearFilters")}
+ {t("common.buttons.clearFilters")}
)}
diff --git a/client/src/Pages/Incidents/Components/DialogResolution.tsx b/client/src/Pages/Incidents/Components/DialogResolution.tsx
index 99dd898dd..de5a9c4d5 100644
--- a/client/src/Pages/Incidents/Components/DialogResolution.tsx
+++ b/client/src/Pages/Incidents/Components/DialogResolution.tsx
@@ -1,9 +1,7 @@
import { useState, useEffect } from "react";
-import Box from "@mui/material/Box";
import { Dialog, TextField } from "@/Components/inputs";
import { usePut } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
-import { useTheme } from "@mui/material";
interface DialogResolutionProps {
open: boolean;
@@ -19,7 +17,6 @@ export const DialogResolution = ({
onResolved,
}: DialogResolutionProps) => {
const { t } = useTranslation();
- const theme = useTheme();
const [comment, setComment] = useState("");
const { put: resolveIncident, loading: isResolving } = usePut();
@@ -50,22 +47,19 @@ export const DialogResolution = ({
onCancel={handleCancel}
onConfirm={handleConfirm}
confirmColor="error"
- cancelColor="primary"
loading={isResolving}
maxWidth="sm"
fullWidth
>
-
- setComment(e.target.value)}
- fullWidth
- />
-
+ setComment(e.target.value)}
+ fullWidth
+ />
);
};
diff --git a/client/src/Pages/Incidents/Components/IncidentTable.tsx b/client/src/Pages/Incidents/Components/IncidentTable.tsx
index bb1759dcd..399f4d8f8 100644
--- a/client/src/Pages/Incidents/Components/IncidentTable.tsx
+++ b/client/src/Pages/Incidents/Components/IncidentTable.tsx
@@ -1,8 +1,6 @@
-import { Table } from "@/Components/design-elements";
+import { Table, ValueLabel, StatusCodeLabel } from "@/Components/design-elements";
import { Pagination } from "@/Components/design-elements/Table";
import type { Header } from "@/Components/design-elements/Table";
-import { ValueLabel } from "@/Components/design-elements";
-import type { ValueType } from "@/Components/design-elements/StatusLabel";
import { ActionsMenu } from "@/Components/actions-menu";
import type { Incident } from "@/Types/Incident";
import type { Monitor } from "@/Types/Monitor";
@@ -136,13 +134,12 @@ export const IncidentsTable = ({
return (
{row.resolutionType}
@@ -155,15 +152,10 @@ export const IncidentsTable = ({
id: "statusCode",
content: t("pages.incidents.table.headers.statusCode"),
render: (row) => {
- const code = row.statusCode;
- if (!code) return "N/A";
- let value: ValueType = "neutral";
- if (code < 300) value = "positive";
- else if (code >= 400) value = "negative";
return (
-
);
},
diff --git a/client/src/Pages/Incidents/index.tsx b/client/src/Pages/Incidents/index.tsx
index 9003f6956..63cd18889 100644
--- a/client/src/Pages/Incidents/index.tsx
+++ b/client/src/Pages/Incidents/index.tsx
@@ -1,4 +1,4 @@
-import { BasePage } from "@/Components/design-elements";
+import { BasePage, EmptyState } from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
@@ -15,6 +15,7 @@ import { HeaderTimeRange } from "@/Components/common";
import { ControlsIncidentFilter } from "@/Pages/Incidents/Components/ControlsIncidentFilter";
import { useGet } from "@/Hooks/UseApi";
+import { LAYOUT } from "@/Utils/Theme/constants";
import { useState, useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident";
@@ -153,8 +154,20 @@ const IncidentsPage = () => {
refetchSummary();
};
+ const trulyEmpty = summaryData !== undefined && (summaryData?.total ?? 0) === 0;
+
+ if (trulyEmpty) {
+ return (
+
+ );
+ }
+
return (
-
+
{
setSelectedResolutionType={setFilter}
onClearFilters={handleClearFilters}
/>
- {activeIncidentsCount > 0 ? (
+ {activeIncidentsCount > 0 && (
<>
{t("pages.incidents.table.activeIncidents")}
@@ -193,18 +207,12 @@ const IncidentsPage = () => {
/>
>
- ) : (
-
- {t("pages.incidents.summaryCard.activeIncidents.active_zero")}
-
)}
{t("pages.incidents.table.resolvedIncidents")}
diff --git a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx
index 54e3cc1ed..c7ebac5be 100644
--- a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx
+++ b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx
@@ -1,4 +1,6 @@
import Grid from "@mui/material/Grid";
+import Stack from "@mui/material/Stack";
+import { NoticeBanner } from "@/Components/design-elements";
import { HistogramInfrastructure } from "@/Components/monitors";
import { useTranslation } from "react-i18next";
@@ -6,6 +8,7 @@ import type { HardwareCheckStats } from "@/Types/Monitor";
import { useMemo } from "react";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
+import { LAYOUT } from "@/Utils/Theme/constants";
const formatBytesToMB = (value: number) => `${(value / (1024 * 1024)).toFixed(2)} MB`;
@@ -60,6 +63,11 @@ const getChartConfigs = (
return configs;
};
+const hasAnyNetworkTraffic = (checks: HardwareCheckStats[]): boolean =>
+ checks.some((c) =>
+ c.net?.some((iface) => iface.bytesSentPerSecond > 0 || iface.deltaBytesRecv > 0)
+ );
+
export const InfraNetworkCharts = ({
checks,
dateRange,
@@ -74,35 +82,43 @@ export const InfraNetworkCharts = ({
() => getChartConfigs(theme, checks, t),
[theme, checks, t]
);
+ const showNoTrafficNotice = chartConfigs.length > 0 && !hasAnyNetworkTraffic(checks);
return (
-
- {chartConfigs.map((config) => {
- return (
-
-
-
- );
- })}
-
+
+ {showNoTrafficNotice && (
+
+ {t("pages.infrastructure.charts.labels.netNoTraffic")}
+
+ )}
+
+ {chartConfigs.map((config) => {
+ return (
+
+
+
+ );
+ })}
+
+
);
};
diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx
index 994ddcea1..e6458ea34 100644
--- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx
+++ b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx
@@ -1,4 +1,5 @@
import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Table, Pagination, StatusLabel, Gauge } from "@/Components/design-elements";
import type { Header } from "@/Components/design-elements/Table";
@@ -12,6 +13,24 @@ import { useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
+import { Checkbox } from "@/Components/inputs";
+
+interface InfraMonitorsTableProps {
+ monitors: Monitor[];
+ refetch: () => void;
+ setSelectedMonitor: (monitor: Monitor | null) => void;
+ sortField: string;
+ setSortField: (field: string) => void;
+ sortOrder: "asc" | "desc";
+ setSortOrder: (order: "asc" | "desc") => void;
+ count: number;
+ page: number;
+ setPage: (page: number) => void;
+ rowsPerPage: number;
+ setRowsPerPage: (rowsPerPage: number) => void;
+ selectedRows?: string[];
+ onSelectionChange?: (selected: string[]) => void;
+}
export const InfraMonitorsTable = ({
monitors,
@@ -26,29 +45,32 @@ export const InfraMonitorsTable = ({
setPage,
rowsPerPage,
setRowsPerPage,
-}: {
- monitors: Monitor[];
- refetch: Function;
- setSelectedMonitor: Function;
- sortField: string;
- setSortField: (field: string) => void;
- sortOrder: "asc" | "desc";
- setSortOrder: (order: "asc" | "desc") => void;
- count: number;
- page: number;
- setPage: (page: number) => void;
- rowsPerPage: number;
- setRowsPerPage: (rowsPerPage: number) => void;
-}) => {
+ selectedRows = [],
+ onSelectionChange,
+}: InfraMonitorsTableProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
- const {
- post,
- // loading: isPatching,
- // error: postError,
- } = usePost();
+ const { post } = usePost, Monitor>();
+
+ const selectedSet = new Set(selectedRows);
+ const isAllSelected =
+ monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id));
+ const isSomeSelected = selectedRows.length > 0 && !isAllSelected;
+
+ const handleSelectAll = (checked: boolean) => {
+ onSelectionChange?.(checked ? monitors.map((m) => m.id) : []);
+ };
+
+ const handleSelectRow = (id: string, checked: boolean) => {
+ if (!onSelectionChange) return;
+ onSelectionChange(
+ checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id)
+ );
+ };
+
+ const isRowSelected = (id: string) => selectedSet.has(id);
const handlePageChange = (
_e: React.MouseEvent | null,
@@ -65,7 +87,7 @@ export const InfraMonitorsTable = ({
setRowsPerPage(value);
};
- const handleSort = (e: any, field: string) => {
+ const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
@@ -80,14 +102,6 @@ export const InfraMonitorsTable = ({
const getActions = (monitor: Monitor): ActionMenuItem[] => {
return [
- {
- id: 1,
- label: t("pages.common.monitors.actions.openSite"),
- action: () => {
- window.open(monitor.url, "_blank", "noreferrer");
- },
- closeMenu: true,
- },
{
id: 2,
label: t("pages.common.monitors.actions.details"),
@@ -109,13 +123,6 @@ export const InfraMonitorsTable = ({
navigate(`/infrastructure/configure/${monitor.id}`);
},
},
- // {
- // id: 5,
- // label: "Clone",
- // action: () => {
-
- // },
- // },
{
id: 6,
label:
@@ -160,8 +167,35 @@ export const InfraMonitorsTable = ({
);
const headers: Header[] = [
+ {
+ id: "selection",
+ content: (
+ handleSelectAll(e.target.checked)}
+ />
+ ),
+ hideMobileLabel: true,
+ render: (row) => (
+ {
+ handleSelectRow(row.id, e.target.checked);
+ }}
+ onClick={(e) => e.stopPropagation()}
+ />
+ ),
+ onClick: (e) => e?.stopPropagation(),
+ },
{
id: "name",
+ align: "left",
+ mobileLabel: t("common.table.headers.name"),
content: (
handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
+
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
-
+
),
render: (row) => {
return ;
@@ -256,6 +292,11 @@ export const InfraMonitorsTable = ({
onRowClick={(row) => {
navigate(`/infrastructure/${row.id}`);
}}
+ getRowSx={(row) => ({
+ backgroundColor: isRowSelected(row.id)
+ ? theme.palette.action.selected
+ : "inherit",
+ })}
/>
{
const { t } = useTranslation();
@@ -37,6 +43,7 @@ const InfrastructureMonitors = () => {
const [sortField, setSortField] = useState("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState(null);
+
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce(search, 300);
@@ -97,6 +104,15 @@ const InfrastructureMonitors = () => {
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
const isLoading = monitorsWithChecksLoading;
+ // Bulk actions
+ const {
+ selectedRows,
+ setSelectedRows,
+ handleBulkPause,
+ handleBulkResume,
+ handleCancelSelection,
+ } = useBulkMonitorActions(refetch, page);
+
// Check if any filters are active
const hasActiveFilters = Boolean(selectedStatus || selectedState || search);
@@ -122,6 +138,7 @@ const InfrastructureMonitors = () => {
return (
{
}}
/>
+
+ {!isLoading && (
+
+ }
+ onClick={handleBulkResume}
+ >
+ {t("common.buttons.resume")}
+
+ }
+ onClick={handleBulkPause}
+ >
+ {t("common.buttons.pause")}
+
+
+ )}
+
{
);
setPage(0);
}}
+ selectedRows={selectedRows}
+ onSelectionChange={setSelectedRows}
/>
{
const theme = useTheme();
+ const { t } = useTranslation();
const {
data: diagnostics,
isLoading: _isLoading,
@@ -16,9 +19,35 @@ export const TabDiagnostics = () => {
} = useGet("/diagnostic/system", {}, { refreshInterval: 5000 });
return (
-
-
-
+
+
+
+
+ {t("pages.logs.diagnostics.sections.runtimeStats.title")}
+
+
+ {t("pages.logs.diagnostics.sections.runtimeStats.description")}
+
+
+
+
+
+
+
+ {t("pages.logs.diagnostics.sections.memoryCpu.title")}
+
+
+ {t("pages.logs.diagnostics.sections.memoryCpu.description")}
+
+
+
+
);
};
diff --git a/client/src/Pages/Logs/TabLogs.tsx b/client/src/Pages/Logs/TabLogs.tsx
index a05726b42..4bf59e149 100644
--- a/client/src/Pages/Logs/TabLogs.tsx
+++ b/client/src/Pages/Logs/TabLogs.tsx
@@ -4,6 +4,7 @@ import Typography from "@mui/material/Typography";
import { useSelector } from "react-redux";
import { Select } from "@/Components/inputs";
import { TableLogs } from "@/Pages/Logs/components/TableLogs";
+import { EmptyState } from "@/Components/design-elements";
import { useTheme } from "@mui/material";
import { useGet } from "@/Hooks/UseApi";
@@ -15,7 +16,7 @@ import type { RootState } from "@/Types/state";
export const TabLogs = () => {
const theme = useTheme();
- const { data: logs } = useGet("/logs");
+ const { data: logs, isLoading } = useGet("/logs");
const [selectedLogLevel, setSelectedLogLevel] = useState("all");
const [page, setPage] = useState(0);
const rowsPerPage = useSelector(
@@ -33,6 +34,16 @@ export const TabLogs = () => {
const paginatedLogs =
filteredLogs?.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) ?? [];
+ if (!isLoading && (logs?.length ?? 0) === 0) {
+ return (
+
+ );
+ }
+
return (
{
const theme = useTheme();
const { t } = useTranslation();
const {
data: queueData,
- // isLoading,
+ isLoading,
// error,
refetch,
- } = useGet("/queue/all-metrics", {}, { refreshInterval: 5000 });
+ } = useGet("/queue/all-metrics", {}, { refreshInterval: 1000 });
const { post, loading: isFlushing } = usePost();
const jobs = queueData?.jobs ?? [];
const metrics = queueData?.metrics ?? null;
+ const hasNeverRun = !isLoading && metrics !== null && (metrics.totalRuns ?? 0) === 0;
+ const noQueueData = !isLoading && metrics === null && jobs.length === 0;
+ if (hasNeverRun || noQueueData) {
+ return (
+
+ );
+ }
const handleFlushQueue = async () => {
await post("/queue/flush", {});
@@ -30,22 +44,50 @@ export const TabQueue = () => {
};
return (
-
+
-
- {t("pages.logs.jobQueue")}
-
-
-
- {t("pages.logs.failedJobs")}
-
-
+
+
+
+ {t("pages.logs.jobQueue")}
+
+
+
+ ),
+ }}
+ />
+
+
+
+
+
+
+
+ {t("pages.logs.failedJobs")}
+
+
+ {t("pages.logs.failedJobsExplainer")}
+
+
+
+
{
const theme = useTheme();
const { t } = useTranslation();
- if (!diagnostics) {
- return null;
- }
- const heapTotalSize = getPercentage(
- diagnostics?.v8HeapStats?.totalHeapSizeBytes,
- diagnostics?.v8HeapStats?.heapSizeLimitBytes
- );
+ const heapTotalSize = diagnostics
+ ? getPercentage(
+ diagnostics.v8HeapStats?.totalHeapSizeBytes,
+ diagnostics.v8HeapStats?.heapSizeLimitBytes
+ )
+ : 0;
+ const heapUsedSize = diagnostics
+ ? getPercentage(
+ diagnostics.v8HeapStats?.usedHeapSizeBytes,
+ diagnostics.v8HeapStats?.heapSizeLimitBytes
+ )
+ : 0;
+ const actualHeapUsed = diagnostics
+ ? getPercentage(
+ diagnostics.v8HeapStats?.usedHeapSizeBytes,
+ diagnostics.v8HeapStats?.totalHeapSizeBytes
+ )
+ : 0;
+ const cpuUsage = diagnostics?.cpuUsage?.usagePercentage ?? 0;
- const heapUsedSize = getPercentage(
- diagnostics?.v8HeapStats?.usedHeapSizeBytes,
- diagnostics?.v8HeapStats?.heapSizeLimitBytes
- );
-
- const actualHeapUsed = getPercentage(
- diagnostics?.v8HeapStats?.usedHeapSizeBytes,
- diagnostics?.v8HeapStats?.totalHeapSizeBytes
- );
+ const fmt = (n: number) => (diagnostics ? formatPercentageFromWhole(n) : PLACEHOLDER);
+ const bytes = (n: number | undefined) =>
+ diagnostics ? prettyBytes(n ?? 0) : PLACEHOLDER;
return (
- *": { width: "100% !important" },
+ }}
>
-
+
);
};
diff --git a/client/src/Pages/Logs/components/Stats.tsx b/client/src/Pages/Logs/components/Stats.tsx
index 7bf343de3..9fe04fa9d 100644
--- a/client/src/Pages/Logs/components/Stats.tsx
+++ b/client/src/Pages/Logs/components/Stats.tsx
@@ -1,4 +1,4 @@
-import Stack from "@mui/material/Stack";
+import Box from "@mui/material/Box";
import { StatBox } from "@/Components/design-elements";
import prettyBytes from "pretty-bytes";
@@ -11,41 +11,59 @@ interface StatsProps {
diagnostics: Diagnostics | null;
}
+const PLACEHOLDER = "—";
+
export const Stats = ({ diagnostics }: StatsProps) => {
const { t } = useTranslation();
const theme = useTheme();
- if (!diagnostics) {
- return null;
- }
+ const eventLoopDelay = diagnostics
+ ? prettyMilliseconds(diagnostics.eventLoopDelayMs ?? 0, {
+ millisecondsDecimalDigits: 2,
+ })
+ : PLACEHOLDER;
+ const uptime = diagnostics
+ ? prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true })
+ : PLACEHOLDER;
+ const usedHeap = diagnostics
+ ? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)
+ : PLACEHOLDER;
+ const totalHeap = diagnostics
+ ? prettyBytes(diagnostics.v8HeapStats?.totalHeapSizeBytes ?? 0)
+ : PLACEHOLDER;
+ const osMemory = diagnostics
+ ? prettyBytes(diagnostics.osStats?.totalMemoryBytes ?? 0)
+ : PLACEHOLDER;
return (
- *": { width: "100% !important" },
+ }}
>
-
+
);
};
diff --git a/client/src/Pages/Logs/components/TableJobs.tsx b/client/src/Pages/Logs/components/TableJobs.tsx
index 660a5df4f..99e9b6711 100644
--- a/client/src/Pages/Logs/components/TableJobs.tsx
+++ b/client/src/Pages/Logs/components/TableJobs.tsx
@@ -22,66 +22,109 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
id: job.monitorId,
}));
+ const cellSx = {
+ whiteSpace: "nowrap" as const,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ maxWidth: 220,
+ };
+
const headers: Header[] = [
+ {
+ id: "state",
+ content: t("pages.logs.table.headers.state"),
+ render: (row) => {
+ if (row.lockedAt) {
+ return t("common.labels.running");
+ }
+ if (row.monitorActive === true) {
+ return t("common.labels.active");
+ }
+ if (row.monitorActive === false) {
+ return t("common.labels.paused");
+ }
+ return t("common.labels.idle");
+ },
+ },
{
id: "id",
content: t("common.table.headers.monitorId"),
- render: (row) => {row.monitorId} ,
+ render: (row) => (
+
+ {row.monitorId}
+
+ ),
},
{
id: "url",
content: t("common.table.headers.url"),
- render: (row) => {row.monitorUrl} ,
- },
- {
- id: "interval",
- content: t("common.table.headers.interval"),
render: (row) => (
- {prettyMilliseconds(row.monitorInterval ?? 0)}
+
+ {row.monitorUrl}
+
),
},
{
id: "type",
content: t("common.table.headers.type"),
- render: (row) => {row.monitorType} ,
+ render: (row) => {row.monitorType} ,
},
{
- id: "active",
- content: t("common.table.headers.active"),
- render: (row) => {row.active.toString()} ,
- },
- {
- id: "runCount",
- content: t("pages.logs.table.headers.runCount"),
- render: (row) => {row.runCount} ,
- },
- {
- id: "failCount",
- content: t("pages.logs.table.headers.failCount"),
- render: (row) => {row.failCount} ,
+ id: "interval",
+ content: t("common.table.headers.interval"),
+ render: (row) => {
+ let interval;
+ if (row.monitorId.toString().includes("geo")) {
+ interval = row.monitorGeoInterval ?? 0;
+ } else {
+ interval = row.repeat ?? 0;
+ }
+ return {prettyMilliseconds(interval)} ;
+ },
},
{
id: "lastRun",
content: t("pages.logs.table.headers.lastRunAt"),
- render: (row) => {formatTimestamp(row.lastRunAt)} ,
- },
- {
- id: "lockedAt",
- content: t("pages.logs.table.headers.lockedAt"),
- render: (row) => {formatTimestamp(row.lockedAt)} ,
- },
-
- {
- id: "lastFinish",
- content: t("pages.logs.table.headers.lastFinishedAt"),
- render: (row) => {formatTimestamp(row.lastFinishedAt)} ,
+ render: (row) => {
+ const v = formatTimestamp(row.lastRunAt) ?? "-";
+ return (
+
+ {v}
+
+ );
+ },
},
{
id: "lastRunTook",
content: t("pages.logs.table.headers.lastRunTook"),
render: (row) => {
const value = row.lastRunTook ? prettyMilliseconds(row.lastRunTook) : "-";
- return {value} ;
+ return {value} ;
+ },
+ },
+ {
+ id: "lockedAt",
+ content: t("pages.logs.table.headers.lockedAt"),
+ render: (row) => {
+ const v = formatTimestamp(row.lockedAt) ?? "-";
+ return (
+
+ {v}
+
+ );
},
},
];
@@ -92,7 +135,10 @@ export const TableJobs = ({ jobs }: TableJobsProps) => {
data={jobsWithId}
getRowSx={(row) => ({
...(row.lockedAt && {
- "& td": { backgroundColor: theme.palette.success.light },
+ "& td": { backgroundColor: theme.palette.rowStatus.running },
+ }),
+ ...(row.monitorActive === false && {
+ "& td": { backgroundColor: theme.palette.rowStatus.paused },
}),
})}
/>
@@ -106,6 +152,7 @@ interface TableFailedJobsProps {
}
export const TableFailedJobs = ({ metrics }: TableFailedJobsProps) => {
const { t } = useTranslation();
+ const theme = useTheme();
if (!metrics) {
return null;
}
@@ -122,7 +169,11 @@ export const TableFailedJobs = ({ metrics }: TableFailedJobsProps) => {
id: "monitorId",
content: t("common.table.headers.monitorId"),
render: (row) => {
- return {row.monitorId} ;
+ return (
+
+ {row.monitorId}
+
+ );
},
},
{
diff --git a/client/src/Pages/Logs/components/TableLogs.tsx b/client/src/Pages/Logs/components/TableLogs.tsx
index 144d6056c..1244a65aa 100644
--- a/client/src/Pages/Logs/components/TableLogs.tsx
+++ b/client/src/Pages/Logs/components/TableLogs.tsx
@@ -45,6 +45,7 @@ const LevelBadge = ({ level }: { level: LogLevel }) => {
export const TableLogs = ({ logs, logCount, page, setPage }: TableLogsProps) => {
const { t } = useTranslation();
+ const theme = useTheme();
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) => state?.ui?.logs?.rowsPerPage ?? 15
@@ -55,7 +56,7 @@ export const TableLogs = ({ logs, logCount, page, setPage }: TableLogsProps) =>
id: "timestamp",
content: t("pages.logs.table.headers.timestamp"),
render: (row) => (
-
+
{formatTimestamp(row.timestamp)}
),
@@ -74,7 +75,9 @@ export const TableLogs = ({ logs, logCount, page, setPage }: TableLogsProps) =>
id: "method",
content: t("pages.logs.table.headers.method"),
render: (row) => (
- {row.method || "-"}
+
+ {row.method || "-"}
+
),
},
{
diff --git a/client/src/Pages/Logs/index.tsx b/client/src/Pages/Logs/index.tsx
index 467e2f3a7..9d71e3253 100644
--- a/client/src/Pages/Logs/index.tsx
+++ b/client/src/Pages/Logs/index.tsx
@@ -10,7 +10,7 @@ const LogsPage = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(1);
return (
-
+
setActiveTab(newValue)}
diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx
index d130ee7c7..44a4b9a81 100644
--- a/client/src/Pages/Maintenance/create/index.tsx
+++ b/client/src/Pages/Maintenance/create/index.tsx
@@ -85,10 +85,6 @@ const CreateMaintenanceWindowPage = () => {
repeat,
};
- if (repeat === 0) {
- payload.expiry = endDateTime.toISOString();
- }
-
return payload;
};
diff --git a/client/src/Pages/Maintenance/index.tsx b/client/src/Pages/Maintenance/index.tsx
index 6a9c6f12b..abdfb4392 100644
--- a/client/src/Pages/Maintenance/index.tsx
+++ b/client/src/Pages/Maintenance/index.tsx
@@ -32,11 +32,10 @@ const MaintenanceWindowPage = () => {
return (
{
return [
@@ -68,7 +72,23 @@ export const NotificationsTable = ({
id: "destination",
content: t("pages.notifications.table.headers.destination"),
render: (row) => {
- return {row?.address} ;
+ return (
+
+
+ {row?.address}
+
+
+ );
},
},
{
@@ -82,15 +102,16 @@ export const NotificationsTable = ({
return headers;
};
- const headers = getHeaders();
-
return (
- {
- navigate(`/notifications/configure/${row.id}`);
- }}
- />
+ <>
+ {
+ navigate(`/notifications/configure/${row.id}`);
+ }}
+ />
+ {notifications.length > 0 && }
+ >
);
};
diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx
index 85568f8bd..0f6d9b652 100644
--- a/client/src/Pages/Notifications/create/index.tsx
+++ b/client/src/Pages/Notifications/create/index.tsx
@@ -147,30 +147,33 @@ const NotificationsCreatePage = () => {
/>
}
/>
- {watchedType !== "matrix" && watchedType !== "telegram" && (
- (
-
- )}
- />
- }
- />
- )}
+ {watchedType !== "matrix" &&
+ watchedType !== "telegram" &&
+ watchedType !== "pushover" &&
+ watchedType !== "twilio" && (
+ (
+
+ )}
+ />
+ }
+ />
+ )}
{watchedType === "telegram" && (
{
}
/>
)}
+ {watchedType === "pushover" && (
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+ }
+ />
+ )}
+ {watchedType === "twilio" && (
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+ }
+ />
+ )}
{watchedType === "matrix" && (
{
return (
{
return (
{
const checks: CheckSnapshot[] = [];
- for (let i = 0; i < 25; i++) {
+ for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
const isUp = Math.random() > 0.1;
const responseTime = Math.floor(Math.random() * 80) + 20;
checks.push({
diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx
index 302ce9ab2..e14366e0f 100644
--- a/client/src/Pages/Settings/index.tsx
+++ b/client/src/Pages/Settings/index.tsx
@@ -24,6 +24,7 @@ import type { SettingsFormData, SettingsFormInput } from "@/Validation/settings"
import { useState } from "react";
import { Controller } from "react-hook-form";
import { TextField, Button, FieldLabel, SliderWithLabel } from "@/Components/inputs";
+import { languageNames } from "@/Components/inputs/LanguageSelector";
import { Box, Typography } from "@mui/material";
import { useDelete } from "@/Hooks/UseApi";
@@ -127,8 +128,8 @@ export const SettingsPage = () => {
timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null;
const handleTimezoneChange = (newValue: Timezone | null) => {
- const newId = newValue?.id ?? "";
- dispatch(setTimezone({ timezone: newId }));
+ if (!newValue?.id) return;
+ dispatch(setTimezone({ timezone: newValue.id }));
};
const handleModeChange = (e: SelectChangeEvent) => {
@@ -181,6 +182,9 @@ export const SettingsPage = () => {
systemEmailRequireTLS: formValues.systemEmailRequireTLS,
systemEmailRejectUnauthorized: formValues.systemEmailRejectUnauthorized,
...(formValues.systemEmailUser && { systemEmailUser: formValues.systemEmailUser }),
+ ...(formValues.systemEmailDisplayName && {
+ systemEmailDisplayName: formValues.systemEmailDisplayName,
+ }),
...(formValues.systemEmailTLSServername && {
systemEmailTLSServername: formValues.systemEmailTLSServername,
}),
@@ -277,6 +281,7 @@ export const SettingsPage = () => {
return (
@@ -326,7 +331,7 @@ export const SettingsPage = () => {
key={lang}
value={lang}
>
- {lang.toUpperCase()}
+ {languageNames[lang] ?? lang}
))}
@@ -575,44 +580,65 @@ export const SettingsPage = () => {
href="https://nodemailer.com/smtp/"
target="_blank"
/>
-
-
- {JSON.stringify(
- {
- host: form.watch("systemEmailHost") || "",
- port: form.watch("systemEmailPort") || "",
- secure: form.watch("systemEmailSecure") ?? false,
- auth: {
- user:
- form.watch("systemEmailUser") ||
- form.watch("systemEmailAddress") ||
- "",
- pass: "",
- },
- name: form.watch("systemEmailConnectionHost") || "localhost",
- pool: form.watch("systemEmailPool") ?? false,
- tls: {
- rejectUnauthorized:
- form.watch("systemEmailRejectUnauthorized") ?? true,
- ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
- requireTLS: form.watch("systemEmailRequireTLS") ?? false,
- servername: form.watch("systemEmailTLSServername") || "",
- },
- },
- null,
- 2
- )}
-
-
+ {(() => {
+ const address = form.watch("systemEmailAddress") || "";
+ const displayName = form.watch("systemEmailDisplayName")?.trim();
+ return (
+ <>
+
+
+ {JSON.stringify(
+ {
+ host: form.watch("systemEmailHost") || "",
+ port: form.watch("systemEmailPort") || "",
+ secure: form.watch("systemEmailSecure") ?? false,
+ auth: {
+ user: form.watch("systemEmailUser") || address,
+ pass: "",
+ },
+ name: form.watch("systemEmailConnectionHost") || "localhost",
+ pool: form.watch("systemEmailPool") ?? false,
+ tls: {
+ rejectUnauthorized:
+ form.watch("systemEmailRejectUnauthorized") ?? true,
+ ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false,
+ requireTLS: form.watch("systemEmailRequireTLS") ?? false,
+ servername: form.watch("systemEmailTLSServername") || "",
+ },
+ },
+ null,
+ 2
+ )}
+
+
+ {address && (
+
+
+ {`From: ${displayName ? `"${displayName}" <${address}>` : address}`}
+
+
+ )}
+ >
+ );
+ })()}
}
rightContent={
@@ -678,6 +704,24 @@ export const SettingsPage = () => {
)}
/>
+ {/* Email Display Name (Optional) */}
+ (
+
+ )}
+ />
+
{/* Email User (Optional) */}
{
setIsDemoMonitorsDialogOpen(false);
}}
loading={isDeletingAllMonitors}
- />
+ confirmColor="error"
+ confirmText={t("common.buttons.removeMonitors")}
+ >
+
+ {t("pages.settings.form.removeMonitors.dialog.paragraph")}
+
+
- {/* Sticky Save Button */}
diff --git a/client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx b/client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx
new file mode 100644
index 000000000..d68e1d0c1
--- /dev/null
+++ b/client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx
@@ -0,0 +1,66 @@
+import { useTranslation } from "react-i18next";
+import Stack from "@mui/material/Stack";
+import RadioGroup from "@mui/material/RadioGroup";
+import MenuItem from "@mui/material/MenuItem";
+import { useTheme } from "@mui/material/styles";
+import { RadioWithDescription, Select } from "@/Components/inputs";
+import {
+ STATUS_PAGE_THEMES,
+ STATUS_PAGE_THEME_MODES,
+ type StatusPageTheme,
+ type StatusPageThemeMode,
+} from "@/Types/StatusPage";
+
+interface Props {
+ theme: StatusPageTheme;
+ themeMode: StatusPageThemeMode;
+ onThemeChange: (value: StatusPageTheme) => void;
+ onThemeModeChange: (value: StatusPageThemeMode) => void;
+}
+
+export const ThemePicker = ({
+ theme,
+ themeMode,
+ onThemeChange,
+ onThemeModeChange,
+}: Props) => {
+ const { t } = useTranslation();
+ const muiTheme = useTheme();
+
+ return (
+
+ onThemeChange(value as StatusPageTheme)}
+ >
+
+ {STATUS_PAGE_THEMES.map((option) => (
+
+ ))}
+
+
+
+ onThemeModeChange(e.target.value as StatusPageThemeMode)}
+ >
+ {STATUS_PAGE_THEME_MODES.map((mode) => (
+
+ {t(`pages.statusPages.form.themeMode.${mode}`)}
+
+ ))}
+
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx
index f09d9e47d..5ef5095ea 100644
--- a/client/src/Pages/StatusPage/Create/index.tsx
+++ b/client/src/Pages/StatusPage/Create/index.tsx
@@ -32,8 +32,10 @@ import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom";
+import { mutate } from "swr";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
+import { ThemePicker } from "./Components/ThemePicker";
const monitorsUrl = (() => {
const params = new URLSearchParams();
@@ -48,6 +50,9 @@ interface TimezoneOption {
name: string;
}
+const buildStatusPageKey = (slug: string | null | undefined) =>
+ slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
+
const CreateStatusPage = () => {
const theme = useTheme();
const { t } = useTranslation();
@@ -58,9 +63,7 @@ const CreateStatusPage = () => {
// Fetch existing status page data when configuring
const { data: statusPageData, isLoading: isLoadingStatusPage } =
- useGet(
- isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
- );
+ useGet(isCreate ? null : buildStatusPageKey(url));
const { data: monitorsResponse } = useGet(monitorsUrl);
const monitors = monitorsResponse ?? [];
@@ -139,6 +142,8 @@ const CreateStatusPage = () => {
fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
+ if (data.theme) fd.append("theme", data.theme);
+ if (data.themeMode) fd.append("themeMode", data.themeMode);
data.monitors.forEach((monitorId) => {
fd.append("monitors[]", monitorId);
@@ -178,6 +183,10 @@ const CreateStatusPage = () => {
}
if (result) {
+ await mutate(buildStatusPageKey(data.url));
+ if (!isCreate && url !== data.url) {
+ await mutate(buildStatusPageKey(url));
+ }
navigate(`/status/${data.url}`);
}
};
@@ -438,6 +447,30 @@ const CreateStatusPage = () => {
}
/>
+ (
+ (
+
+ )}
+ />
+ )}
+ />
+ }
+ />
{
window.open(
- `/status/public/${statusPage.url}`,
+ `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`,
"_blank",
"noopener,noreferrer"
);
diff --git a/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx b/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx
deleted file mode 100644
index 32a15d64b..000000000
--- a/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-import Stack from "@mui/material/Stack";
-import Typography from "@mui/material/Typography";
-import { Gauge } from "@/Components/design-elements";
-import { useTheme } from "@mui/material/styles";
-import { useTranslation } from "react-i18next";
-import prettyBytes from "pretty-bytes";
-import type { Monitor } from "@/Types/Monitor";
-import Grid from "@mui/material/Grid";
-import useMediaQuery from "@mui/material/useMediaQuery";
-import Box from "@mui/material/Box";
-import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
-
-const GAUGE_RADIUS = 60;
-const GAUGE_STROKE_WIDTH = 12;
-const PERCENTAGE_MULTIPLIER = 100;
-
-interface StatusPageMonitor extends Monitor {
- checks?: Monitor["recentChecks"];
-}
-
-interface MetricDetail {
- label: string;
- value: string;
-}
-
-interface MetricConfig {
- key: string;
- label: string;
- hasData: boolean;
- progress: number;
- details: MetricDetail[];
-}
-
-const MetricDetailRow = ({ label, value }: MetricDetail) => {
- const theme = useTheme();
- return (
-
-
- {label}
-
- {value}
-
- );
-};
-
-interface MetricItemProps {
- label: string;
- progress: number;
- details?: MetricDetail[];
-}
-
-const MetricItem = ({ label, progress, details }: MetricItemProps) => {
- const theme = useTheme();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
- return (
-
-
-
- {label}
-
- {details && details.length > 0 && (
-
- {details.map((detail) => (
-
- ))}
-
- )}
-
- );
-};
-
-type LatestCheck = NonNullable[number];
-
-const buildCpuMetric = (
- check: LatestCheck,
- t: (key: string) => string
-): MetricConfig | null => {
- if (!check.cpu || typeof check.cpu.usage_percent !== "number") {
- return null;
- }
- const usagePercent = (check.cpu.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER;
- return {
- key: "cpu",
- label: t("pages.statusPages.monitorsList.infrastructure.cpu"),
- hasData: true,
- progress: usagePercent,
- details: [
- {
- label: t("pages.statusPages.monitorsList.infrastructure.usage"),
- value: `${usagePercent.toFixed(2)}%`,
- },
- ],
- };
-};
-
-const buildMemoryMetric = (
- check: LatestCheck,
- t: (key: string) => string
-): MetricConfig | null => {
- if (
- !check.memory ||
- typeof check.memory.usage_percent !== "number" ||
- typeof check.memory.used_bytes !== "number" ||
- typeof check.memory.total_bytes !== "number"
- ) {
- return null;
- }
- return {
- key: "memory",
- label: t("pages.statusPages.monitorsList.infrastructure.memory"),
- hasData: true,
- progress: (check.memory.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER,
- details: [
- {
- label: t("pages.statusPages.monitorsList.infrastructure.used"),
- value: prettyBytes(check.memory.used_bytes ?? 0),
- },
- {
- label: t("pages.statusPages.monitorsList.infrastructure.total"),
- value: prettyBytes(check.memory.total_bytes ?? 0),
- },
- ],
- };
-};
-
-const buildDiskMetric = (
- check: LatestCheck,
- t: (key: string) => string
-): MetricConfig | null => {
- if (!check.disk || check.disk.length === 0) {
- return null;
- }
- const disks = check.disk;
- const avgUsagePercent =
- (disks.reduce((acc, disk) => acc + (disk?.usage_percent ?? 0), 0) / disks.length) *
- PERCENTAGE_MULTIPLIER;
- const totalUsedBytes = disks.reduce((acc, disk) => acc + (disk?.used_bytes ?? 0), 0);
- const totalTotalBytes = disks.reduce((acc, disk) => acc + (disk?.total_bytes ?? 0), 0);
-
- return {
- key: "disk",
- label: t("pages.statusPages.monitorsList.infrastructure.disk"),
- hasData: true,
- progress: avgUsagePercent,
- details: [
- {
- label: t("pages.statusPages.monitorsList.infrastructure.used"),
- value: prettyBytes(totalUsedBytes),
- },
- {
- label: t("pages.statusPages.monitorsList.infrastructure.total"),
- value: prettyBytes(totalTotalBytes),
- },
- ],
- };
-};
-
-export const InfrastructureMetrics = ({ monitor }: { monitor: StatusPageMonitor }) => {
- const theme = useTheme();
- const { t } = useTranslation();
-
- const latestCheck = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
-
- if (!latestCheck) {
- return (
-
- {t("pages.statusPages.monitorsList.noData")}
-
- );
- }
-
- const metrics: MetricConfig[] = [
- buildCpuMetric(latestCheck, t),
- buildMemoryMetric(latestCheck, t),
- buildDiskMetric(latestCheck, t),
- ].filter((m): m is MetricConfig => m !== null);
-
- if (metrics.length === 0) {
- return (
-
- {t("pages.statusPages.monitorsList.noData")}
-
- );
- }
-
- return (
-
- {metrics.map(({ key, label, progress, details }) => (
-
- ))}
-
- );
-};
diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx
deleted file mode 100644
index 248a69c9b..000000000
--- a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-import Stack from "@mui/material/Stack";
-import { useTranslation } from "react-i18next";
-import Box from "@mui/material/Box";
-import Typography from "@mui/material/Typography";
-import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common";
-import { StatusLabel, BaseBox } from "@/Components/design-elements";
-import { SwitchComponent } from "@/Components/inputs";
-import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics";
-
-import { useTheme, type Theme } from "@mui/material/styles";
-import { useSelector } from "react-redux";
-import { useState } from "react";
-import type { Monitor } from "@/Types/Monitor";
-import type { StatusPage } from "@/Types/StatusPage";
-import { getMonitorTypeLabel } from "@/Types/StatusPage";
-import type { RootState } from "@/Types/state";
-import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
-
-interface StatusPageMonitor extends Monitor {
- checks?: Monitor["recentChecks"];
-}
-
-interface MonitorsListProps {
- statusPage: StatusPage;
- monitors: StatusPageMonitor[];
-}
-
-const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => {
- const bg =
- monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light;
- return {
- backgroundColor: bg,
- color: theme.palette.background.paper,
- padding: `${theme.spacing(SPACING.SM)} ${theme.spacing(SPACING.LG)}`,
- borderRadius: theme.shape.borderRadius,
- };
-};
-
-const MonitorHeader = ({
- monitor,
- showURL,
-}: {
- monitor: StatusPageMonitor;
- showURL: boolean;
-}) => {
- const theme = useTheme();
- const { t } = useTranslation();
-
- return (
-
-
-
-
- {monitor.name}
-
-
- {getMonitorTypeLabel(monitor.type, t)}
-
-
- {showURL && monitor.url && (
-
- {monitor.url}
-
- )}
-
-
-
- );
-};
-
-const MonitorContent = ({
- monitor,
- statusPage,
- chartType,
-}: {
- monitor: StatusPageMonitor;
- statusPage: StatusPage;
- chartType: string;
-}) => {
- const theme = useTheme();
-
- if (monitor.type === "hardware") {
- if (statusPage.showInfrastructure === false) return null;
- return ;
- }
-
- if (statusPage.showCharts === false) return null;
-
- const checks = monitor.checks?.slice().reverse() ?? [];
- return (
-
- {chartType === "histogram" ? (
-
- ) : (
-
- )}
-
- );
-};
-
-export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
- const theme = useTheme();
- const { t } = useTranslation();
- const showURL = useSelector((state: RootState) => state.ui?.showURL);
- const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram");
-
- return (
-
- {statusPage.showCharts && (
-
- {t("pages.statusPages.monitorsList.chartTypeHeatmap")}
- setChartType(e.target.checked ? "histogram" : "heatmap")}
- />
-
- {t("pages.statusPages.monitorsList.chartTypeHistogram")}
-
-
- )}
-
- {monitors.map((monitor) => (
-
-
-
-
- ))}
-
- );
-};
diff --git a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx b/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx
deleted file mode 100644
index b5c52b32b..000000000
--- a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { AlertTriangle, CircleCheck } from "lucide-react";
-import Stack from "@mui/material/Stack";
-import Typography from "@mui/material/Typography";
-
-import { useTranslation } from "react-i18next";
-import type { Theme } from "@mui/material";
-import { useTheme } from "@mui/material";
-import type { Monitor } from "@/Types/Monitor";
-
-const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => {
- const monitorsStatus: Record = {
- icon: ,
- };
-
- if (monitors.every((monitor) => monitor.status === "up")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.allUp");
- monitorsStatus.color = theme.palette.success.main;
- monitorsStatus.icon = ;
- return monitorsStatus;
- } else if (monitors.every((monitor) => monitor.status === "down")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.allDown");
- monitorsStatus.color = theme.palette.error.main;
- return monitorsStatus;
- } else if (monitors.some((monitor) => monitor.status === "down")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.degraded");
- monitorsStatus.color = theme.palette.warning.main;
- return monitorsStatus;
- } else {
- monitorsStatus.msg = t("pages.statusPages.statusBar.unknown");
- monitorsStatus.color = theme.palette.warning.main;
- return monitorsStatus;
- }
-};
-
-interface StatusBarProps {
- monitors: Monitor[];
-}
-
-export const StatusBar = ({ monitors }: StatusBarProps) => {
- const theme = useTheme();
- const { t } = useTranslation();
- const monitorsStatus = getMonitorStatus(monitors, theme, t);
-
- return (
-
- {monitorsStatus.icon}
- {monitorsStatus.msg}
-
- );
-};
diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx
index 78b7cba41..9c4001fae 100644
--- a/client/src/Pages/StatusPage/Status/index.tsx
+++ b/client/src/Pages/StatusPage/Status/index.tsx
@@ -1,18 +1,64 @@
import { BasePage, BaseFallback } from "@/Components/design-elements";
-import { StatusBar } from "@/Pages/StatusPage/Status/Components/StatusBar";
-import { MonitorsList } from "@/Pages/StatusPage/Status/Components/MonitorsList";
import Typography from "@mui/material/Typography";
import { Link } from "react-router-dom";
import Stack from "@mui/material/Stack";
-import Box from "@mui/material/Box";
-import { useMediaQuery, useTheme } from "@mui/material";
+import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useLocation, useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
-import type { StatusPageResponse } from "@/Types/StatusPage";
-import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls";
+import { resolveStatusPageTheme } from "@/Types/StatusPage";
+import {
+ PUBLIC_STATUS_PAGE_PREFIX,
+ type StatusPageResponse,
+ type StatusPageTheme,
+} from "@/Types/StatusPage";
+import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
+import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
+import {
+ BaseStatusPage,
+ type ThemeConfig,
+} from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
+import { BrowserFrame } from "@/Pages/StatusPage/Status/themes/BrowserFrame";
+import { refinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
+import { RefinedHeader } from "@/Pages/StatusPage/Status/themes/refined/RefinedHeader";
+import { RefinedHero } from "@/Pages/StatusPage/Status/themes/refined/RefinedHero";
+import { modernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
+import { ModernHeader } from "@/Pages/StatusPage/Status/themes/modern/ModernHeader";
+import { ModernHero } from "@/Pages/StatusPage/Status/themes/modern/ModernHero";
+import { boldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
+import { BoldHeader } from "@/Pages/StatusPage/Status/themes/bold/BoldHeader";
+import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero";
+import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
+import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader";
+import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero";
+
+const THEME_CONFIGS: Record> = {
+ refined: {
+ createStyles: refinedStyles,
+ HeaderSlot: RefinedHeader,
+ HeroSlot: RefinedHero,
+ },
+ modern: {
+ createStyles: modernStyles,
+ HeaderSlot: ModernHeader,
+ HeroSlot: ModernHero,
+ overallStatusOptions: { iconSize: 20 },
+ },
+ bold: {
+ createStyles: boldStyles,
+ HeaderSlot: BoldHeader,
+ HeroSlot: BoldHero,
+ overallStatusOptions: { iconSize: 18 },
+ },
+ editorial: {
+ createStyles: editorialStyles,
+ HeaderSlot: EditorialHeader,
+ HeroSlot: EditorialHero,
+ overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" },
+ },
+};
const StatusPageView = () => {
const theme = useTheme();
@@ -20,8 +66,8 @@ const StatusPageView = () => {
const { url } = useParams();
const isAdmin = useIsAdmin();
const location = useLocation();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
- const isPublic = location.pathname.startsWith("/status/public");
+
+ const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
@@ -65,48 +111,50 @@ const StatusPageView = () => {
);
}
- let sx: React.CSSProperties = {};
+ const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
+ const themedRenderer = (
+
+ );
+
+ // Public route: render directly on the viewport, themed background covers everything.
if (isPublic) {
- sx.paddingTop = theme.spacing(20);
- sx.paddingLeft = isSmall ? "5vw" : "20vw";
- sx.paddingRight = isSmall ? "5vw" : "20vw";
+ return (
+
+ {themedRenderer}
+
+ );
}
- const logoSrc = statusPage.logo?.data
- ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
- : null;
-
+ const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
return (
- {logoSrc && (
-
- )}
-
-
+
+ {themedRenderer}
+
);
};
diff --git a/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx
new file mode 100644
index 000000000..b893aa85c
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx
@@ -0,0 +1,70 @@
+import type { ReactNode } from "react";
+import Box from "@mui/material/Box";
+import { useStatusPageTheme } from "./StatusPageThemeProvider";
+
+interface Props {
+ url: string;
+ children: ReactNode;
+}
+
+export const BrowserFrame = ({ url, children }: Props) => {
+ const { tokens } = useStatusPageTheme();
+
+ return (
+
+
+
+
+
+
+
+
+ {url}
+
+
+ {children}
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
new file mode 100644
index 000000000..f79991c3b
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
@@ -0,0 +1,153 @@
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+import {
+ DEFAULT_STATUS_PAGE_THEME,
+ resolveStatusPageTheme,
+ resolveStatusPageThemeMode,
+ type StatusPageTheme,
+ type StatusPageThemeMode,
+} from "@/Types/StatusPage";
+import { resolveTimezone } from "@/Utils/TimeUtils";
+import { themeTokens, type StatusPageThemeTokens } from "./tokens";
+
+type ResolvedMode = "light" | "dark";
+
+interface StatusPageThemeContextValue {
+ theme: StatusPageTheme;
+ mode: ResolvedMode;
+ tokens: StatusPageThemeTokens;
+ timezone: string;
+}
+
+const StatusPageThemeContext = createContext(null);
+
+const resolveSystemMode = (): ResolvedMode => {
+ if (typeof window === "undefined" || !window.matchMedia) return "light";
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+};
+
+interface Props {
+ theme?: StatusPageTheme;
+ themeMode?: StatusPageThemeMode;
+ timezone?: string;
+ /**
+ * When true, paint the document body with the theme background so it
+ * covers beyond the provider's wrapper (useful on the public route
+ * where no admin shell sits behind). Defaults to false for admin
+ * previews that already have an app background.
+ */
+ paintBody?: boolean;
+ /**
+ * When true, the provider's wrapper div does NOT paint its own
+ * background or set a min-height. Use this when the wrapper is
+ * inside a clipped container (e.g. BrowserFrame) that already sets
+ * the visible background, so the wrapper's bg doesn't bleed past the
+ * container's rounded corners.
+ */
+ transparent?: boolean;
+ children: ReactNode;
+}
+
+export const StatusPageThemeProvider = ({
+ theme,
+ themeMode,
+ timezone,
+ paintBody = false,
+ transparent = false,
+ children,
+}: Props) => {
+ const resolvedTheme = resolveStatusPageTheme(theme);
+ const resolvedThemeMode = resolveStatusPageThemeMode(themeMode);
+ const resolvedTimezone = resolveTimezone(timezone);
+
+ const [systemMode, setSystemMode] = useState(resolveSystemMode);
+
+ useEffect(() => {
+ if (resolvedThemeMode !== "auto") return;
+ if (typeof window === "undefined" || !window.matchMedia) return;
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
+ // Reconcile on first paint whenever we (re-)enter auto mode — only
+ // when it disagrees with the stored value, so we don't trigger an
+ // extra render on the common hot path.
+ const next: ResolvedMode = mq.matches ? "dark" : "light";
+ setSystemMode((prev) => (prev === next ? prev : next));
+ const handler = (e: MediaQueryListEvent) =>
+ setSystemMode(e.matches ? "dark" : "light");
+ mq.addEventListener("change", handler);
+ return () => mq.removeEventListener("change", handler);
+ }, [resolvedThemeMode]);
+
+ useEffect(() => {
+ if (!paintBody || typeof document === "undefined") return;
+ const html = document.documentElement;
+ const body = document.body;
+ const prev = {
+ htmlBg: html.style.background,
+ bodyBg: body.style.background,
+ bodyMargin: body.style.margin,
+ htmlColorScheme: html.style.colorScheme,
+ };
+ const resolvedMode = resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
+ const bg = themeTokens[resolvedTheme][resolvedMode].bg;
+ html.style.background = bg;
+ body.style.background = bg;
+ body.style.margin = "0";
+ html.style.colorScheme = resolvedMode;
+ return () => {
+ html.style.background = prev.htmlBg;
+ body.style.background = prev.bodyBg;
+ body.style.margin = prev.bodyMargin;
+ html.style.colorScheme = prev.htmlColorScheme;
+ };
+ }, [paintBody, resolvedTheme, resolvedThemeMode, systemMode]);
+
+ const resolvedMode: ResolvedMode =
+ resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
+ const tokens = themeTokens[resolvedTheme][resolvedMode];
+
+ const value = useMemo(
+ () => ({
+ theme: resolvedTheme,
+ mode: resolvedMode,
+ tokens,
+ timezone: resolvedTimezone,
+ }),
+ [resolvedTheme, resolvedMode, tokens, resolvedTimezone]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export const useStatusPageTheme = (): StatusPageThemeContextValue => {
+ const ctx = useContext(StatusPageThemeContext);
+ if (!ctx) {
+ return {
+ theme: DEFAULT_STATUS_PAGE_THEME,
+ mode: resolveSystemMode(),
+ tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()],
+ timezone: resolveTimezone(),
+ };
+ }
+ return ctx;
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/bold/BoldHeader.tsx b/client/src/Pages/StatusPage/Status/themes/bold/BoldHeader.tsx
new file mode 100644
index 000000000..7b483b474
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/bold/BoldHeader.tsx
@@ -0,0 +1,20 @@
+import Box from "@mui/material/Box";
+import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
+import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
+import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
+
+export const BoldHeader = ({ statusPage, logoSrc, styles }: SlotProps) => (
+
+ {logoSrc ? (
+
+ ) : (
+ {monoFirstChar(statusPage.companyName)}
+ )}
+ {statusPage.companyName}
+
+);
diff --git a/client/src/Pages/StatusPage/Status/themes/bold/BoldHero.tsx b/client/src/Pages/StatusPage/Status/themes/bold/BoldHero.tsx
new file mode 100644
index 000000000..3d378ddaf
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/bold/BoldHero.tsx
@@ -0,0 +1,32 @@
+import Box from "@mui/material/Box";
+import { useTranslation } from "react-i18next";
+import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
+import type { BoldStyles } from "@/Pages/StatusPage/Status/themes/bold/styles";
+
+export const BoldHero = ({ overall, monitorCount, styles }: SlotProps) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {overall.icon}
+
+ {overall.message}
+
+
+ {t("pages.statusPages.statusBar.monitoringSummary", {
+ count: monitorCount,
+ })}
+
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts
new file mode 100644
index 000000000..9153ab994
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts
@@ -0,0 +1,405 @@
+import type { SxProps, Theme } from "@mui/material/styles";
+import type { StatusPageThemeTokens } from "../tokens";
+import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
+import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
+import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
+
+export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
+export type BoldBarKind = "up" | "down" | "empty";
+export type BoldGaugeFill = "ok" | "warm" | "hot";
+
+export interface BoldStyles {
+ page: SxProps;
+ top: SxProps;
+ brand: SxProps;
+ logoConic: SxProps;
+ logoImg: SxProps;
+ hero: SxProps;
+ heroTitle: SxProps;
+ heroCheck: (tone: OverallTone) => SxProps;
+ heroSub: SxProps;
+ chartSwitchWrap: SxProps;
+ chartSwitch: SxProps;
+ chartSwitchButton: (active: boolean) => SxProps;
+ monitorList: SxProps;
+ card: SxProps;
+ cardRow: SxProps;
+ cardLeft: SxProps;
+ monitorName: SxProps;
+ monitorMeta: SxProps;
+ pill: SxProps;
+ pillHardware: SxProps;
+ monitorUrl: SxProps;
+ badge: (tone: OverallTone) => SxProps;
+ heatmap: SxProps;
+ heatmapCell: (kind: BoldHeatCell) => SxProps;
+ histogram: SxProps;
+ bar: (kind: BoldBarKind, heightPct: number) => SxProps;
+ chartStats: SxProps;
+ infra: SxProps;
+ infraEmpty: SxProps;
+ gauge: SxProps;
+ gaugeLabel: SxProps;
+ gaugeValue: SxProps;
+ gaugeBar: SxProps;
+ gaugeFill: (level: BoldGaugeFill, widthPct: number) => SxProps;
+ gaugeSub: SxProps;
+ footer: SxProps;
+}
+
+export const boldStyles = (
+ tokens: StatusPageThemeTokens,
+ isDark: boolean
+): BoldStyles => {
+ const heatCellBg: Record = {
+ fast: tokens.up,
+ med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`,
+ slow: tokens.warn,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+ const heatCellShadow: Record = {
+ fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
+ med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
+ slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`,
+ down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`,
+ empty: "none",
+ };
+
+ const barBg: Record = {
+ up: tokens.up,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+
+ const gaugeFillBg: Record = {
+ ok: `linear-gradient(90deg, ${tokens.up}, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%))`,
+ warm: `linear-gradient(90deg, ${tokens.warn}, #fbbf24)`,
+ hot: `linear-gradient(90deg, ${tokens.down}, #fb7185)`,
+ };
+
+ const heroBgLight = `linear-gradient(135deg, color-mix(in srgb, ${tokens.up} 8%, transparent), transparent 60%), ${tokens.surface}`;
+ const heroBgDark = `linear-gradient(135deg, color-mix(in srgb, ${tokens.up} 16%, transparent), transparent 60%), ${tokens.surface}`;
+
+ const heroGlowLight = `radial-gradient(400px 200px at 20% 30%, color-mix(in srgb, ${tokens.up} 14%, transparent), transparent 60%), radial-gradient(300px 200px at 90% 80%, color-mix(in srgb, ${tokens.upStrong} 10%, transparent), transparent 60%)`;
+ const heroGlowDark = `radial-gradient(400px 200px at 20% 30%, color-mix(in srgb, ${tokens.up} 32%, transparent), transparent 60%), radial-gradient(300px 200px at 90% 80%, color-mix(in srgb, ${tokens.upStrong} 22%, transparent), transparent 60%)`;
+
+ return {
+ page: {
+ flex: "1 0 auto",
+ maxWidth: 1040,
+ width: "100%",
+ mx: "auto",
+ p: "56px 24px 96px",
+ fontFamily: BOLD_SANS_STACK,
+ fontSize: 14,
+ lineHeight: 1.55,
+ color: tokens.text,
+ WebkitFontSmoothing: "antialiased",
+ },
+
+ top: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ mb: "36px",
+ },
+ brand: {
+ display: "flex",
+ alignItems: "center",
+ gap: "14px",
+ fontWeight: 700,
+ fontSize: 16,
+ letterSpacing: "-0.01em",
+ color: tokens.text,
+ },
+ logoConic: {
+ width: 36,
+ height: 36,
+ borderRadius: "12px",
+ background: `conic-gradient(from 140deg, ${tokens.up}, ${tokens.upStrong}, #0f766e, ${tokens.up})`,
+ display: "grid",
+ placeItems: "center",
+ color: "#05070a",
+ fontWeight: 900,
+ fontSize: 14,
+ boxShadow: `0 8px 24px color-mix(in srgb, ${tokens.up} 30%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.2)`,
+ },
+ logoImg: { maxHeight: 36, maxWidth: 140, objectFit: "contain" },
+
+ hero: {
+ position: "relative",
+ borderRadius: "22px",
+ padding: "36px 36px 32px",
+ mb: "28px",
+ background: isDark ? heroBgDark : heroBgLight,
+ border: `1px solid ${tokens.border}`,
+ overflow: "hidden",
+ "&::before": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ background: isDark ? heroGlowDark : heroGlowLight,
+ pointerEvents: "none",
+ opacity: 0.8,
+ zIndex: 0,
+ ...(isDark ? { mixBlendMode: "screen" } : {}),
+ },
+ "& > *": { position: "relative", zIndex: 1 },
+ },
+ heroTitle: {
+ m: 0,
+ fontSize: 34,
+ fontWeight: 800,
+ letterSpacing: "-0.02em",
+ lineHeight: 1.1,
+ color: tokens.text,
+ display: "flex",
+ alignItems: "center",
+ gap: "10px",
+ },
+ heroCheck: (tone) => ({
+ display: "inline-grid",
+ placeItems: "center",
+ width: 32,
+ height: 32,
+ borderRadius: "50%",
+ background: toneColor(tone, tokens),
+ color: tone === "down" ? "#fff" : "#05070a",
+ flexShrink: 0,
+ boxShadow: `0 0 0 6px ${toneSoft(tone, tokens)}`,
+ }),
+ heroSub: { mt: "10px", mb: 0, color: tokens.textMuted, fontSize: 14 },
+
+ chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "16px" },
+ chartSwitch: {
+ display: "inline-flex",
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "999px",
+ background: tokens.surface,
+ p: "3px",
+ gap: "2px",
+ },
+ chartSwitchButton: (active) => ({
+ border: 0,
+ background: active ? tokens.upSoft : "transparent",
+ fontFamily: "inherit",
+ fontSize: 11,
+ padding: "8px 18px",
+ cursor: "pointer",
+ color: active ? tokens.up : tokens.textMuted,
+ fontWeight: 700,
+ textTransform: "uppercase",
+ letterSpacing: "0.12em",
+ transition: "background 0.2s, color 0.2s",
+ borderRadius: "999px",
+ "&:hover": { color: active ? tokens.up : tokens.text },
+ }),
+
+ monitorList: {
+ listStyle: "none",
+ m: 0,
+ p: 0,
+ display: "flex",
+ flexDirection: "column",
+ gap: "14px",
+ },
+ card: {
+ background: tokens.surface,
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "18px",
+ overflow: "hidden",
+ transition: "transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), border-color 0.2s",
+ "&:hover": {
+ transform: "translateY(-3px)",
+ borderColor: `color-mix(in srgb, ${tokens.up} 40%, ${tokens.border})`,
+ },
+ },
+ cardRow: {
+ display: "grid",
+ gridTemplateColumns: "1fr auto",
+ alignItems: "center",
+ gap: "24px",
+ p: "22px 28px",
+ },
+ cardLeft: { minWidth: 0 },
+ monitorName: {
+ fontWeight: 700,
+ fontSize: 17,
+ letterSpacing: "-0.01em",
+ color: tokens.text,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ },
+ monitorMeta: {
+ display: "flex",
+ gap: "10px",
+ alignItems: "center",
+ mt: "6px",
+ flexWrap: "wrap",
+ },
+ pill: {
+ fontSize: 10,
+ textTransform: "uppercase",
+ letterSpacing: "0.1em",
+ color: tokens.textMuted,
+ background: `color-mix(in srgb, ${tokens.surface} 80%, ${tokens.bg})`,
+ border: `1px solid ${tokens.border}`,
+ padding: "3px 10px",
+ borderRadius: "999px",
+ fontWeight: 700,
+ },
+ pillHardware: {
+ fontSize: 10,
+ textTransform: "uppercase",
+ letterSpacing: "0.1em",
+ padding: "3px 10px",
+ borderRadius: "999px",
+ fontWeight: 700,
+ color: tokens.up,
+ borderColor: `color-mix(in srgb, ${tokens.up} 40%, transparent)`,
+ border: `1px solid color-mix(in srgb, ${tokens.up} 40%, transparent)`,
+ background: tokens.upSoft,
+ },
+ monitorUrl: {
+ fontSize: 12,
+ color: tokens.textMuted,
+ fontFamily: MONO_STACK,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ maxWidth: 300,
+ },
+
+ badge: (tone) => ({
+ fontSize: 11,
+ fontWeight: 800,
+ padding: "6px 14px",
+ borderRadius: "999px",
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "7px",
+ textTransform: "uppercase",
+ letterSpacing: "0.08em",
+ whiteSpace: "nowrap",
+ background: toneSoft(tone, tokens),
+ color: toneColor(tone, tokens),
+ "&::before": {
+ content: '""',
+ width: 6,
+ height: 6,
+ borderRadius: "50%",
+ background: "currentColor",
+ boxShadow: "0 0 8px currentColor",
+ },
+ }),
+
+ heatmap: {
+ padding: "0 28px 22px",
+ display: "grid",
+ gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
+ gap: "3px",
+ height: 48,
+ },
+ heatmapCell: (kind) => ({
+ borderRadius: "3px",
+ background: heatCellBg[kind],
+ boxShadow: heatCellShadow[kind],
+ opacity: kind === "empty" ? 0.4 : 1,
+ transition: "transform 0.15s",
+ "&:hover": { transform: "scaleY(1.2)" },
+ }),
+
+ histogram: {
+ padding: "0 28px",
+ display: "grid",
+ gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
+ gap: "3px",
+ alignItems: "flex-end",
+ height: 48,
+ },
+ bar: (kind, heightPct) => ({
+ background: barBg[kind],
+ borderRadius: "3px",
+ minHeight: 4,
+ opacity: kind === "empty" ? 0.4 : 1,
+ height: `${heightPct}%`,
+ }),
+ chartStats: {
+ padding: "0 28px 22px",
+ fontSize: 11,
+ color: tokens.textMuted,
+ fontVariantNumeric: "tabular-nums",
+ fontWeight: 700,
+ letterSpacing: "0.05em",
+ },
+
+ infra: {
+ padding: "18px 28px 26px",
+ display: "grid",
+ gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
+ gap: "16px",
+ },
+ infraEmpty: {
+ padding: "18px 28px 26px",
+ color: tokens.textMuted,
+ fontSize: 13,
+ },
+ gauge: {
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "16px",
+ p: "16px 18px",
+ background: `color-mix(in srgb, ${tokens.surface} 80%, ${tokens.bg})`,
+ },
+ gaugeLabel: {
+ fontSize: 10,
+ color: tokens.textMuted,
+ textTransform: "uppercase",
+ letterSpacing: "0.12em",
+ fontWeight: 700,
+ },
+ gaugeValue: {
+ fontSize: 30,
+ fontWeight: 800,
+ letterSpacing: "-0.02em",
+ mt: "6px",
+ fontVariantNumeric: "tabular-nums",
+ color: tokens.text,
+ },
+ gaugeBar: {
+ height: 6,
+ background: tokens.border,
+ borderRadius: "3px",
+ overflow: "hidden",
+ mt: "12px",
+ },
+ gaugeFill: (level, widthPct) => ({
+ display: "block",
+ height: "100%",
+ background: gaugeFillBg[level],
+ borderRadius: "3px",
+ transition: "width 0.9s cubic-bezier(0.2, 0.8, 0.2, 1)",
+ width: `${Math.max(0, Math.min(100, widthPct))}%`,
+ }),
+ gaugeSub: {
+ fontSize: 11,
+ color: tokens.textMuted,
+ mt: "10px",
+ fontVariantNumeric: "tabular-nums",
+ },
+
+ footer: {
+ textAlign: "center",
+ color: tokens.textMuted,
+ fontSize: 12,
+ mt: "52px",
+ "& a": {
+ color: tokens.up,
+ textDecoration: "underline",
+ textUnderlineOffset: "3px",
+ fontWeight: 800,
+ },
+ },
+ };
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHeader.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHeader.tsx
new file mode 100644
index 000000000..56a2a9af2
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHeader.tsx
@@ -0,0 +1,36 @@
+import Box from "@mui/material/Box";
+import { useTranslation } from "react-i18next";
+import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
+import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
+
+export const EditorialHeader = ({
+ statusPage,
+ logoSrc,
+ styles,
+}: SlotProps) => {
+ const { t } = useTranslation();
+ return (
+
+ {logoSrc && (
+
+ )}
+
+ {statusPage.companyName}
+
+
+ {t("pages.statusPages.editorial.reportTitle")}
+
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHero.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHero.tsx
new file mode 100644
index 000000000..cd6a06406
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialHero.tsx
@@ -0,0 +1,46 @@
+import { useMemo } from "react";
+import Box from "@mui/material/Box";
+import { useTranslation } from "react-i18next";
+import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
+import type { EditorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
+
+export const EditorialHero = ({
+ overall,
+ monitorCount,
+ styles,
+}: SlotProps) => {
+ const { t } = useTranslation();
+ const todayLabel = useMemo(
+ () =>
+ new Date().toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }),
+ []
+ );
+
+ return (
+ <>
+
+
+ {overall.message}
+
+
+ {todayLabel} ·{" "}
+ {t("pages.statusPages.statusBar.monitoringSummary", {
+ count: monitorCount,
+ })}
+
+ >
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
new file mode 100644
index 000000000..57128180e
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
@@ -0,0 +1,341 @@
+import type { SxProps, Theme } from "@mui/material/styles";
+import type { StatusPageThemeTokens } from "../tokens";
+import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
+import {
+ EDITORIAL_SECONDARY_SANS_STACK,
+ MONO_STACK,
+ SERIF_STACK,
+} from "../shared/fontStacks";
+import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
+
+export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
+export type EditorialBarKind = "up" | "down" | "empty";
+export type EditorialGaugeFill = "ok" | "warm" | "hot";
+
+export interface EditorialStyles {
+ page: SxProps;
+ top: SxProps;
+ brandWrap: SxProps;
+ logoImg: SxProps;
+ brandEyebrow: SxProps;
+ brandTitle: SxProps;
+ statusLine: SxProps;
+ statusDot: (tone: OverallTone) => SxProps;
+ dateline: SxProps;
+ chartSwitchWrap: SxProps;
+ chartSwitch: SxProps;
+ chartSwitchButton: (active: boolean) => SxProps;
+ monitorList: SxProps;
+ card: SxProps;
+ cardRow: SxProps;
+ cardLeft: SxProps;
+ monitorName: SxProps;
+ monitorMeta: SxProps;
+ pill: SxProps;
+ pillHardware: SxProps;
+ monitorUrl: SxProps;
+ badge: (tone: OverallTone) => SxProps;
+ heatmap: SxProps;
+ heatmapCell: (kind: EditorialHeatCell) => SxProps;
+ histogram: SxProps;
+ bar: (kind: EditorialBarKind, heightPct: number) => SxProps;
+ chartStats: SxProps;
+ infra: SxProps;
+ infraEmpty: SxProps;
+ gauge: SxProps;
+ gaugeLabel: SxProps;
+ gaugeValue: SxProps;
+ gaugeBar: SxProps;
+ gaugeFill: (level: EditorialGaugeFill, widthPct: number) => SxProps;
+ gaugeSub: SxProps;
+ footer: SxProps;
+}
+
+export const editorialStyles = (
+ tokens: StatusPageThemeTokens,
+ _isDark: boolean
+): EditorialStyles => {
+ const heatCellBg: Record = {
+ fast: tokens.up,
+ med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
+ slow: tokens.warn,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+
+ const barBg: Record = {
+ up: tokens.up,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+
+ const gaugeFillBg: Record = {
+ ok: tokens.up,
+ warm: tokens.warn,
+ hot: tokens.down,
+ };
+
+ return {
+ page: {
+ flex: "1 0 auto",
+ maxWidth: 760,
+ width: "100%",
+ mx: "auto",
+ p: "64px 24px 96px",
+ fontFamily: SERIF_STACK,
+ fontSize: 15,
+ lineHeight: 1.6,
+ color: tokens.text,
+ WebkitFontSmoothing: "antialiased",
+ },
+
+ top: {
+ display: "flex",
+ alignItems: "flex-end",
+ justifyContent: "space-between",
+ mb: "48px",
+ borderBottom: `2px solid ${tokens.text}`,
+ pb: "18px",
+ },
+ brandWrap: { display: "flex", flexDirection: "column" },
+ logoImg: { maxHeight: 48, maxWidth: 160, objectFit: "contain", mb: "8px" },
+ brandEyebrow: {
+ m: 0,
+ fontSize: 11,
+ textTransform: "uppercase",
+ letterSpacing: "0.3em",
+ fontWeight: 700,
+ color: tokens.textMuted,
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
+ },
+ brandTitle: {
+ mt: "6px",
+ mb: 0,
+ fontSize: 40,
+ fontWeight: 700,
+ letterSpacing: "-0.02em",
+ lineHeight: 1,
+ color: tokens.text,
+ },
+
+ statusLine: {
+ fontSize: 22,
+ fontWeight: 400,
+ m: 0,
+ mb: "8px",
+ letterSpacing: "-0.01em",
+ lineHeight: 1.35,
+ color: tokens.text,
+ fontFamily: SERIF_STACK,
+ },
+ statusDot: (tone) => ({
+ display: "inline-block",
+ width: 10,
+ height: 10,
+ borderRadius: "50%",
+ background: toneColor(tone, tokens),
+ mr: "10px",
+ verticalAlign: "middle",
+ }),
+ dateline: {
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
+ fontSize: 11,
+ textTransform: "uppercase",
+ letterSpacing: "0.12em",
+ color: tokens.textMuted,
+ m: 0,
+ mb: "56px",
+ },
+
+ chartSwitchWrap: {
+ display: "flex",
+ justifyContent: "flex-end",
+ mb: "20px",
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
+ },
+ chartSwitch: {
+ display: "inline-flex",
+ border: `1px solid ${tokens.text}`,
+ "& > button + button": { borderLeft: `1px solid ${tokens.text}` },
+ },
+ chartSwitchButton: (active) => ({
+ fontFamily: "inherit",
+ fontSize: 10,
+ textTransform: "uppercase",
+ letterSpacing: "0.2em",
+ padding: "6px 14px",
+ border: 0,
+ background: active ? tokens.text : "transparent",
+ color: active ? tokens.bg : tokens.text,
+ cursor: "pointer",
+ fontWeight: 700,
+ }),
+
+ monitorList: {
+ listStyle: "none",
+ m: 0,
+ p: 0,
+ display: "flex",
+ flexDirection: "column",
+ },
+ card: {
+ background: "transparent",
+ border: 0,
+ borderBottom: `1px solid ${tokens.border}`,
+ mb: 0,
+ py: "20px",
+ "&:last-of-type": { borderBottom: 0 },
+ },
+ cardRow: {
+ display: "grid",
+ gridTemplateColumns: "1fr auto",
+ alignItems: "baseline",
+ gap: "20px",
+ },
+ cardLeft: { minWidth: 0 },
+ monitorName: {
+ fontSize: 20,
+ fontWeight: 700,
+ letterSpacing: "-0.015em",
+ color: tokens.text,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ },
+ monitorMeta: {
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
+ fontSize: 12,
+ color: tokens.textMuted,
+ mt: "6px",
+ display: "flex",
+ gap: "12px",
+ flexWrap: "wrap",
+ alignItems: "center",
+ },
+ pill: {
+ textTransform: "uppercase",
+ letterSpacing: "0.12em",
+ fontWeight: 700,
+ color: tokens.textMuted,
+ },
+ pillHardware: {
+ textTransform: "uppercase",
+ letterSpacing: "0.12em",
+ fontWeight: 700,
+ color: tokens.up,
+ },
+ monitorUrl: {
+ fontFamily: MONO_STACK,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ maxWidth: 280,
+ fontSize: 12,
+ },
+
+ badge: (tone) => ({
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
+ fontSize: 10,
+ fontWeight: 700,
+ padding: "4px 10px",
+ borderRadius: 0,
+ textTransform: "uppercase",
+ letterSpacing: "0.15em",
+ whiteSpace: "nowrap",
+ background: toneSoft(tone, tokens),
+ color: toneColor(tone, tokens),
+ }),
+
+ heatmap: {
+ mt: "16px",
+ display: "grid",
+ gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
+ gap: "2px",
+ height: 40,
+ },
+ heatmapCell: (kind) => ({
+ background: heatCellBg[kind],
+ opacity: kind === "empty" ? 0.5 : 1,
+ }),
+
+ histogram: {
+ mt: "16px",
+ display: "grid",
+ gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
+ gap: "2px",
+ alignItems: "flex-end",
+ height: 40,
+ },
+ bar: (kind, heightPct) => ({
+ background: barBg[kind],
+ minHeight: 3,
+ opacity: kind === "empty" ? 0.5 : 1,
+ height: `${heightPct}%`,
+ }),
+ chartStats: {
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
+ fontSize: 11,
+ color: tokens.textMuted,
+ fontVariantNumeric: "tabular-nums",
+ textTransform: "uppercase",
+ letterSpacing: "0.1em",
+ },
+
+ infra: {
+ display: "grid",
+ gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
+ gap: "24px",
+ mt: "20px",
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
+ },
+ infraEmpty: { mt: "20px", color: tokens.textMuted, fontSize: 13 },
+ gauge: {},
+ gaugeLabel: {
+ fontSize: 10,
+ color: tokens.textMuted,
+ textTransform: "uppercase",
+ letterSpacing: "0.18em",
+ fontWeight: 700,
+ },
+ gaugeValue: {
+ fontSize: 28,
+ fontWeight: 700,
+ letterSpacing: "-0.02em",
+ mt: "4px",
+ fontVariantNumeric: "tabular-nums",
+ fontFamily: SERIF_STACK,
+ color: tokens.text,
+ },
+ gaugeBar: { height: 2, background: tokens.border, mt: "10px" },
+ gaugeFill: (level, widthPct) => ({
+ display: "block",
+ height: "100%",
+ background: gaugeFillBg[level],
+ transition: "width 0.8s",
+ width: `${Math.max(0, Math.min(100, widthPct))}%`,
+ }),
+ gaugeSub: {
+ fontSize: 11,
+ color: tokens.textMuted,
+ mt: "8px",
+ fontVariantNumeric: "tabular-nums",
+ },
+
+ footer: {
+ textAlign: "center",
+ color: tokens.textMuted,
+ fontSize: 11,
+ mt: "56px",
+ textTransform: "uppercase",
+ letterSpacing: "0.2em",
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
+ "& a": {
+ color: tokens.text,
+ textDecoration: "none",
+ borderBottom: `1px solid ${tokens.text}`,
+ pb: "2px",
+ fontWeight: 700,
+ },
+ },
+ };
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernHeader.tsx b/client/src/Pages/StatusPage/Status/themes/modern/ModernHeader.tsx
new file mode 100644
index 000000000..c7f01bc4e
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/modern/ModernHeader.tsx
@@ -0,0 +1,24 @@
+import Box from "@mui/material/Box";
+import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
+import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
+import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
+
+export const ModernHeader = ({
+ statusPage,
+ logoSrc,
+ styles,
+}: SlotProps) => (
+
+ {logoSrc ? (
+
+ ) : (
+ {monoFirstChar(statusPage.companyName)}
+ )}
+ {statusPage.companyName}
+
+);
diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx b/client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx
new file mode 100644
index 000000000..a7cf93bc7
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/modern/ModernHero.tsx
@@ -0,0 +1,34 @@
+import Box from "@mui/material/Box";
+import { useTranslation } from "react-i18next";
+import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
+import type { ModernStyles } from "@/Pages/StatusPage/Status/themes/modern/styles";
+
+export const ModernHero = ({
+ overall,
+ monitorCount,
+ styles,
+}: SlotProps) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+ {overall.message}
+
+
+ {t("pages.statusPages.statusBar.monitoringSummary", {
+ count: monitorCount,
+ })}
+
+
+ {overall.icon}
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts
new file mode 100644
index 000000000..1ee3af741
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts
@@ -0,0 +1,429 @@
+import type { SxProps, Theme } from "@mui/material/styles";
+import { keyframes } from "@mui/system";
+import type { StatusPageThemeTokens } from "../tokens";
+import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
+import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
+import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
+
+export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty";
+export type ModernBarKind = "up" | "down" | "empty";
+export type ModernGaugeFill = "ok" | "warm" | "hot";
+
+export interface ModernStyles {
+ page: SxProps;
+ top: SxProps;
+ brand: SxProps;
+ logoGrad: SxProps;
+ logoImg: SxProps;
+ hero: SxProps;
+ pulse: (tone: OverallTone) => SxProps;
+ statusCopy: SxProps;
+ heroTitle: SxProps;
+ heroSub: SxProps;
+ heroIcon: (tone: OverallTone) => SxProps;
+ chartSwitchWrap: SxProps;
+ chartSwitch: SxProps;
+ chartSwitchButton: (active: boolean) => SxProps;
+ monitorList: SxProps;
+ card: SxProps;
+ cardRow: SxProps;
+ cardLeft: SxProps;
+ monitorName: SxProps;
+ monitorMeta: SxProps;
+ pill: SxProps;
+ pillHardware: SxProps;
+ monitorUrl: SxProps;
+ badge: (tone: OverallTone) => SxProps;
+ heatmap: SxProps;
+ heatmapCell: (kind: ModernHeatCell) => SxProps;
+ histogram: SxProps;
+ bar: (kind: ModernBarKind, heightPct: number) => SxProps;
+ chartStats: SxProps;
+ infra: SxProps;
+ infraEmpty: SxProps;
+ gauge: SxProps;
+ gaugeLabel: SxProps;
+ gaugeValue: SxProps;
+ gaugeBar: SxProps;
+ gaugeFill: (level: ModernGaugeFill, widthPct: number) => SxProps;
+ gaugeSub: SxProps;
+ footer: SxProps;
+}
+
+const lightShadow =
+ "0 2px 4px rgba(16, 24, 40, 0.04), 0 12px 32px rgba(16, 24, 40, 0.06)";
+const darkShadow = "0 2px 6px rgba(0, 0, 0, 0.5), 0 20px 40px rgba(0, 0, 0, 0.4)";
+const lightShadowHover =
+ "0 4px 8px rgba(16, 24, 40, 0.05), 0 16px 40px rgba(16, 24, 40, 0.08)";
+const darkShadowHover = "0 4px 10px rgba(0, 0, 0, 0.55), 0 24px 48px rgba(0, 0, 0, 0.45)";
+
+const pillBase = {
+ fontSize: 10,
+ textTransform: "uppercase" as const,
+ letterSpacing: "0.08em",
+ padding: "3px 9px",
+ borderRadius: "999px",
+ fontWeight: 600,
+};
+
+const pulseKeyframes = keyframes`
+ 0% { transform: scale(1); opacity: 0.5; }
+ 100% { transform: scale(2.4); opacity: 0; }
+`;
+
+const fadeInKeyframes = keyframes`
+ to { opacity: 1; transform: translateY(0); }
+`;
+
+export const modernStyles = (
+ tokens: StatusPageThemeTokens,
+ isDark: boolean
+): ModernStyles => {
+ const cardShadow = isDark ? darkShadow : lightShadow;
+ const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
+
+ const heatCellBg: Record = {
+ fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
+ med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
+ slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
+ down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
+ empty: tokens.border,
+ };
+
+ const barBg: Record = {
+ up: tokens.up,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+
+ const gaugeFillBg: Record = {
+ ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`,
+ warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`,
+ hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
+ };
+
+ return {
+ page: {
+ flex: "1 0 auto",
+ maxWidth: 980,
+ width: "100%",
+ mx: "auto",
+ p: "56px 20px 80px",
+ fontFamily: SANS_STACK,
+ fontSize: 14,
+ lineHeight: 1.5,
+ color: tokens.text,
+ WebkitFontSmoothing: "antialiased",
+ },
+
+ top: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ mb: "32px",
+ },
+ brand: {
+ display: "flex",
+ alignItems: "center",
+ gap: "12px",
+ fontWeight: 700,
+ fontSize: 15,
+ letterSpacing: "-0.01em",
+ color: tokens.text,
+ },
+ logoGrad: {
+ width: 32,
+ height: 32,
+ borderRadius: "10px",
+ background: `linear-gradient(135deg, ${tokens.up}, ${tokens.upStrong})`,
+ display: "grid",
+ placeItems: "center",
+ color: "#fff",
+ fontWeight: 700,
+ fontSize: 14,
+ boxShadow: `0 4px 10px color-mix(in srgb, ${tokens.up} 40%, transparent)`,
+ },
+ logoImg: { maxHeight: 32, maxWidth: 120, objectFit: "contain" },
+
+ hero: {
+ position: "relative",
+ borderRadius: "20px",
+ padding: "28px 32px",
+ mb: "24px",
+ display: "flex",
+ alignItems: "center",
+ gap: "20px",
+ background: tokens.surface,
+ border: `1px solid ${tokens.border}`,
+ boxShadow: cardShadow,
+ },
+ pulse: (tone) => {
+ const c = toneColor(tone, tokens);
+ return {
+ position: "relative",
+ width: 14,
+ height: 14,
+ borderRadius: "50%",
+ background: c,
+ color: c,
+ flexShrink: 0,
+ "&::before, &::after": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ borderRadius: "50%",
+ background: "currentColor",
+ opacity: 0.5,
+ animation: `${pulseKeyframes} 2s ease-out infinite`,
+ },
+ "&::after": { animationDelay: "1s" },
+ "@media (prefers-reduced-motion: reduce)": {
+ "&::before, &::after": { animation: "none" },
+ },
+ };
+ },
+ statusCopy: { flex: 1, minWidth: 0 },
+ heroTitle: {
+ m: 0,
+ mb: "4px",
+ fontSize: 22,
+ fontWeight: 700,
+ letterSpacing: "-0.02em",
+ color: tokens.text,
+ },
+ heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
+ heroIcon: (tone) => ({
+ color: toneColor(tone, tokens),
+ display: "flex",
+ alignItems: "center",
+ }),
+
+ chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "14px" },
+ chartSwitch: {
+ display: "inline-flex",
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "999px",
+ background: tokens.surface,
+ p: "3px",
+ gap: "2px",
+ },
+ chartSwitchButton: (active) => ({
+ border: 0,
+ background: active ? tokens.upSoft : "transparent",
+ fontFamily: "inherit",
+ fontSize: 11,
+ padding: "6px 16px",
+ cursor: "pointer",
+ color: active ? tokens.up : tokens.textMuted,
+ fontWeight: 600,
+ textTransform: "uppercase",
+ letterSpacing: "0.08em",
+ transition: "background 0.2s, color 0.2s",
+ borderRadius: "999px",
+ "&:hover": { color: active ? tokens.up : tokens.text },
+ }),
+
+ monitorList: {
+ listStyle: "none",
+ m: 0,
+ p: 0,
+ display: "flex",
+ flexDirection: "column",
+ gap: "14px",
+ },
+ card: {
+ background: tokens.surface,
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "16px",
+ boxShadow: cardShadow,
+ overflow: "hidden",
+ transition: "transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.2s",
+ opacity: 0,
+ transform: "translateY(8px)",
+ animation: `${fadeInKeyframes} 0.5s forwards`,
+ "&:nth-of-type(1)": { animationDelay: "0.05s" },
+ "&:nth-of-type(2)": { animationDelay: "0.1s" },
+ "&:nth-of-type(3)": { animationDelay: "0.15s" },
+ "&:nth-of-type(4)": { animationDelay: "0.2s" },
+ "&:nth-of-type(5)": { animationDelay: "0.25s" },
+ "&:nth-of-type(n+6)": { animationDelay: "0.3s" },
+ "&:hover": { transform: "translateY(-2px)", boxShadow: cardHoverShadow },
+ "@media (prefers-reduced-motion: reduce)": {
+ animation: "none",
+ opacity: 1,
+ transform: "none",
+ },
+ },
+ cardRow: {
+ display: "grid",
+ gridTemplateColumns: "1fr auto",
+ alignItems: "center",
+ gap: "20px",
+ p: "18px 24px",
+ },
+ cardLeft: { minWidth: 0 },
+ monitorName: {
+ fontWeight: 600,
+ fontSize: 15,
+ letterSpacing: "-0.005em",
+ color: tokens.text,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ },
+ monitorMeta: {
+ display: "flex",
+ gap: "10px",
+ alignItems: "center",
+ mt: "6px",
+ flexWrap: "wrap",
+ },
+ pill: {
+ ...pillBase,
+ color: tokens.textMuted,
+ background: tokens.bg,
+ },
+ pillHardware: {
+ ...pillBase,
+ color: tokens.up,
+ background: tokens.upSoft,
+ },
+ monitorUrl: {
+ fontSize: 12,
+ color: tokens.textMuted,
+ fontFamily: MONO_STACK,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ maxWidth: 280,
+ },
+
+ badge: (tone) => ({
+ fontSize: 11,
+ fontWeight: 700,
+ padding: "5px 12px",
+ borderRadius: "999px",
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "6px",
+ whiteSpace: "nowrap",
+ background: toneSoft(tone, tokens),
+ color: toneColor(tone, tokens),
+ "&::before": {
+ content: '""',
+ width: 6,
+ height: 6,
+ borderRadius: "50%",
+ background: "currentColor",
+ },
+ }),
+
+ heatmap: {
+ padding: "0 24px 20px",
+ display: "grid",
+ gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
+ gap: "3px",
+ height: 46,
+ },
+ heatmapCell: (kind) => ({
+ borderRadius: "3px",
+ background: heatCellBg[kind],
+ opacity: kind === "empty" ? 0.4 : 1,
+ transition: "transform 0.15s",
+ "&:hover": { transform: "scaleY(1.2)" },
+ }),
+
+ histogram: {
+ padding: "0 24px",
+ display: "grid",
+ gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
+ gap: "3px",
+ alignItems: "flex-end",
+ height: 46,
+ },
+ bar: (kind, heightPct) => ({
+ background: barBg[kind],
+ borderRadius: "3px",
+ minHeight: 4,
+ opacity: kind === "empty" ? 0.4 : 1,
+ height: `${heightPct}%`,
+ transition: "transform 0.15s",
+ "&:hover": { transform: "scaleY(1.08)" },
+ }),
+ chartStats: {
+ padding: "0 24px 20px",
+ fontSize: 11,
+ color: tokens.textMuted,
+ fontVariantNumeric: "tabular-nums",
+ fontWeight: 600,
+ },
+
+ infra: {
+ padding: "16px 24px 22px",
+ display: "grid",
+ gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
+ gap: "14px",
+ },
+ infraEmpty: {
+ padding: "16px 24px 22px",
+ color: tokens.textMuted,
+ fontSize: 13,
+ },
+ gauge: {
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "14px",
+ p: "14px 16px",
+ background: `linear-gradient(135deg, ${tokens.bg}, ${tokens.surface})`,
+ },
+ gaugeLabel: {
+ fontSize: 10,
+ color: tokens.textMuted,
+ textTransform: "uppercase",
+ letterSpacing: "0.1em",
+ fontWeight: 700,
+ },
+ gaugeValue: {
+ fontSize: 26,
+ fontWeight: 700,
+ letterSpacing: "-0.02em",
+ mt: "4px",
+ fontVariantNumeric: "tabular-nums",
+ color: tokens.text,
+ },
+ gaugeBar: {
+ height: 6,
+ background: tokens.border,
+ borderRadius: "3px",
+ overflow: "hidden",
+ mt: "10px",
+ },
+ gaugeFill: (level, widthPct) => ({
+ display: "block",
+ height: "100%",
+ background: gaugeFillBg[level],
+ borderRadius: "3px",
+ transition: "width 0.8s cubic-bezier(0.2, 0.8, 0.2, 1)",
+ width: `${Math.max(0, Math.min(100, widthPct))}%`,
+ }),
+ gaugeSub: {
+ fontSize: 11,
+ color: tokens.textMuted,
+ mt: "8px",
+ fontVariantNumeric: "tabular-nums",
+ },
+
+ footer: {
+ textAlign: "center",
+ color: tokens.textMuted,
+ fontSize: 12,
+ mt: "48px",
+ "& a": {
+ color: tokens.up,
+ textDecoration: "underline",
+ textUnderlineOffset: "3px",
+ fontWeight: 700,
+ "&:hover": { color: tokens.upStrong },
+ },
+ },
+ };
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedHeader.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHeader.tsx
new file mode 100644
index 000000000..43e164f71
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHeader.tsx
@@ -0,0 +1,29 @@
+import Box from "@mui/material/Box";
+import { monoFirstChar } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
+import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
+import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
+
+export const RefinedHeader = ({
+ statusPage,
+ logoSrc,
+ styles,
+}: SlotProps) => (
+
+ {logoSrc ? (
+
+ ) : (
+ {monoFirstChar(statusPage.companyName)}
+ )}
+
+ {statusPage.companyName}
+
+
+);
diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx
new file mode 100644
index 000000000..c9a59ad77
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/refined/RefinedHero.tsx
@@ -0,0 +1,34 @@
+import Box from "@mui/material/Box";
+import { useTranslation } from "react-i18next";
+import type { SlotProps } from "@/Pages/StatusPage/Status/themes/shared/BaseStatusPage";
+import type { RefinedStyles } from "@/Pages/StatusPage/Status/themes/refined/styles";
+
+export const RefinedHero = ({
+ overall,
+ monitorCount,
+ styles,
+}: SlotProps) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+ {overall.message}
+
+
+ {t("pages.statusPages.statusBar.monitoringSummary", {
+ count: monitorCount,
+ })}
+
+
+ {overall.icon}
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts
new file mode 100644
index 000000000..a6a52c021
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts
@@ -0,0 +1,368 @@
+import type { SxProps, Theme } from "@mui/material/styles";
+import type { StatusPageThemeTokens } from "../tokens";
+import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
+import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
+
+export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
+export type RefinedBarKind = "up" | "down" | "empty";
+export type RefinedGaugeFill = "ok" | "warm" | "hot";
+import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
+
+export interface RefinedStyles {
+ page: SxProps;
+ top: SxProps;
+ brand: SxProps;
+ logoMono: SxProps;
+ logoImg: SxProps;
+ company: SxProps;
+ hero: SxProps;
+ statusDot: (tone: OverallTone) => SxProps;
+ statusCopy: SxProps;
+ heroTitle: SxProps;
+ heroSub: SxProps;
+ heroIcon: (tone: OverallTone) => SxProps;
+ chartSwitchWrap: SxProps;
+ chartSwitch: SxProps;
+ chartSwitchButton: (active: boolean) => SxProps;
+ monitorList: SxProps;
+ card: SxProps;
+ cardRow: SxProps;
+ cardLeft: SxProps;
+ monitorName: SxProps;
+ monitorMeta: SxProps;
+ pill: SxProps;
+ pillHardware: SxProps;
+ monitorUrl: SxProps;
+ badge: (tone: OverallTone) => SxProps;
+ heatmap: SxProps;
+ heatmapCell: (kind: RefinedHeatCell) => SxProps;
+ histogram: SxProps;
+ bar: (kind: RefinedBarKind, heightPct: number) => SxProps;
+ chartStats: SxProps;
+ infra: SxProps;
+ infraEmpty: SxProps;
+ gauge: SxProps;
+ gaugeLabel: SxProps;
+ gaugeValue: SxProps;
+ gaugeBar: SxProps;
+ gaugeFill: (level: RefinedGaugeFill, widthPct: number) => SxProps;
+ gaugeSub: SxProps;
+ footer: SxProps;
+}
+
+const cardShadow = "0 1px 2px rgba(10, 16, 32, 0.06), 0 6px 18px rgba(10, 16, 32, 0.08)";
+const cardShadowHover =
+ "0 2px 4px rgba(16, 24, 40, 0.06), 0 10px 24px rgba(16, 24, 40, 0.06)";
+
+const pillBase = {
+ fontSize: 10,
+ textTransform: "uppercase" as const,
+ letterSpacing: "0.08em",
+ padding: "2px 8px",
+ borderRadius: "999px",
+ fontWeight: 600,
+};
+
+export const refinedStyles = (
+ tokens: StatusPageThemeTokens,
+ _isDark: boolean
+): RefinedStyles => {
+ const heatCellBg: Record = {
+ fast: tokens.up,
+ med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
+ slow: tokens.warn,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+
+ const barBg: Record = {
+ up: tokens.up,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+
+ const gaugeFillBg: Record = {
+ ok: tokens.up,
+ warm: tokens.warn,
+ hot: tokens.down,
+ };
+
+ return {
+ page: {
+ flex: "1 0 auto",
+ maxWidth: 960,
+ width: "100%",
+ mx: "auto",
+ p: "48px 20px 80px",
+ fontFamily: SANS_STACK,
+ fontSize: 14,
+ lineHeight: 1.5,
+ color: tokens.text,
+ WebkitFontSmoothing: "antialiased",
+ },
+
+ top: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ mb: "28px",
+ },
+ brand: {
+ display: "flex",
+ alignItems: "center",
+ gap: "10px",
+ fontWeight: 600,
+ letterSpacing: "-0.01em",
+ color: tokens.text,
+ },
+ logoMono: {
+ width: 28,
+ height: 28,
+ borderRadius: "8px",
+ background: tokens.up,
+ display: "grid",
+ placeItems: "center",
+ color: "#fff",
+ fontWeight: 700,
+ fontSize: 13,
+ },
+ logoImg: { maxHeight: 32, maxWidth: 120, objectFit: "contain" },
+ company: { fontSize: 14 },
+
+ hero: {
+ background: tokens.surface,
+ border: `1px solid ${tokens.border}`,
+ borderRadius: tokens.radius,
+ padding: "22px 24px",
+ display: "flex",
+ alignItems: "center",
+ gap: "16px",
+ boxShadow: cardShadow,
+ mb: "20px",
+ },
+ statusDot: (tone) => ({
+ width: 10,
+ height: 10,
+ borderRadius: "50%",
+ background: toneColor(tone, tokens),
+ boxShadow: `0 0 0 4px ${toneSoft(tone, tokens)}`,
+ flexShrink: 0,
+ }),
+ statusCopy: { flex: 1, minWidth: 0 },
+ heroTitle: {
+ m: 0,
+ mb: "2px",
+ fontSize: 17,
+ fontWeight: 600,
+ letterSpacing: "-0.01em",
+ color: tokens.text,
+ },
+ heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
+ heroIcon: (tone) => ({
+ color: toneColor(tone, tokens),
+ display: "flex",
+ alignItems: "center",
+ }),
+
+ chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "12px" },
+ chartSwitch: {
+ display: "inline-flex",
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "8px",
+ background: tokens.surface,
+ p: "3px",
+ gap: "2px",
+ },
+ chartSwitchButton: (active) => ({
+ border: 0,
+ background: active ? tokens.upSoft : "transparent",
+ fontFamily: "inherit",
+ fontSize: 11,
+ padding: "5px 14px",
+ cursor: "pointer",
+ color: active ? tokens.up : tokens.textMuted,
+ borderRadius: "5px",
+ transition: "background 0.15s ease, color 0.15s ease",
+ fontWeight: active ? 600 : 500,
+ "&:hover": { color: active ? tokens.up : tokens.text },
+ }),
+
+ monitorList: {
+ listStyle: "none",
+ m: 0,
+ p: 0,
+ display: "flex",
+ flexDirection: "column",
+ gap: "12px",
+ },
+ card: {
+ background: tokens.surface,
+ border: `1px solid ${tokens.border}`,
+ borderRadius: tokens.radius,
+ boxShadow: cardShadow,
+ overflow: "hidden",
+ position: "relative",
+ transition: "transform 0.15s, box-shadow 0.15s",
+ "&:hover": { transform: "translateY(-1px)", boxShadow: cardShadowHover },
+ },
+ cardRow: {
+ display: "grid",
+ gridTemplateColumns: "1fr auto",
+ alignItems: "center",
+ gap: "16px",
+ p: "16px 20px",
+ },
+ cardLeft: { minWidth: 0 },
+ monitorName: {
+ fontWeight: 600,
+ fontSize: 14,
+ letterSpacing: "-0.005em",
+ color: tokens.text,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ },
+ monitorMeta: {
+ display: "flex",
+ gap: "10px",
+ alignItems: "center",
+ mt: "4px",
+ flexWrap: "wrap",
+ },
+ pill: {
+ ...pillBase,
+ color: tokens.textMuted,
+ border: `1px solid ${tokens.border}`,
+ },
+ pillHardware: {
+ ...pillBase,
+ color: tokens.up,
+ border: `1px solid ${tokens.border}`,
+ background: tokens.upSoft,
+ },
+ monitorUrl: {
+ fontSize: 12,
+ color: tokens.textMuted,
+ fontFamily: MONO_STACK,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ maxWidth: 280,
+ },
+
+ badge: (tone) => ({
+ fontSize: 11,
+ fontWeight: 600,
+ padding: "4px 10px",
+ borderRadius: "999px",
+ whiteSpace: "nowrap",
+ background: toneSoft(tone, tokens),
+ color: toneColor(tone, tokens),
+ }),
+
+ heatmap: {
+ padding: "0 20px 16px",
+ display: "grid",
+ gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
+ gap: "3px",
+ height: 42,
+ },
+ heatmapCell: (kind) => ({
+ borderRadius: "2px",
+ background: heatCellBg[kind],
+ opacity: kind === "empty" ? 0.4 : 1,
+ transition: "transform 0.15s",
+ "&:hover": { transform: "scaleY(1.15)" },
+ }),
+
+ histogram: {
+ padding: "0 20px",
+ display: "grid",
+ gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
+ gap: "3px",
+ alignItems: "flex-end",
+ height: 42,
+ },
+ bar: (kind, heightPct) => ({
+ background: barBg[kind],
+ borderRadius: "2px",
+ minHeight: 3,
+ opacity: kind === "empty" ? 0.4 : 1,
+ height: `${heightPct}%`,
+ }),
+ chartStats: {
+ padding: "0 20px 16px",
+ fontSize: 11,
+ color: tokens.textMuted,
+ fontVariantNumeric: "tabular-nums",
+ },
+
+ infra: {
+ padding: "14px 20px 18px",
+ display: "grid",
+ gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
+ gap: "12px",
+ },
+ infraEmpty: {
+ padding: "14px 20px 18px",
+ color: tokens.textMuted,
+ fontSize: 13,
+ },
+ gauge: {
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "10px",
+ p: "12px 14px",
+ background: tokens.bg,
+ },
+ gaugeLabel: {
+ fontSize: 11,
+ color: tokens.textMuted,
+ textTransform: "uppercase",
+ letterSpacing: "0.08em",
+ fontWeight: 600,
+ },
+ gaugeValue: {
+ fontSize: 20,
+ fontWeight: 600,
+ letterSpacing: "-0.01em",
+ mt: "2px",
+ fontVariantNumeric: "tabular-nums",
+ color: tokens.text,
+ },
+ gaugeBar: {
+ height: 4,
+ background: tokens.border,
+ borderRadius: "2px",
+ overflow: "hidden",
+ mt: "8px",
+ },
+ gaugeFill: (level, widthPct) => ({
+ display: "block",
+ height: "100%",
+ background: gaugeFillBg[level],
+ borderRadius: "2px",
+ transition: "width 0.6s",
+ width: `${Math.max(0, Math.min(100, widthPct))}%`,
+ }),
+ gaugeSub: {
+ fontSize: 11,
+ color: tokens.textMuted,
+ mt: "6px",
+ fontVariantNumeric: "tabular-nums",
+ },
+
+ footer: {
+ textAlign: "center",
+ color: tokens.textMuted,
+ fontSize: 12,
+ mt: "40px",
+ "& a": {
+ color: tokens.up,
+ textDecoration: "underline",
+ textUnderlineOffset: "3px",
+ fontWeight: 600,
+ "&:hover": { color: tokens.upStrong || tokens.up },
+ },
+ },
+ };
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx
new file mode 100644
index 000000000..71aa85a89
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx
@@ -0,0 +1,250 @@
+import { useMemo, useState } from "react";
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import { useTranslation } from "react-i18next";
+import type { SxProps, Theme } from "@mui/material/styles";
+import type { Monitor } from "@/Types/Monitor";
+import type { StatusPage } from "@/Types/StatusPage";
+import { getMonitorTypeLabel } from "@/Types/StatusPage";
+import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens";
+import {
+ ThemedHeatmap,
+ type HeatCellKind,
+} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap";
+import {
+ ThemedHistogram,
+ type BarKind,
+} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram";
+import {
+ ThemedInfrastructure,
+ type GaugeFillLevel,
+} from "@/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure";
+import {
+ type OverallStatus,
+ type OverallTone,
+ monitorBadgeTone,
+ resolveOverallStatus,
+ statusBadgeKey,
+} from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
+import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
+
+type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
+
+export interface BaseStyles {
+ page: SxProps;
+ top: SxProps;
+ chartSwitchWrap: SxProps;
+ chartSwitch: SxProps;
+ chartSwitchButton: (active: boolean) => SxProps;
+ monitorList: SxProps;
+ card: SxProps;
+ cardRow: SxProps;
+ cardLeft: SxProps;
+ monitorName: SxProps;
+ monitorMeta: SxProps;
+ pill: SxProps;
+ pillHardware: SxProps;
+ monitorUrl: SxProps;
+ badge: (tone: OverallTone) => SxProps;
+ heatmap: SxProps;
+ heatmapCell: (kind: HeatCellKind) => SxProps;
+ histogram: SxProps;
+ bar: (kind: BarKind, heightPct: number) => SxProps;
+ chartStats: SxProps;
+ infra: SxProps;
+ infraEmpty: SxProps;
+ gauge: SxProps;
+ gaugeLabel: SxProps