Compare commits

..
548 changed files with 12335 additions and 21070 deletions

No files matched your search

-6
View File
@@ -1,6 +0,0 @@
**/node_modules
**/dist
**/.env
**/*.env
.git
docker/dev/mongo/data
-103
View File
@@ -1,103 +0,0 @@
name: Build image
on:
push:
branches: ["develop", "master"]
tags: ["v*"]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# checkmate-backend-mono is a transitional alias so existing deployments
# keep receiving updates; drop it after one release cycle.
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository_owner }}/checkmate
ghcr.io/${{ github.repository_owner }}/checkmate-backend-mono
flavor: |
latest=false
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Build and push multi-arch image
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# The hosted staging/prod instances share one host and one compose file; each
# deploy pulls and recreates only its own service.
deploy-staging:
needs: build-and-push
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
steps:
- name: Deploy staging.checkmate.so
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.DEMO_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate
git pull
cd docs/hosting/checkmate-so
docker compose pull checkmate-staging
docker compose up -d checkmate-staging
docker system prune -af
deploy-prod:
needs: build-and-push
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- name: Deploy demo.checkmate.so
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.DEMO_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate
git pull
cd docs/hosting/checkmate-so
docker compose pull checkmate-demo
docker compose up -d checkmate-demo
docker system prune -af
@@ -0,0 +1,148 @@
name: Deploy images on release
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
docker-build-and-push-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version from tag
id: extract_tag
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Client Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-client:${{ steps.extract_tag.outputs.version }} \
-f ./docker/dist/client.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Client Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-client:${{ steps.extract_tag.outputs.version }}
docker-build-and-push-server:
needs: docker-build-and-push-client
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract version
id: extract_tag
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-backend:${{ steps.extract_tag.outputs.version }} \
-f ./docker/dist/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-backend:${{ steps.extract_tag.outputs.version }}
- name: Build Mongo Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-mongo:${{ steps.extract_tag.outputs.version }} \
-f ./docker/dist/mongoDB.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push MongoDB Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-mongo:${{ steps.extract_tag.outputs.version }}
docker-build-and-push-server-mono-multiarch:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version
id: extract_tag
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/dist-arm/server.Dockerfile
push: true
no-cache: true
tags: |
ghcr.io/bluewave-labs/checkmate-backend-mono-multiarch:${{ steps.extract_tag.outputs.version }}
platforms: linux/amd64,linux/arm64
labels: |
org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate
build-args: |
VITE_APP_VERSION=${{ steps.extract_tag.outputs.version }}
docker-build-and-push-server-mono:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version
id: extract_tag
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-backend-mono:${{ steps.extract_tag.outputs.version }} \
-f ./docker/dist-mono/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ steps.extract_tag.outputs.version }} \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate-backend-mono:${{ steps.extract_tag.outputs.version }}
+143
View File
@@ -0,0 +1,143 @@
name: Deploy images
on:
push:
branches: ["master"]
workflow_dispatch:
jobs:
docker-build-and-push-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build Client Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-client:latest \
-f ./docker/dist/client.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
.
- name: Push Client Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-client:latest
docker-build-and-push-server:
needs: docker-build-and-push-client
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-backend:latest \
-f ./docker/dist/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-backend:latest
- name: Build Mongo Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-mongo:latest \
-f ./docker/dist/mongoDB.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push MongoDB Docker image
run: |
docker push ghcr.io/bluewave-labs/checkmate-mongo:latest
docker-build-and-push-server-mono-multiarch:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/dist-arm/server.Dockerfile
push: true
no-cache: true
tags: |
ghcr.io/bluewave-labs/checkmate-backend-mono-multiarch:latest
platforms: linux/amd64,linux/arm64
labels: |
org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate
build-args: |
VITE_APP_VERSION=${{ env.VERSION }}
docker-build-and-push-server-mono:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate-backend-mono:latest \
-f ./docker/dist-mono/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate-backend-mono:latest
+91
View File
@@ -0,0 +1,91 @@
name: Demo deploy
on:
push:
branches: [ "demo" ]
workflow_dispatch:
jobs:
docker-build-and-push-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build Client Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:frontend-demo \
-f ./docker/prod/client.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
.
- name: Push Client Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:frontend-demo
docker-build-and-push-server:
needs: docker-build-and-push-client
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:backend-demo \
-f ./docker/prod/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:backend-demo
- name: Build Mongo Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:mongo-demo \
-f ./docker/prod/mongoDB.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push MongoDB Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:mongo-demo
deploy-to-demo:
needs: docker-build-and-push-server
runs-on: ubuntu-latest
steps:
- name: SSH into server and restart container using Docker Compose
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.DEMO_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate
git pull
cd docker/unified
docker compose down
docker compose pull
docker compose up -d
docker system prune -af
+91
View File
@@ -0,0 +1,91 @@
name: Staging deploy
on:
push:
branches: [ "develop" ]
jobs:
docker-build-and-push-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: vars
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- name: Build Client Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:frontend-staging \
-f ./docker/staging/client.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
.
- name: Push Client Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:frontend-staging
docker-build-and-push-server:
needs: docker-build-and-push-client
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Server Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:backend-staging \
-f ./docker/staging/server.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push Server Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:backend-staging
- name: Build Mongo Docker image
run: |
docker build \
-t ghcr.io/bluewave-labs/checkmate:mongo-staging \
-f ./docker/staging/mongoDB.Dockerfile \
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
.
- name: Push MongoDB Docker image
run: docker push ghcr.io/bluewave-labs/checkmate:mongo-staging
deploy-to-staging:
needs: docker-build-and-push-server
runs-on: ubuntu-latest
steps:
- name: SSH into server and restart container using Docker Compose
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ secrets.DEMO_SERVER_HOST }}
username: ${{ secrets.DEMO_SERVER_USER }}
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
script: |
cd checkmate
git pull
cd docker/unified
docker compose down
docker compose pull
docker compose up -d
docker system prune -af
+15 -20
View File
@@ -35,9 +35,9 @@ npm run format # Prettier formatting
### Docker Development ### Docker Development
```bash ```bash
docker build -f docker/Dockerfile -t checkmate . # build the mono image (server + built client) cd docker/dev
docker compose -f docker/dev/docker-compose.yaml up # local full stack (builds image, runs mongo + worker) ./build_images.sh
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:8.0 # just a dev database docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:6.0
``` ```
## Environment Setup ## Environment Setup
@@ -51,7 +51,6 @@ TOKEN_TTL="99d"
ORIGIN="localhost" ORIGIN="localhost"
LOG_LEVEL="debug" LOG_LEVEL="debug"
``` ```
Only `CLIENT_HOST` (must be a full URL), `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required; the rest have defaults (see `server/src/config/envValidation.ts`). Setup source of truth: CONTRIBUTING.md.
### Client `.env` ### Client `.env`
```env ```env
@@ -69,23 +68,19 @@ VITE_APP_LOG_LEVEL="debug"
### Backend Layers ### Backend Layers
``` ```
server/src/ server/src/
├── api/ # HTTP layer ├── controllers/ # Route handlers (authController, monitorController, etc.)
│ ├── controllers/ # Route handlers (authController, monitorController, etc.) ├── service/ # Business logic
│ ├── middleware/ # verifyJWT, rateLimiter, sanitization, responseHandler │ ├── business/ # Core monitoring logic
│ ├── routes/ # API route definitions │ ├── infrastructure/ # Server/system utilities
│ └── validation/ # Zod request-payload schemas │ └── system/ # App-level settings
├── domain/ # Business logic + data access, one folder per entity
│ ├── monitors/ # e.g. monitor.service.ts, monitor.repository.ts, monitor.type.ts
│ ├── checks/
│ ├── incidents/
│ └── … # users, teams, status-pages, notifications, etc.
├── service/ # Cross-cutting services (e.g. infrastructure/)
├── config/ # App config wiring (controllers, routes, services, envValidation)
├── db/ ├── db/
│ └── migration/ # Database migrations (run on startup) │ ├── models/ # Mongoose schemas (Monitor, Check, Incident, User, etc.)
├── templates/ # Email/notification templates │ ├── migration/ # Database migrations (run on startup)
├── types/ # Shared const tuples + derived types │ └── modules/ # Database-specific modules
└── utils/ # Shared utilities ├── middleware/v1/ # verifyJWT, rateLimiter, sanitization, responseHandler
├── routes/v1/ # API route definitions
├── validation/ # Joi input validation schemas
└── repositories/ # Data access layer
``` ```
### Frontend Structure ### Frontend Structure
+29 -37
View File
@@ -88,10 +88,34 @@ cd Checkmate
#### Step 2: Set Up Docker Containers (MongoDB) #### Step 2: Set Up Docker Containers (MongoDB)
Navigate to the Docker dev directory:
```bash
cd docker/dev
```
Build the Docker images:
Linux:
```bash
./build_images.sh
```
Windows:
```bash
./build_images.bat
```
Run MongoDB container: Run MongoDB container:
```bash ```bash
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:8.0 docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:6.0
```
Navigate back to the root directory:
```bash
cd ../..
``` ```
#### Step 3: Set Up the Backend (Server) #### Step 3: Set Up the Backend (Server)
@@ -124,12 +148,9 @@ LOG_LEVEL="debug"
- `CLIENT_HOST`: Frontend URL (default: http://localhost:5173) - `CLIENT_HOST`: Frontend URL (default: http://localhost:5173)
- `JWT_SECRET`: Secret key for JWT tokens (change to something secure) - `JWT_SECRET`: Secret key for JWT tokens (change to something secure)
- `DB_CONNECTION_STRING`: MongoDB connection URL - `DB_CONNECTION_STRING`: MongoDB connection URL
- `ORIGIN`: Origin for CORS purposes (optional) - `ORIGIN`: Origin for CORS purposes
- `TOKEN_TTL`: Token time to live in vercel/ms format (optional, default: `99d`) - `TOKEN_TTL`: Token time to live (in vercel/ms format)
- `LOG_LEVEL`: Debug level: debug, info, warn, error (optional, default: `debug`) - `LOG_LEVEL`: Debug level (debug, info, warn, error)
- `QUEUE_MODE`: Queue mode, `primary` or `worker` (optional, default: `primary`)
Only `CLIENT_HOST`, `JWT_SECRET`, and `DB_CONNECTION_STRING` are strictly required. Note that `CLIENT_HOST` must be a full URL.
Start the backend server: Start the backend server:
@@ -205,7 +226,7 @@ docker rm uptime_database_mongo
**Port already in use:** **Port already in use:**
- Check if another service is using ports 5173, 52345, or 27017 - Check if another service is using ports 5173, 52345, 27017, or 6379
- Stop the conflicting service or change the port in `.env` files - Stop the conflicting service or change the port in `.env` files
**MongoDB connection issues:** **MongoDB connection issues:**
@@ -234,35 +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). 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?
Documentation contributions are very welcome — fixing a typo, clarifying a step, or adding a missing example is a valuable PR.
- Small edits (typos, wording, formatting): open a PR directly against `develop`.
- Larger changes (restructuring a section, adding a new guide): open an issue first so we can align on scope, then a PR.
- Screenshots are helpful when documenting UI behaviour.
- README translations live in `docs/translations/`. If you're adding a new language, please also add a flag + link to the language bar in the root `README.md` and every existing translated README so they stay in sync.
### Help with translations?
The application uses `i18next` with one JSON file per language under `client/src/locales/` (`en.json` is the source of truth). English source strings are the definitive copy; other languages are generated as one-time machine translations that anyone is welcome to refine.
**To improve an existing language:**
1. Edit the relevant `client/src/locales/<code>.json` file directly.
2. Keep the JSON key structure identical to `en.json` — only translate the string values.
3. Open a PR against `develop`. No language-selector code changes are needed.
**To add a new language:**
1. Add `client/src/locales/<code>.json` with the same key structure as `en.json`, translated. If you can't translate every key, leave unfamiliar strings in English rather than removing keys — i18next falls back to the key path when a value is missing.
2. Add the display name to `languageNames` in `client/src/Components/inputs/LanguageSelector.tsx` (e.g. `it: "Italiano"`). The selector picks up any language present in `resources` automatically.
3. Open a PR against `develop`.
If translating a whole file is more than you want to take on, open an issue asking us to add the language and we'll generate an initial machine-translated file that you (or others) can refine over time.
**Note:** an older workflow that synced translations with PoEditor is still in the repo but no longer authoritative. Please treat the `.json` files in `client/src/locales/` as the source of truth.
### Submit a pull request? ### Submit a pull request?
Follow the [pull request checklist](#pull-request-checklist). Your PR should: Follow the [pull request checklist](#pull-request-checklist). Your PR should:
+1 -32
View File
@@ -9,7 +9,6 @@
🇪🇸 <a href="docs/translations/README.es.md">Español</a> | 🇪🇸 <a href="docs/translations/README.es.md">Español</a> |
🇫🇮 <a href="docs/translations/README.fi.md">Suomi</a> | 🇫🇮 <a href="docs/translations/README.fi.md">Suomi</a> |
🇫🇷 <a href="docs/translations/README.fr.md">Français</a> | 🇫🇷 <a href="docs/translations/README.fr.md">Français</a> |
🇮🇹 <a href="docs/translations/README.it.md">Italiano</a> |
🇯🇵 <a href="docs/translations/README.ja.md">日本語</a> | 🇯🇵 <a href="docs/translations/README.ja.md">日本語</a> |
🇧🇷 <a href="docs/translations/README.pt-BR.md">Português (Brasil)</a> | 🇧🇷 <a href="docs/translations/README.pt-BR.md">Português (Brasil)</a> |
🇷🇺 <a href="docs/translations/README.ru.md">Русский</a> | 🇷🇺 <a href="docs/translations/README.ru.md">Русский</a> |
@@ -80,37 +79,7 @@ Usage instructions can be found [here](https://checkmate.so/docs).
<a id="installation"></a> <a id="installation"></a>
## Installation ## Installation
The quickest way to run Checkmate is the reference Docker Compose file, which runs the all-in-one image (`ghcr.io/bluewave-labs/checkmate`) plus MongoDB: See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
```bash
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
```
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
### Configuration
The image is configured entirely through environment variables on the server container:
| Variable | Required | Description |
|---|---|---|
| `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` |
| `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` |
| `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails |
| `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) |
The web client needs no configuration by default: it calls the API on the same origin it was served from (`/api/v1`). For setups where the defaults don't apply — for example, the API is reached through a different origin than the page — the server renders overrides into the client at runtime via these optional variables:
| Variable | Description |
|---|---|
| `CLIENT_CONFIG_API_BASE_URL` | Full base URL the client calls the API at, e.g. `https://api.example.com/api/v1`; defaults to same-origin `/api/v1` |
| `CLIENT_CONFIG_CLIENT_HOST` | Origin used when the client builds absolute links (invites, status pages); defaults to the browser's current origin |
| `CLIENT_CONFIG_LOG_LEVEL` | Browser console log level: `error`, `warn`, `info`, or `debug` (default `error`) |
> **Upgrading from an older image?** The `UPTIME_APP_*` variables (`UPTIME_APP_API_BASE_URL`, `UPTIME_APP_CLIENT_HOST`, `UPTIME_APP_LOG_LEVEL`) are no longer read. In most setups no replacement is needed — the same-origin defaults cover them; if you pointed the client at a different origin, use the `CLIENT_CONFIG_*` equivalents above. The `checkmate-client`, `checkmate-backend`, `checkmate-mongo`, and `checkmate-backend-mono-multiarch` images are no longer updated — switch to `ghcr.io/bluewave-labs/checkmate`, keeping your existing MongoDB service and data volume.
See full installation instructions in the [Checkmate documentation portal](https://checkmate.so/docs).
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions. Alternatively, you can also use [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.
+2 -6
View File
@@ -2,9 +2,5 @@ apiVersion: v2
name: checkmate-chart name: checkmate-chart
description: A Helm chart for Checkmate App description: A Helm chart for Checkmate App
type: application type: application
version: 0.2.0 version: 0.1.0
# DO NOT bump appVersion past v3.8.1 until this chart is migrated to the all-in-one appVersion: "2.3"
# image (ghcr.io/bluewave-labs/checkmate). The checkmate-client / checkmate-backend /
# checkmate-mongo images this chart references are frozen at v3.8.1 — later versions
# are never published, so a bumped chart will fail to pull.
appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1)
+12 -142
View File
@@ -19,11 +19,12 @@ cd checkmate/charts/helm/checkmate
### 2. Customize values.yaml ### 2. Customize values.yaml
Edit `values.yaml` to update: Edit `values.yaml` to update:
- `client.ingress.host` and `api.ingress.host` with your domain names - `client.ingress.host` and `server.ingress.host` with your domain names
- `api.protocol` (usually http or https) - `server.protocol` (usually http or https)
- **If upgrading**: Migrate persistence settings from flat structure to nested: - **If upgrading**: Migrate persistence settings from flat structure to nested:
- Old: `persistence.mongodbSize` → New: `persistence.mongo.size` - Old: `persistence.mongodbSize` → New: `persistence.mongo.size`
- Add: `persistence.mongo.storageClass` (leave empty for default) - Old: `persistence.redisSize` → New: `persistence.redis.size`
- Add: `persistence.mongo.storageClass` and `persistence.redis.storageClass` (leave empty for default)
- Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values - Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values
- **For TLS/HTTPS**: Configure ingress TLS settings (see section below) - **For TLS/HTTPS**: Configure ingress TLS settings (see section below)
@@ -31,7 +32,7 @@ Edit `values.yaml` to update:
```bash ```bash
helm install checkmate ./charts/helm/checkmate helm install checkmate ./charts/helm/checkmate
``` ```
This will deploy the client, api, and MongoDB components. This will deploy the client, server, MongoDB, and Redis components.
### 4. Verify the deployment ### 4. Verify the deployment
Check pods and services: Check pods and services:
@@ -42,137 +43,6 @@ kubectl get svc
Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts. Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts.
## Upgrading from a previous release
This release renames the `server.*` value block to `api.*` and drops the (unused) bundled Redis.
Read this before running `helm upgrade` on an existing install.
### `server.*` → `api.*` (values rename)
The value block formerly named `server` is now `api` (`server.image` → `api.image`,
`server.ingress.host` → `api.ingress.host`, `server.protocol` → `api.protocol`, `server.resources`,
`server.affinity`, `server.tolerations`, `server.ingress.*`, etc.).
**Your existing `server.*` overrides keep working** — the chart merges any legacy `server.*` values on
top of the `api.*` defaults (legacy wins where set), so an unchanged values file upgrades cleanly. The
shim is transitional; migrate your overrides to `api.*` at your convenience and drop the `server:`
block. If you set **both** `server.*` and `api.*` for the same field, the legacy `server.*` value wins —
don't mix them.
### Resource renames cause a replace, not a rename
The Kubernetes objects are renamed alongside the values:
| Old object | New object |
| --- | --- |
| Deployment/Service `checkmate-server` | `checkmate-api` |
| Ingress `checkmate-server-ingress` | `checkmate-api-ingress` |
| ConfigMap `checkmate-server-nginx-cm` | `checkmate-client-nginx-cm` |
Helm deletes the old objects and creates the new ones on upgrade. Expect a brief API restart and a
**new ClusterIP** for the API Service — update anything outside the chart that referenced the
`checkmate-server` Service by name. MongoDB data (its PVC) is untouched.
### Redis removed
The bundled Redis StatefulSet/Service and the `redis.*` / `persistence.redis.*` values are gone — the
scheduler runs in-process and never used them. Default installs (`redis.enabled: false`) are
unaffected. If you had explicitly set `redis.enabled: true`, **the Redis StatefulSet and its PVC are
deleted on upgrade** — back up anything you stored there first. Leftover `redis:` / `persistence.redis:`
keys in your values file are harmless (ignored).
### New secret defaults
`NODE_ENV: production`, `LOG_LEVEL: info`, and `TOKEN_TTL: 99d` are now part of the default `secrets`
block and merge into your install unless you override them. Set them explicitly if you relied on
different values (e.g. a non-production `NODE_ENV`).
### Image tags follow the chart's `appVersion`
Each tier's `image` value is now the repository **without a tag**; the tag defaults to the chart's
`appVersion`, so the client, api, worker, and mongodb images all move together when you upgrade the
chart. You can still pin per tier:
```yaml
api:
image: ghcr.io/bluewave-labs/checkmate-backend # repo only
tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag
```
- `<tier>.tag` overrides the version for that tier. The `worker` tier inherits `api.tag` (and
`api.image`) unless you set `worker.tag` / `worker.image`.
- If you put a **full `repo:tag`** in `image` (e.g. a legacy `server.image: …:v3.2.0` override), it is
used verbatim and the `tag` field is ignored — so existing pinned values keep working unchanged.
**Prefer a pinned tag over `latest`.** With the default `imagePullPolicy: IfNotPresent`, `latest` is
cached per node and never refreshed, and `helm rollback` can't move a floating tag — pin a version (or
let it default to `appVersion`) for reproducible, rollback-able deploys.
## Scaling: the worker tier & autoscaling
By default the chart runs a single all-in-one API pod that both schedules **and** processes monitoring
jobs (`worker.enabled: false`). For larger deployments you can split processing onto a dedicated,
horizontally-scalable worker tier.
### Topology
| `worker.enabled` | API (`checkmate-api`) | Worker (`checkmate-worker`) |
| --- | --- | --- |
| `false` (default) | schedules **and** processes jobs (`QUEUE_PRIMARY_PROCESSES=true`) | not deployed |
| `true` | schedules + serves the API only (`QUEUE_PRIMARY_PROCESSES=false`, derived) | processes all jobs |
`QUEUE_PRIMARY_PROCESSES` is **derived** from `worker.enabled` — you never set it directly, so the API
and worker tier can never both process (or both ignore) the queue.
### Enabling the worker tier
```yaml
worker:
enabled: true
replicas: 2 # used only when autoscaling.enabled = false
```
The worker takes **no inbound traffic** (no Service): the kubelet probes pod IPs directly on
`HEALTH_PORT` (`/livez`, `/readyz`), and graceful shutdown drains in-flight jobs before exit.
> **Invariant:** `worker.terminationGracePeriodSeconds` (default `30`) **must stay greater than the
> server drain timeout (25s)**. Lower it and Kubernetes SIGKILLs workers mid-drain, losing in-flight
> checks.
> **Caveat:** with the worker tier enabled the API no longer processes jobs, so if **all** workers are
> down nothing processes. The `autoscaling.minReplicaCount: 1` floor plus liveness restarts mitigate
> this; full failover to the API would need leader election (not supported).
### Autoscaling with KEDA
Backlog-driven autoscaling is **off by default** and requires the
[KEDA operator](https://keda.sh/) installed in-cluster. Enable it with:
```yaml
worker:
enabled: true
autoscaling:
enabled: true
minReplicaCount: 1 # processing floor — never 0, or checks silently stall
maxReplicaCount: 10
backlogPerReplica: 50 # target due-job backlog per worker
mongo:
dbName: uptime_db # MUST match the database in DB_CONNECTION_STRING
```
A KEDA `ScaledObject` queries MongoDB directly for the due-check backlog and scales the worker
Deployment between `minReplicaCount` and `maxReplicaCount`. When autoscaling is on, the Deployment
omits `replicas` so it doesn't fight the HPA KEDA creates. Make sure `autoscaling.mongo.dbName`
matches the database name in your `DB_CONNECTION_STRING`, or the scaler counts the wrong DB (always 0
backlog → never scales up).
#### Fallback: Prometheus-adapter HPA (not shipped)
If your KEDA/Mongo versions reject the `$$NOW`/`$toLong` query, or you already run Prometheus, scrape
the worker's `/metrics` (`checkmate_worker_due_backlog`), expose it via prometheus-adapter, and target
it with a standard `HorizontalPodAutoscaler`. The manifest is left to the operator; the app computes
`now` in code on this path, so the query coercion is unnecessary.
## Enabling TLS/HTTPS with cert-manager ## Enabling TLS/HTTPS with cert-manager
If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers. If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers.
@@ -198,7 +68,7 @@ client:
enabled: true enabled: true
secretName: checkmate-client-tls secretName: checkmate-client-tls
api: server:
protocol: https protocol: https
ingress: ingress:
enabled: true enabled: true
@@ -208,7 +78,7 @@ api:
cert-manager.io/cluster-issuer: "letsencrypt-prod" cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls: tls:
enabled: true enabled: true
secretName: checkmate-api-tls secretName: checkmate-server-tls
``` ```
### Alternative: Using --set flags ### Alternative: Using --set flags
@@ -218,13 +88,13 @@ You can also enable TLS during installation using Helm's `--set` flags:
```bash ```bash
helm install checkmate ./charts/helm/checkmate \ helm install checkmate ./charts/helm/checkmate \
--set client.protocol=https \ --set client.protocol=https \
--set api.protocol=https \ --set server.protocol=https \
--set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \ --set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
--set client.ingress.tls.enabled=true \ --set client.ingress.tls.enabled=true \
--set client.ingress.tls.secretName=checkmate-client-tls \ --set client.ingress.tls.secretName=checkmate-client-tls \
--set api.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \ --set server.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
--set api.ingress.tls.enabled=true \ --set server.ingress.tls.enabled=true \
--set api.ingress.tls.secretName=checkmate-api-tls --set server.ingress.tls.secretName=checkmate-server-tls
``` ```
### Verification ### Verification
@@ -237,7 +107,7 @@ kubectl get certificates
# Check certificate details # Check certificate details
kubectl describe certificate checkmate-client-tls kubectl describe certificate checkmate-client-tls
kubectl describe certificate checkmate-api-tls kubectl describe certificate checkmate-server-tls
# Verify the secrets were created # Verify the secrets were created
kubectl get secrets | grep checkmate-tls kubectl get secrets | grep checkmate-tls
@@ -1,35 +0,0 @@
{{- /*
Back-compat shim: the `server.*` value block was renamed to `api.*`. Existing installs that
still pass a legacy `server:` block keep working — this returns the effective API values with any
legacy `server.*` overrides merged on top of the `api.*` defaults (legacy wins where set).
New installs have no `server:` key, so this is a no-op deepCopy of `.Values.api`. Consume it as:
{{- $api := include "checkmate.api" . | fromYaml -}}
then reference `$api.*` instead of `.Values.api.*`.
*/}}
{{- define "checkmate.api" -}}
{{- $api := deepCopy .Values.api -}}
{{- with .Values.server -}}
{{- $api = mergeOverwrite $api (deepCopy .) -}}
{{- end -}}
{{- $api | toYaml -}}
{{- end -}}
{{- /*
Resolve a full image reference from a repo + optional tag. `appVersion` is the single source of
truth for tags: bumping Chart.appVersion moves every tier at once. Call as:
{{ include "checkmate.image" (dict "image" $repo "tag" $tag "root" $) }}
If `image` already carries a tag (a ":" in its final path segment) it is used verbatim — this keeps
legacy `server.image: repo:tag` overrides working and lets anyone pin a full ref (e.g. tag "latest").
A ":" only in the registry segment (a registry port like "reg:5000/x") is ignored, so a tag is still
appended. Otherwise the tag is `tag` if set, else the chart's appVersion.
*/}}
{{- define "checkmate.image" -}}
{{- $lastSegment := .image | splitList "/" | last -}}
{{- if contains ":" $lastSegment -}}
{{- .image -}}
{{- else -}}
{{- printf "%s:%s" .image (.tag | default .root.Chart.AppVersion) -}}
{{- end -}}
{{- end -}}
@@ -1,65 +0,0 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-api
spec:
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
selector:
matchLabels:
app: checkmate-api
template:
metadata:
labels:
app: checkmate-api
spec:
{{- with $api.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with $api.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: api
image: {{ include "checkmate.image" (dict "image" $api.image "tag" $api.tag "root" $) }}
imagePullPolicy: {{ $api.imagePullPolicy }}
ports:
- name: http
containerPort: {{ $api.port }}
envFrom:
- secretRef:
name: checkmate-secrets
# Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive.
# NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the API is
# also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5.
livenessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $api.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ $api.probes.liveness.periodSeconds }}
timeoutSeconds: {{ $api.probes.liveness.timeoutSeconds }}
failureThreshold: {{ $api.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: {{ $api.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ $api.probes.readiness.periodSeconds }}
timeoutSeconds: {{ $api.probes.readiness.timeoutSeconds }}
failureThreshold: {{ $api.probes.readiness.failureThreshold }}
{{- with $api.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
env:
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
- name: QUEUE_MODE
value: "primary"
# Derived: when a worker tier is deployed the API only schedules (false); otherwise it is
# all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs.
- name: QUEUE_PRIMARY_PROCESSES
value: {{ not .Values.worker.enabled | quote }}
@@ -1,10 +0,0 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: v1
kind: Service
metadata:
name: checkmate-api
spec:
selector:
app: checkmate-api
ports:
- port: {{ $api.port }}
@@ -1,4 +1,3 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -23,13 +22,12 @@ spec:
{{- end }} {{- end }}
containers: containers:
- name: client - name: client
image: {{ include "checkmate.image" (dict "image" .Values.client.image "tag" .Values.client.tag "root" $) }} image: {{ .Values.client.image }}
imagePullPolicy: {{ .Values.client.imagePullPolicy }}
ports: ports:
- containerPort: {{ .Values.client.port }} - containerPort: {{ .Values.client.port }}
env: env:
- name: UPTIME_APP_API_BASE_URL - name: UPTIME_APP_API_BASE_URL
value: "{{ $api.protocol }}://{{ $api.ingress.host }}/api/v1" value: "{{ .Values.server.protocol }}://{{ .Values.server.ingress.host }}/api/v1"
- name: UPTIME_APP_CLIENT_HOST - name: UPTIME_APP_CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}" value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
volumeMounts: volumeMounts:
@@ -43,4 +41,4 @@ spec:
volumes: volumes:
- name: config-volume - name: config-volume
configMap: configMap:
name: checkmate-client-nginx-cm name: checkmate-server-nginx-cm
@@ -22,8 +22,7 @@ spec:
{{- end }} {{- end }}
containers: containers:
- name: mongodb - name: mongodb
image: {{ include "checkmate.image" (dict "image" .Values.mongodb.image "tag" .Values.mongodb.tag "root" $) }} image: {{ .Values.mongodb.image }}
imagePullPolicy: {{ .Values.mongodb.imagePullPolicy }}
ports: ports:
- containerPort: {{ .Values.mongodb.port }} - containerPort: {{ .Values.mongodb.port }}
command: ["mongod", "--quiet", "--bind_ip_all"] command: ["mongod", "--quiet", "--bind_ip_all"]
@@ -38,9 +37,7 @@ spec:
- metadata: - metadata:
name: checkmate-mongo-persistent-storage name: checkmate-mongo-persistent-storage
spec: spec:
{{- with .Values.persistence.mongo.storageClass }} storageClassName: {{ .Values.persistence.mongo.storageClass | quote }}
storageClassName: {{ . | quote }} {{/* omitted when empty so the cluster's default StorageClass applies */}}
{{- end }}
accessModes: ["ReadWriteOnce"] accessModes: ["ReadWriteOnce"]
resources: resources:
requests: requests:
+14 -20
View File
@@ -1,15 +1,14 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if eq .Values.client.ingress.host "change_me" }} {{- if eq .Values.client.ingress.host "change_me" }}
{{- fail "client.ingress.host must be overridden and not set to 'change_me'" }} {{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
{{- end }} {{- end }}
{{- if eq $api.ingress.host "change_me" }} {{- if eq .Values.server.ingress.host "change_me" }}
{{- fail "api.ingress.host (or legacy server.ingress.host) must be overridden and not set to 'change_me'" }} {{- fail "server.ingress.host must be overridden and not set to 'change_me'" }}
{{- end }} {{- end }}
{{- $apiProtocol := $api.protocol }} {{- $serverProtocol := .Values.server.protocol }}
{{- if not (or (eq $apiProtocol "http") (eq $apiProtocol "https")) }} {{- if not (or (eq $serverProtocol "http") (eq $serverProtocol "https")) }}
{{- fail "api.protocol must be either 'http' or 'https'" }} {{- fail "server.protocol must be either 'http' or 'https'" }}
{{- end }} {{- end }}
{{- $clientProtocol := .Values.client.protocol }} {{- $clientProtocol := .Values.client.protocol }}
@@ -22,33 +21,28 @@
{{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }} {{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
{{- end }} {{- end }}
{{- if and $api.ingress.tls.enabled (ne $apiProtocol "https") }} {{- if and .Values.server.ingress.tls.enabled (ne $serverProtocol "https") }}
{{- fail "api.ingress.tls.enabled is true but api.protocol is not 'https'. Set api.protocol: https to ensure correct API base URL." }} {{- fail "server.ingress.tls.enabled is true but server.protocol is not 'https'. Set server.protocol: https to ensure correct API base URL." }}
{{- end }} {{- end }}
{{/* If client runs on https, API must also be https to avoid mixed content */}} {{/* If client runs on https, API must also be https to avoid mixed content */}}
{{- if and (eq $clientProtocol "https") (ne $apiProtocol "https") }} {{- if and (eq $clientProtocol "https") (ne $serverProtocol "https") }}
{{- fail "client.protocol is 'https' but api.protocol is not. Set api.protocol: https to prevent browser mixed-content issues." }} {{- fail "client.protocol is 'https' but server.protocol is not. Set server.protocol: https to prevent browser mixed-content issues." }}
{{- end }} {{- end }}
{{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}} {{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}}
{{- $cAnn := .Values.client.ingress.annotations | default dict }} {{- $cAnn := .Values.client.ingress.annotations | default dict }}
{{- $aAnn := $api.ingress.annotations | default dict }} {{- $sAnn := .Values.server.ingress.annotations | default dict }}
{{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }} {{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }}
{{- $apiHasIssuer := or (hasKey $aAnn "cert-manager.io/cluster-issuer") (hasKey $aAnn "cert-manager.io/issuer") }} {{- $serverHasIssuer := or (hasKey $sAnn "cert-manager.io/cluster-issuer") (hasKey $sAnn "cert-manager.io/issuer") }}
{{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }} {{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }}
{{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} {{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- end }} {{- end }}
{{- if and $api.ingress.tls.enabled (not $apiHasIssuer) }} {{- if and .Values.server.ingress.tls.enabled (not $serverHasIssuer) }}
{{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} {{- fail "server.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
{{- end }} {{- end }}
{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}} {{/* Secret name can be omitted; we default to <release>-client|server-tls in templates */}}
{{- if and .Values.worker.enabled (le (int .Values.worker.terminationGracePeriodSeconds) 25) }}
{{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }}
{{- end }}
{{/* Secret name can be omitted; we default to <release>-client|api-tls in templates */}}
@@ -0,0 +1,11 @@
{{- if .Values.redis.enabled }}
apiVersion: v1
kind: Service
metadata:
name: checkmate-redis
spec:
selector:
app: checkmate-redis
ports:
- port: {{ .Values.redis.port }}
{{- end }}
@@ -0,0 +1,33 @@
{{- if .Values.redis.enabled }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: checkmate-redis
spec:
replicas: 1
selector:
matchLabels:
app: checkmate-redis
template:
metadata:
labels:
app: checkmate-redis
spec:
containers:
- name: redis
image: {{ .Values.redis.image }}
ports:
- containerPort: {{ .Values.redis.port }}
volumeMounts:
- name: redis-data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: checkmate-redis-persistent-storage
spec:
storageClassName: {{ .Values.persistence.redis.storageClass | quote }}
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: {{ .Values.persistence.redis.size | default "1Gi" | quote }}
{{- end }}
@@ -4,14 +4,6 @@
{{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }} {{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }}
{{- end }} {{- end }}
{{- /*
The bundled Mongo service ("checkmate-mongodb") lives in .Release.Namespace. App pods reach it
by bare name, but the KEDA operator runs in its own namespace and a bare name is NXDOMAIN there,
so worker autoscaling fails to query the backlog. Qualify the in-cluster host to its FQDN here so
the one connection string resolves from every namespace. External/already-qualified hosts are not
matched (the char after the host name is "." for an FQDN, which the pattern excludes).
*/}}
{{- $mongoFqdn := printf "checkmate-mongodb.%s.svc.cluster.local" .Release.Namespace }}
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
@@ -19,9 +11,5 @@ metadata:
type: Opaque type: Opaque
stringData: stringData:
{{- range $key, $value := $secrets }} {{- range $key, $value := $secrets }}
{{- if eq $key "DB_CONNECTION_STRING" }}
{{ $key }}: {{ regexReplaceAll "(//|@)checkmate-mongodb([:/,]|$)" $value (printf "${1}%s${2}" $mongoFqdn) | quote }}
{{- else }}
{{ $key }}: {{ $value | quote }} {{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }} {{- end }}
@@ -0,0 +1,37 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-server
spec:
replicas: 1
selector:
matchLabels:
app: checkmate-server
template:
metadata:
labels:
app: checkmate-server
spec:
{{- with .Values.server.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with .Values.server.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: server
image: {{ .Values.server.image }}
ports:
- containerPort: {{ .Values.server.port }}
envFrom:
- secretRef:
name: checkmate-secrets
{{- with .Values.server.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
env:
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
@@ -1,12 +1,11 @@
{{- $api := include "checkmate.api" . | fromYaml -}} {{- if .Values.server.ingress.enabled }}
{{- if $api.ingress.enabled }}
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: checkmate-api-ingress name: checkmate-server-ingress
{{- if $api.ingress.annotations }} {{- if .Values.server.ingress.annotations }}
annotations: annotations:
{{- range $key, $value := $api.ingress.annotations }} {{- range $key, $value := .Values.server.ingress.annotations }}
{{ $key }}: {{ $value | quote }} {{ $key }}: {{ $value | quote }}
{{- end }} {{- end }}
{{- end }} {{- end }}
@@ -18,22 +17,22 @@ metadata:
#nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" #nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
#nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}} #nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}}
spec: spec:
ingressClassName: {{ $api.ingress.className }} ingressClassName: {{ .Values.server.ingress.className }}
{{- if $api.ingress.tls.enabled }} {{- if .Values.server.ingress.tls.enabled }}
tls: tls:
- hosts: - hosts:
- {{ $api.ingress.host }} - {{ .Values.server.ingress.host }}
secretName: {{ default (printf "%s-api-tls" .Release.Name) $api.ingress.tls.secretName }} secretName: {{ default (printf "%s-server-tls" .Release.Name) .Values.server.ingress.tls.secretName }}
{{- end }} {{- end }}
rules: rules:
- host: {{ $api.ingress.host }} - host: {{ .Values.server.ingress.host }}
http: http:
paths: paths:
- path: /api/v1 - path: /api/v1
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: checkmate-api name: checkmate-server
port: port:
number: {{ $api.port }} number: {{ .Values.server.port }}
{{- end }} {{- end }}
@@ -1,8 +1,7 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
metadata: metadata:
name: checkmate-client-nginx-cm name: checkmate-server-nginx-cm
data: data:
default.conf: | default.conf: |
server { server {
@@ -23,7 +22,7 @@ data:
} }
# location /api/ { # location /api/ {
# proxy_pass http://{{ $api.ingress.host }}:5000/api/; # proxy_pass http://{{ .Values.server.ingress.host }}:5000/api/;
# proxy_http_version 1.1; # proxy_http_version 1.1;
# proxy_set_header Host $host; # proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr; # proxy_set_header X-Real-IP $remote_addr;
@@ -32,7 +31,7 @@ data:
# } # }
location /api-docs/ { location /api-docs/ {
proxy_pass http://{{ $api.ingress.host }}:5000/api-docs/; proxy_pass http://{{ .Values.server.ingress.host }}:5000/api-docs/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Service
metadata:
name: checkmate-server
spec:
selector:
app: checkmate-server
ports:
- port: {{ .Values.server.port }}
@@ -1,66 +0,0 @@
{{- $api := include "checkmate.api" . | fromYaml -}}
{{- if .Values.worker.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkmate-worker
spec:
{{- if not .Values.worker.autoscaling.enabled }}
replicas: {{ .Values.worker.replicas }} # omitted when autoscaling owns the replica count
{{- end }}
selector:
matchLabels:
app: checkmate-worker
template:
metadata:
labels:
app: checkmate-worker
spec:
terminationGracePeriodSeconds: {{ .Values.worker.terminationGracePeriodSeconds }}
{{- with .Values.worker.affinity }}
affinity:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
{{- with .Values.worker.tolerations }}
tolerations:
{{- tpl ( . | toYaml) $ | nindent 8 }}
{{- end }}
containers:
- name: worker
image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $api.image) "tag" (.Values.worker.tag | default $api.tag) "root" $) }}
imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $api.imagePullPolicy }}
ports:
- name: health
containerPort: {{ .Values.worker.healthPort }}
envFrom:
- secretRef:
name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv)
env:
# Secret default CLIENT_HOST is the non-URL "change_me"; override like the API so validateEnv passes.
- name: CLIENT_HOST
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
- name: QUEUE_MODE
value: "worker"
- name: HEALTH_PORT
value: {{ .Values.worker.healthPort | quote }}
livenessProbe:
httpGet:
path: /livez
port: health
initialDelaySeconds: {{ .Values.worker.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.worker.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.worker.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.worker.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: /readyz
port: health
initialDelaySeconds: {{ .Values.worker.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.worker.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.worker.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.worker.probes.readiness.failureThreshold }}
{{- with .Values.worker.resources }}
resources:
{{- tpl ( . | toYaml) $ | nindent 12 }}
{{- end }}
{{- end }}
@@ -1,30 +0,0 @@
{{- if and .Values.worker.enabled .Values.worker.autoscaling.enabled }}
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: checkmate-worker
spec:
scaleTargetRef:
name: checkmate-worker
minReplicaCount: {{ .Values.worker.autoscaling.minReplicaCount }} # floor: never 0 — a cold gap stops checks
maxReplicaCount: {{ .Values.worker.autoscaling.maxReplicaCount }}
cooldownPeriod: {{ .Values.worker.autoscaling.cooldownPeriod }}
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleDown:
stabilizationWindowSeconds: {{ .Values.worker.autoscaling.stabilizationWindowSeconds }}
triggers:
- type: mongodb
metadata:
dbName: {{ .Values.worker.autoscaling.mongo.dbName | quote }}
collection: {{ .Values.worker.autoscaling.mongo.collection | quote }}
# The Phase 2 countDueBacklog predicate, time-relativized with $$NOW (epoch-ms via $toLong):
query: >-
{ "type": "check", "isActive": true,
"$expr": { "$lte": ["$nextScheduledAt", { "$toLong": "$$NOW" }] },
"$or": [ { "lockedUntil": null },
{ "$expr": { "$lt": ["$lockedUntil", { "$toLong": "$$NOW" }] } } ] }
queryValue: {{ .Values.worker.autoscaling.backlogPerReplica | quote }}
connectionStringFromEnv: DB_CONNECTION_STRING # resolved from the worker's checkmate-secrets
{{- end }}
+27 -65
View File
@@ -1,7 +1,5 @@
client: client:
image: ghcr.io/bluewave-labs/checkmate-client image: ghcr.io/bluewave-labs/checkmate-client:v3.2.0
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 80 port: 80
protocol: http protocol: http
ingress: ingress:
@@ -19,24 +17,10 @@ client:
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod"). # a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation # The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
api: server:
image: ghcr.io/bluewave-labs/checkmate-backend image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 52345 port: 52345
protocol: http protocol: http
# Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up).
probes:
liveness:
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readiness:
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
ingress: ingress:
enabled: true enabled: true
host: change_me host: change_me
@@ -47,62 +31,40 @@ api:
# cert-manager.io/cluster-issuer: "letsencrypt-prod" # cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls: tls:
enabled: false enabled: false
# secretName: {{ .Release.Name }}-api-tls # Optional; defaults to <release>-api-tls if omitted # secretName: {{ .Release.Name }}-server-tls # Optional; defaults to <release>-server-tls if omitted
# Note: when enabling TLS, also set api.protocol: https and add # Note: when enabling TLS, also set server.protocol: https and add
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod"). # a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation # The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
mongodb: redis:
image: ghcr.io/bluewave-labs/checkmate-mongo
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
port: 27017
worker:
# Off by default; existing installs are unchanged. When true, a dedicated worker processes all
# jobs and the API stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the
# API is all-in-one and processes jobs itself.
enabled: false enabled: false
image: "" # defaults to api.image when empty, same as API image image: redis:7.2
tag: "" # defaults to api.tag (-> Chart.appVersion) when empty port: 6379
imagePullPolicy: "" # defaults to api.imagePullPolicy when empty
replicas: 1 # used only when autoscaling.enabled = false mongodb:
healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics image: ghcr.io/bluewave-labs/checkmate-mongo:v3.2.0
terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain port: 27017
resources: {}
affinity: {}
tolerations: {}
probes:
liveness: # /livez — failureThreshold>=3 so one slow tick never restarts mid-job
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readiness: # /readyz — flips to 503 on drain
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
autoscaling:
enabled: false # requires the KEDA operator installed in-cluster
minReplicaCount: 1 # processing floor — never 0 (a cold gap silently stops checks)
maxReplicaCount: 10
backlogPerReplica: 50 # KEDA queryValue: target due-backlog per worker
cooldownPeriod: 300 # seconds before scaling an idle target down
stabilizationWindowSeconds: 300 # damp the interval-boundary herd
mongo:
dbName: uptime_db # must match DB_CONNECTION_STRING's database
collection: jobs
secrets: secrets:
JWT_SECRET: change_me JWT_SECRET: change_me
DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db # REFRESH_TOKEN_SECRET: change_me
# SYSTEM_EMAIL_ADDRESS: test@example.com
# SYSTEM_EMAIL_PASSWORD: change_me
# SYSTEM_EMAIL_HOST: smtp.example.com
# SYSTEM_EMAIL_PORT: "587"
# PAGESPEED_API_KEY: change_me
DB_CONNECTION_STRING: mongodb://checkmate-mongodb.namespace.svc:27017/uptime_db
CLIENT_HOST: change_me CLIENT_HOST: change_me
NODE_ENV: production # REDIS_HOST: redis
LOG_LEVEL: info # REDIS_PORT: "6379"
TOKEN_TTL: 99d # DB_TYPE: MongoDB
# TOKEN_TTL: 99d
# REFRESH_TOKEN_TTL: 99d
persistence: persistence:
mongo: mongo:
size: 5Gi size: 5Gi
storageClass: "" storageClass: ""
redis:
size: 1Gi
storageClass: ""
+4 -6
View File
@@ -1,6 +1,4 @@
# Production builds use same-origin defaults: API at /api/v1, client host from VITE_APP_API_BASE_URL=UPTIME_APP_API_BASE_URL
# window.location.origin. The empty values below override any local .env so a VITE_APP_API_V2_BASE_URL=UPTIME_APP_API_V2_BASE_URL
# production build never bakes in a developer's localhost URLs. VITE_APP_CLIENT_HOST=UPTIME_APP_CLIENT_HOST
VITE_APP_API_BASE_URL= VITE_APP_LOG_LEVEL=UPTIME_APP_LOG_LEVEL
VITE_APP_CLIENT_HOST=
VITE_APP_LOG_LEVEL=
+1
View File
@@ -28,4 +28,5 @@ dist-ssr
# Scripts # Scripts
*.sh *.sh
!env.sh
.env.development .env.development
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
for i in $(env | grep UPTIME_APP_)
do
key=$(echo $i | cut -d '=' -f 1)
value=$(echo $i | cut -d '=' -f 2-)
echo $key=$value
find /usr/share/nginx/html -type f \( -name '*.js' -o -name '*.css' \) -exec sed -i "s|${key}|${value}|g" '{}' +
done
-1
View File
@@ -12,7 +12,6 @@
/> />
<title>Checkmate</title> <title>Checkmate</title>
<script src="/config.js"></script>
</head> </head>
<body> <body>
+211 -321
View File
File diff suppressed because it is too large. Load diff
+3 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "3.9.2", "version": "3.7.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -14,10 +14,9 @@
"format-check": "prettier --check ." "format-check": "prettier --check ."
}, },
"lint-staged": { "lint-staged": {
"**/*": "prettier --write --ignore-unknown" "**/*": "prettier --write"
}, },
"dependencies": { "dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@hello-pangea/dnd": "^18.0.0", "@hello-pangea/dnd": "^18.0.0",
@@ -33,7 +32,7 @@
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"human-interval": "2.0.1", "human-interval": "2.0.1",
"i18next": "25.4.2", "i18next": "25.4.2",
"joi": "17.13.4", "joi": "17.13.3",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"maplibre-gl": "^5.19.0", "maplibre-gl": "^5.19.0",
"mui-color-input": "^7.0.0", "mui-color-input": "^7.0.0",
-4
View File
@@ -1,4 +0,0 @@
// Fallback so /config.js always resolves (e.g. under the Vite dev server). In
// the Docker image the server shadows this path and renders the real config
// from its CLIENT_CONFIG_* environment variables.
window.__CHECKMATE_CONFIG__ = {};
@@ -13,9 +13,7 @@ export const languageNames: Record<string, string> = {
ca: "Català", ca: "Català",
es: "Español", es: "Español",
fr: "Français", fr: "Français",
it: "Italiano",
"pt-BR": "Português (BR)", "pt-BR": "Português (BR)",
pl: "Polski",
cs: "Čeština", cs: "Čeština",
fi: "Suomi", fi: "Suomi",
tr: "Türkçe", tr: "Türkçe",
+1 -1
View File
@@ -22,7 +22,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
width: "100%", width: "100%",
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
height: props.multiline ? "auto" : 34, height: 34,
fontSize: typographyLevels.base, fontSize: typographyLevels.base,
overflow: "hidden", overflow: "hidden",
}, },
@@ -20,7 +20,7 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
const msg: Record<string, string> = { const msg: Record<string, string> = {
success: "Excellent", success: "Excellent",
warning: "Average", warning: "Average",
error: "Poor", danger: "Poor",
}; };
return ( return (
+1 -5
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor"; import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor"; import { DefaultPageSpeedStrategy } from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor"; import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions { interface UseMonitorFormOptions {
@@ -40,13 +40,11 @@ export const getMonitorDefaults = (
...getGeoCheckDefaults(data), ...getGeoCheckDefaults(data),
type: "http", type: "http",
url: data?.url || "", url: data?.url || "",
method: data?.method ?? DefaultHttpMethod,
ignoreTlsErrors: data?.ignoreTlsErrors || false, ignoreTlsErrors: data?.ignoreTlsErrors || false,
useAdvancedMatching: data?.useAdvancedMatching || false, useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || "", matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "", expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "", jsonPath: data?.jsonPath || "",
customUpCodes: data?.customUpCodes || [],
}; };
break; break;
case "ping": case "ping":
@@ -135,13 +133,11 @@ export const getMonitorDefaults = (
...getGeoCheckDefaults(data), ...getGeoCheckDefaults(data),
type: "http", type: "http",
url: "", url: "",
method: DefaultHttpMethod,
ignoreTlsErrors: false, ignoreTlsErrors: false,
useAdvancedMatching: false, useAdvancedMatching: false,
matchMethod: "", matchMethod: "",
expectedValue: "", expectedValue: "",
jsonPath: "", jsonPath: "",
customUpCodes: [],
}; };
} }
-8
View File
@@ -78,14 +78,6 @@ function buildDefaults(data: Notification | null): NotificationFormData {
accessToken: data.accessToken || "", accessToken: data.accessToken || "",
}; };
} }
if (data?.type === "ntfy") {
return {
type: "ntfy",
notificationName: data.notificationName || "",
address: data.address || "",
topic: data.topic || "",
};
}
// Default: email (covers both data === null and data.type === "email") // Default: email (covers both data === null and data.type === "email")
return { return {
type: "email", type: "email",
-1
View File
@@ -31,7 +31,6 @@ export const useStatusPageForm = ({
const defaults: StatusPageFormData = { const defaults: StatusPageFormData = {
companyName: data?.companyName || "", companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(), url: data?.url || generateDefaultUrl(),
customDomain: data?.customDomain ?? null,
timezone: resolveTimezone(data?.timezone), timezone: resolveTimezone(data?.timezone),
type: data?.type || ["uptime"], type: data?.type || ["uptime"],
color: data?.color || "#13715B", color: data?.color || "#13715B",
@@ -10,9 +10,8 @@ import { useInviteForm } from "@/Hooks/useInviteForm";
import type { InviteFormData } from "@/Validation/invite"; import type { InviteFormData } from "@/Validation/invite";
import { usePost } from "@/Hooks/UseApi"; import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants"; import { LAYOUT } from "@/Utils/Theme/constants";
import { runtimeConfig } from "@/Utils/runtimeConfig";
const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST; const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST;
interface InviteResponse { interface InviteResponse {
token: string; token: string;
@@ -57,7 +56,7 @@ export const InviteTeamMemberDialog = ({
const result = await generateToken("/invite", data); const result = await generateToken("/invite", data);
if (result?.data?.token) { if (result?.data?.token) {
const token = result.data.token; const token = result.data.token;
const link = `${CLIENT_HOST || window.location.origin}/register/${token}`; const link = CLIENT_HOST ? `${CLIENT_HOST}/register/${token}` : token;
setInviteLink(link); setInviteLink(link);
} }
}; };
+21 -143
View File
@@ -1,7 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { logger } from "@/Utils/logger"; import { logger } from "@/Utils/logger";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
import { useParams, useLocation, useNavigate } from "react-router"; import { useParams, useLocation, useNavigate } from "react-router";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -42,11 +41,8 @@ import {
type Monitor, type Monitor,
type MonitorType, type MonitorType,
type GamesMap, type GamesMap,
type HttpMethod,
supportsGeoCheck, supportsGeoCheck,
DefaultPageSpeedStrategy, DefaultPageSpeedStrategy,
DefaultHttpMethod,
HttpMethods,
} from "@/Types/Monitor"; } from "@/Types/Monitor";
import type { Notification } from "@/Types/Notification"; import type { Notification } from "@/Types/Notification";
import type { Tag } from "@/Types/Tag"; import type { Tag } from "@/Types/Tag";
@@ -262,19 +258,18 @@ const CreateMonitorPage = () => {
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaults, defaultValues: defaults,
}); });
const { control, watch, handleSubmit, clearErrors, trigger, reset, setValue } = form; const { control, watch, handleSubmit, clearErrors, trigger, reset } = form;
useEffect(() => { useEffect(() => {
reset(defaults); reset(defaults);
}, [defaults, reset]); }, [defaults, reset]);
// Multi-step wizard on every create flow; edit mode keeps the flat form // Multi-step wizard, uptime create only (showTypeSelector). Other flows
// where showStep() is always true. // keep the flat form, where showStep() is always true.
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const showStep = (step: number) => isEditMode || currentStep === step; const showStep = (step: number) => !showTypeSelector || currentStep === step;
const watchedType = watch("type") as MonitorType; const watchedType = watch("type") as MonitorType;
const watchedMethod = watch("method") as HttpMethod | undefined;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean; const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
@@ -367,7 +362,7 @@ const CreateMonitorPage = () => {
refetch={refetchMonitor} refetch={refetchMonitor}
onDelete={handleDeleteClick} onDelete={handleDeleteClick}
/> />
{!isEditMode && ( {showTypeSelector && (
<StepProgress <StepProgress
steps={totalSteps} steps={totalSteps}
current={currentStep} current={currentStep}
@@ -1096,144 +1091,27 @@ const CreateMonitorPage = () => {
rightContent={ rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller <Controller
name="method" name="useAdvancedMatching"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack
<Select direction="row"
{...field} alignItems="center"
value={field.value ?? DefaultHttpMethod} spacing={theme.spacing(SPACING.LG)}
onChange={(e) => { >
const value = e.target.value as HttpMethod; <Switch
field.onChange(value); checked={field.value ?? false}
// HEAD has no response body, reset advanced matching fields if selected onChange={(e) => field.onChange(e.target.checked)}
if (value === "HEAD") { />
setValue("useAdvancedMatching", false); <Typography>
setValue("matchMethod", "");
setValue("expectedValue", "");
setValue("jsonPath", "");
}
}}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.method.label"
)}
>
{HttpMethods.map((method) => (
<MenuItem
key={method}
value={method}
>
{method}
</MenuItem>
))}
</Select>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t( {t(
`pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}` "pages.createMonitor.form.advanced.option.advancedMatching.label"
)} )}
</Typography> </Typography>
</Stack> </Stack>
)} )}
/> />
<Controller {watchedUseAdvancedMatching && (
name="customUpCodes"
control={control}
render={({ field }) => {
const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) =>
field.value?.includes(option.id)
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
autoHighlight
options={ALL_HTTP_STATUS_CODES}
value={selectedOptions}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
onChange={(
_: unknown,
newValue: typeof ALL_HTTP_STATUS_CODES
) => {
field.onChange(newValue?.map((v) => v.id) || []);
}}
fieldLabel={t(
"pages.createMonitor.form.advanced.option.customUpCodes.label"
)}
/>
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{t(
"pages.createMonitor.form.advanced.option.customUpCodes.description"
)}
</Typography>
{selectedOptions.length > 0 && (
<Stack
flex={1}
gap={theme.spacing(SPACING.MD)}
width="100%"
>
{selectedOptions.map((option, index) => (
<Stack
direction="row"
alignItems="center"
key={option.id}
width="100%"
>
<Typography flexGrow={1}>{option.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value?.filter((id) => id !== option.id)
);
}}
aria-label={t(
"pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel"
)}
>
<Trash2 size={16} />
</IconButton>
{index < selectedOptions.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
{watchedMethod !== "HEAD" && (
<Controller
name="useAdvancedMatching"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
{t(
"pages.createMonitor.form.advanced.option.advancedMatching.label"
)}
</Typography>
</Stack>
)}
/>
)}
{watchedUseAdvancedMatching && watchedMethod !== "HEAD" && (
<Stack spacing={theme.spacing(LAYOUT.MD)}> <Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller <Controller
name="matchMethod" name="matchMethod"
@@ -1457,9 +1335,9 @@ const CreateMonitorPage = () => {
<Stack <Stack
direction="row" direction="row"
justifyContent={!isEditMode ? "space-between" : "flex-end"} justifyContent={showTypeSelector ? "space-between" : "flex-end"}
> >
{!isEditMode && ( {showTypeSelector && (
<Button <Button
type="button" type="button"
variant="outlined" variant="outlined"
@@ -1470,7 +1348,7 @@ const CreateMonitorPage = () => {
{t("common.buttons.back")} {t("common.buttons.back")}
</Button> </Button>
)} )}
{!isEditMode && currentStep < totalSteps - 1 ? ( {showTypeSelector && currentStep < totalSteps - 1 ? (
<Button <Button
key="wizard-next" key="wizard-next"
type="button" type="button"
@@ -63,20 +63,10 @@ const getChartConfigs = (
return configs; return configs;
}; };
// Loopback / bridge names Capture sees when it can only observe the container's const hasAnyNetworkTraffic = (checks: HardwareCheckStats[]): boolean =>
// own network namespace. When ALL reported interfaces match this set we assume checks.some((c) =>
// the "no real interfaces visible" case and surface the notice. c.net?.some((iface) => iface.bytesSentPerSecond > 0 || iface.deltaBytesRecv > 0)
const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]); );
const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => {
const named = new Set<string>();
checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name)));
if (named.size === 0) return false;
for (const name of named) {
if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) return false;
}
return true;
};
export const InfraNetworkCharts = ({ export const InfraNetworkCharts = ({
checks, checks,
@@ -92,14 +82,13 @@ export const InfraNetworkCharts = ({
() => getChartConfigs(theme, checks, t), () => getChartConfigs(theme, checks, t),
[theme, checks, t] [theme, checks, t]
); );
const showLoopbackOnlyNotice = const showNoTrafficNotice = chartConfigs.length > 0 && !hasAnyNetworkTraffic(checks);
chartConfigs.length > 0 && onlyContainerVisibleInterfaces(checks);
return ( return (
<Stack gap={theme.spacing(LAYOUT.XS)}> <Stack gap={theme.spacing(LAYOUT.XS)}>
{showLoopbackOnlyNotice && ( {showNoTrafficNotice && (
<NoticeBanner severity="warning"> <NoticeBanner severity="warning">
{t("pages.infrastructure.charts.labels.loopbackOnlyNotice")} {t("pages.infrastructure.charts.labels.netNoTraffic")}
</NoticeBanner> </NoticeBanner>
)} )}
<Grid <Grid
+3 -53
View File
@@ -2,9 +2,8 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { TableJobs, TableFailedJobs } from "./components/TableJobs"; import { TableJobs, TableFailedJobs } from "./components/TableJobs";
import { TableWorkers } from "./components/TableWorkers";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useState } from "react";
import { useTranslation, Trans } from "react-i18next"; import { useTranslation, Trans } from "react-i18next";
import { useGet, usePost } from "@/Hooks/UseApi"; import { useGet, usePost } from "@/Hooks/UseApi";
import type { QueueData } from "@/Types/Queue"; import type { QueueData } from "@/Types/Queue";
@@ -16,27 +15,16 @@ import { SPACING } from "@/Utils/Theme/constants";
export const TabQueue = () => { export const TabQueue = () => {
const theme = useTheme(); const theme = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const { const {
data: queueData, data: queueData,
isLoading, isLoading,
// error, // error,
refetch, refetch,
} = useGet<QueueData>( } = useGet<QueueData>("/queue/all-metrics", {}, { refreshInterval: 1000 });
`/queue/all-metrics?page=${page}&rowsPerPage=${rowsPerPage}`,
{},
{ refreshInterval: 1000, keepPreviousData: true }
);
const { post, loading: isFlushing } = usePost(); const { post, loading: isFlushing } = usePost();
const jobs = queueData?.jobs ?? []; const jobs = queueData?.jobs ?? [];
const jobsCount = queueData?.count ?? 0;
const workers = (queueData?.metrics?.workers ?? []).map((worker, idx) => ({
...worker,
id: idx,
}));
const metrics = queueData?.metrics ?? null; const metrics = queueData?.metrics ?? null;
const hasNeverRun = !isLoading && metrics !== null && (metrics.totalRuns ?? 0) === 0; const hasNeverRun = !isLoading && metrics !== null && (metrics.totalRuns ?? 0) === 0;
const noQueueData = !isLoading && metrics === null && jobs.length === 0; const noQueueData = !isLoading && metrics === null && jobs.length === 0;
@@ -58,34 +46,6 @@ export const TabQueue = () => {
return ( return (
<Stack gap={theme.spacing(16)}> <Stack gap={theme.spacing(16)}>
<Metrics metrics={metrics} /> <Metrics metrics={metrics} />
<Stack gap={theme.spacing(3)}>
<Stack gap={theme.spacing(1)}>
<Typography
variant="eyebrow"
color={theme.palette.text.secondary}
>
{t("pages.logs.workers")}
</Typography>
<Typography color={theme.palette.text.secondary}>
<Trans
i18nKey="pages.logs.workersExplainers"
components={{
highlight: (
<Box
component="span"
bgcolor={theme.palette.rowStatus.running}
color={theme.palette.text.primary}
px={SPACING.SM}
py={SPACING.XXS}
borderRadius={theme.shape.borderRadius}
/>
),
}}
/>
</Typography>
</Stack>
<TableWorkers queueWorkers={workers} />
</Stack>
<Stack gap={theme.spacing(3)}> <Stack gap={theme.spacing(3)}>
<Stack gap={theme.spacing(1)}> <Stack gap={theme.spacing(1)}>
<Typography <Typography
@@ -112,17 +72,7 @@ export const TabQueue = () => {
/> />
</Typography> </Typography>
</Stack> </Stack>
<TableJobs <TableJobs jobs={jobs} />
jobs={jobs}
count={jobsCount}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={(_, newPage) => setPage(newPage)}
onRowsPerPageChange={(event) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
}}
/>
</Stack> </Stack>
<Stack gap={theme.spacing(3)}> <Stack gap={theme.spacing(3)}>
<Stack gap={theme.spacing(1)}> <Stack gap={theme.spacing(1)}>
@@ -33,26 +33,6 @@ export const MongoStats = ({ diagnostics }: MongoStatsProps) => {
title={t("pages.logs.diagnostics.mongoDBStats.readyState")} title={t("pages.logs.diagnostics.mongoDBStats.readyState")}
subtitle={mongoStats.readyState.toString()} subtitle={mongoStats.readyState.toString()}
/> />
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.readsPerSecond")}
subtitle={mongoStats.readsPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.insertsPerSecond")}
subtitle={mongoStats.insertsPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.updatesPerSecond")}
subtitle={mongoStats.updatesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.deletesPerSecond")}
subtitle={mongoStats.deletesPerSecond.toFixed(0)}
/>
<StatBox
title={t("pages.logs.diagnostics.mongoDBStats.writesPerSecond")}
subtitle={mongoStats.writesPerSecond.toFixed(0)}
/>
<StatBox <StatBox
title={t("pages.logs.diagnostics.mongoDBStats.host")} title={t("pages.logs.diagnostics.mongoDBStats.host")}
subtitle={mongoStats.host} subtitle={mongoStats.host}
+65 -87
View File
@@ -1,4 +1,4 @@
import { Table, Pagination } from "@/Components/design-elements"; import { Table } from "@/Components/design-elements";
import { Typography, useTheme } from "@mui/material"; import { Typography, useTheme } from "@mui/material";
import prettyMilliseconds from "pretty-ms"; import prettyMilliseconds from "pretty-ms";
import { formatTimestamp } from "@/Utils/TimeUtils"; import { formatTimestamp } from "@/Utils/TimeUtils";
@@ -6,27 +6,14 @@ import { formatTimestamp } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue"; import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue";
import type { Header } from "@/Components/design-elements"; import type { Header } from "@/Components/design-elements";
import type { TablePaginationProps } from "@mui/material/TablePagination";
type QueueJobWithId = QueueJobSummary & { id: string | number }; type QueueJobWithId = QueueJobSummary & { id: string | number };
interface TableJobsProps { interface TableJobsProps {
jobs: QueueJobSummary[]; jobs: QueueJobSummary[];
count: number;
page: number;
rowsPerPage: number;
onPageChange: TablePaginationProps["onPageChange"];
onRowsPerPageChange: TablePaginationProps["onRowsPerPageChange"];
} }
export const TableJobs = ({ export const TableJobs = ({ jobs }: TableJobsProps) => {
jobs,
count,
page,
rowsPerPage,
onPageChange,
onRowsPerPageChange,
}: TableJobsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
@@ -42,15 +29,12 @@ export const TableJobs = ({
maxWidth: 220, maxWidth: 220,
}; };
const isRunning = (row: QueueJobWithId) =>
row.lockedBy !== null && row.lockedUntil !== null && row.lockedUntil > Date.now();
const headers: Header<QueueJobWithId>[] = [ const headers: Header<QueueJobWithId>[] = [
{ {
id: "state", id: "state",
content: t("pages.logs.table.headers.state"), content: t("pages.logs.table.headers.state"),
render: (row) => { render: (row) => {
if (isRunning(row)) { if (row.lockedAt) {
return t("common.labels.running"); return t("common.labels.running");
} }
if (row.monitorActive === true) { if (row.monitorActive === true) {
@@ -75,6 +59,18 @@ export const TableJobs = ({
</Typography> </Typography>
), ),
}, },
{
id: "url",
content: t("common.table.headers.url"),
render: (row) => (
<Typography
title={row.monitorUrl ?? ""}
sx={cellSx}
>
{row.monitorUrl}
</Typography>
),
},
{ {
id: "type", id: "type",
content: t("common.table.headers.type"), content: t("common.table.headers.type"),
@@ -83,57 +79,50 @@ export const TableJobs = ({
{ {
id: "interval", id: "interval",
content: t("common.table.headers.interval"), content: t("common.table.headers.interval"),
render: (row) => (
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
),
},
{
id: "lastFinishedAt",
content: t("pages.logs.table.headers.lastFinishedAt"),
render: (row) => { render: (row) => {
const lastFinishedAt = formatTimestamp(row.lastFinishedAt) ?? "-"; let interval;
return ( if (row.monitorId.toString().includes("geo")) {
<Typography interval = row.monitorGeoInterval ?? 0;
title={lastFinishedAt} } else {
sx={cellSx} interval = row.repeat ?? 0;
>
{lastFinishedAt}
</Typography>
);
},
},
{
id: "nextScheduledAt",
content: t("pages.logs.table.headers.nextScheduledAt"),
render: (row) => {
const nextScheduledAt = formatTimestamp(row.nextScheduledAt) ?? "-";
return (
<Typography
title={nextScheduledAt}
sx={cellSx}
>
{nextScheduledAt}
</Typography>
);
},
},
{
id: "lockedBy",
content: t("pages.logs.table.headers.lockedBy"),
render: (row) => {
const lockedBy = row.lockedBy;
if (!lockedBy) {
return <Typography sx={cellSx}>-</Typography>;
} }
return <Typography sx={cellSx}>{prettyMilliseconds(interval)}</Typography>;
const workerId = lockedBy.split(":")[2]; },
const shortWorkerId = workerId.slice(workerId.length - 8); },
{
id: "lastRun",
content: t("pages.logs.table.headers.lastRunAt"),
render: (row) => {
const v = formatTimestamp(row.lastRunAt) ?? "-";
return ( return (
<Typography <Typography
title={workerId} title={v}
sx={cellSx} sx={cellSx}
> >
{`...${shortWorkerId}`} {v}
</Typography>
);
},
},
{
id: "lastRunTook",
content: t("pages.logs.table.headers.lastRunTook"),
render: (row) => {
const value = row.lastRunTook ? prettyMilliseconds(row.lastRunTook) : "-";
return <Typography sx={cellSx}>{value}</Typography>;
},
},
{
id: "lockedAt",
content: t("pages.logs.table.headers.lockedAt"),
render: (row) => {
const v = formatTimestamp(row.lockedAt) ?? "-";
return (
<Typography
title={v}
sx={cellSx}
>
{v}
</Typography> </Typography>
); );
}, },
@@ -141,29 +130,18 @@ export const TableJobs = ({
]; ];
return ( return (
<> <Table
<Table headers={headers}
headers={headers} data={jobsWithId}
data={jobsWithId} getRowSx={(row) => ({
getRowSx={(row) => ({ ...(row.lockedAt && {
...(isRunning(row) && { "& td": { backgroundColor: theme.palette.rowStatus.running },
"& td": { backgroundColor: theme.palette.rowStatus.running }, }),
}), ...(row.monitorActive === false && {
...(row.monitorActive === false && { "& td": { backgroundColor: theme.palette.rowStatus.paused },
"& td": { backgroundColor: theme.palette.rowStatus.paused }, }),
}), })}
})} />
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={onPageChange}
onRowsPerPageChange={onRowsPerPageChange}
itemsOnPage={jobs.length}
/>
</>
); );
}; };
@@ -1,94 +0,0 @@
import { Table } from "@/Components/design-elements";
import { Typography, useTheme } from "@mui/material";
import { formatTimestamp } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
import type { QueueWorker } from "@/Types/Queue";
import type { Header } from "@/Components/design-elements";
type QueueWorkerWithId = QueueWorker & { id: number };
interface TableWorkersProps {
queueWorkers: QueueWorkerWithId[];
}
export const TableWorkers = ({ queueWorkers }: TableWorkersProps) => {
const { t } = useTranslation();
const theme = useTheme();
const cellSx = {
whiteSpace: "nowrap" as const,
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 220,
};
const headers: Header<QueueWorkerWithId>[] = [
{
id: "host",
content: t("pages.logs.table.headers.host"),
render: (row) => {
const host = row.workerId.split(":")[0];
return (
<Typography
fontFamily={theme.typography.fontFamilyMonospace}
sx={cellSx}
>
{host}
</Typography>
);
},
},
{
id: "processId",
content: t("pages.logs.table.headers.processId"),
render: (row) => {
const processId = row.workerId.split(":")[1];
return (
<Typography
fontFamily={theme.typography.fontFamilyMonospace}
sx={cellSx}
>
{processId}
</Typography>
);
},
},
{
id: "id",
content: t("pages.logs.table.headers.workerId"),
render: (row) => {
const workerId = row.workerId.split(":")[2];
const shortWorkerId = workerId.slice(workerId.length - 8);
return (
<Typography
fontFamily={theme.typography.fontFamilyMonospace}
title={workerId}
sx={cellSx}
>
{`...${shortWorkerId}`}
</Typography>
);
},
},
{
id: "mode",
content: t("pages.logs.table.headers.workerMode"),
render: (row) => <Typography sx={cellSx}>{row.mode}</Typography>,
},
{
id: "lastSeen",
content: t("pages.logs.table.headers.lastSeenAt"),
render: (row) => (
<Typography sx={cellSx}>{formatTimestamp(row.lastSeenAt)}</Typography>
),
},
];
return (
<Table
headers={headers}
data={queueWorkers}
/>
);
};
+1 -1
View File
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
const LogsPage = () => { const LogsPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<number>(1); const [activeTab, setActiveTab] = useState<number>(0);
return ( return (
<BasePage headerKey="logs"> <BasePage headerKey="logs">
<Tabs <Tabs
@@ -150,8 +150,7 @@ const NotificationsCreatePage = () => {
{watchedType !== "matrix" && {watchedType !== "matrix" &&
watchedType !== "telegram" && watchedType !== "telegram" &&
watchedType !== "pushover" && watchedType !== "pushover" &&
watchedType !== "twilio" && watchedType !== "twilio" && (
watchedType !== "ntfy" && (
<ConfigBox <ConfigBox
title={addressConfig.title} title={addressConfig.title}
subtitle={addressConfig.description} subtitle={addressConfig.description}
@@ -175,48 +174,6 @@ const NotificationsCreatePage = () => {
} }
/> />
)} )}
{watchedType === "ntfy" && (
<ConfigBox
title={t("pages.notifications.form.ntfy.title")}
subtitle={t("pages.notifications.form.ntfy.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="topic"
control={control}
defaultValue={"topic" in defaults ? defaults.topic : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "telegram" && ( {watchedType === "telegram" && (
<ConfigBox <ConfigBox
title={t("pages.notifications.form.telegram.title")} title={t("pages.notifications.form.telegram.title")}
+299 -412
View File
@@ -1,4 +1,4 @@
import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements"; import { BasePage, ConfigBox } from "@/Components/design-elements";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { logger } from "@/Utils/logger"; import { logger } from "@/Utils/logger";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
@@ -26,7 +26,6 @@ import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useStatusPageForm } from "@/Hooks/useStatusPageForm"; import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
import type { StatusPageFormData } from "@/Validation/statusPage"; import type { StatusPageFormData } from "@/Validation/statusPage";
import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statusPage";
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi"; import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor"; import type { Monitor } from "@/Types/Monitor";
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"; import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
@@ -87,27 +86,13 @@ const CreateStatusPage = () => {
defaultValues: defaults, defaultValues: defaults,
}); });
const { control, reset, handleSubmit, trigger } = form; const { control, reset, handleSubmit } = form;
// Reset form when defaults change (from fetched data) // Reset form when defaults change (from fetched data)
useEffect(() => { useEffect(() => {
reset(defaults); reset(defaults);
}, [defaults, reset]); }, [defaults, reset]);
// Multi-step wizard, create only. The configure (edit) flow keeps the flat
// form, where showStep() is always true.
const [currentStep, setCurrentStep] = useState(0);
const showStep = (step: number) => !isCreate || currentStep === step;
const totalSteps = statusPageStepCount();
const handleNext = async () => {
const isValid = await trigger(statusPageStepFieldsFor(currentStep));
if (isValid) {
setCurrentStep((step) => step + 1);
}
};
const handleBack = () => setCurrentStep((step) => step - 1);
const watchedMonitorIds: string[] = form.watch("monitors") ?? []; const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
const computedTypes: MonitorDisplayType[] = useMemo(() => { const computedTypes: MonitorDisplayType[] = useMemo(() => {
const selectedMonitors = (watchedMonitorIds ?? []) const selectedMonitors = (watchedMonitorIds ?? [])
@@ -151,18 +136,12 @@ const CreateStatusPage = () => {
fd.append("isPublished", String(data.isPublished)); fd.append("isPublished", String(data.isPublished));
if (data.companyName) fd.append("companyName", data.companyName); if (data.companyName) fd.append("companyName", data.companyName);
if (data.url) fd.append("url", data.url); if (data.url) fd.append("url", data.url);
if (data.customDomain !== undefined && data.customDomain !== null) {
fd.append("customDomain", data.customDomain);
} else {
fd.append("customDomain", "");
}
if (data.timezone) fd.append("timezone", data.timezone); if (data.timezone) fd.append("timezone", data.timezone);
if (data.color) fd.append("color", data.color); if (data.color) fd.append("color", data.color);
fd.append("showCharts", String(data.showCharts)); fd.append("showCharts", String(data.showCharts));
fd.append("showUptimePercentage", String(data.showUptimePercentage)); fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink)); fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure)); fd.append("showInfrastructure", String(data.showInfrastructure));
fd.append("customCSS", data.customCSS ?? "");
if (data.theme) fd.append("theme", data.theme); if (data.theme) fd.append("theme", data.theme);
if (data.themeMode) fd.append("themeMode", data.themeMode); if (data.themeMode) fd.append("themeMode", data.themeMode);
@@ -221,382 +200,315 @@ const CreateStatusPage = () => {
onSubmit={handleSubmit(onSubmit, onError)} onSubmit={handleSubmit(onSubmit, onError)}
> >
{!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />} {!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />}
{isCreate && ( <ConfigBox
<StepProgress title={t("pages.statusPages.form.access.title")}
steps={totalSteps} subtitle={t("pages.statusPages.form.access.description")}
current={currentStep} rightContent={
/> <Stack
)} direction="row"
{showStep(0) && ( alignItems="center"
<ConfigBox spacing={theme.spacing(SPACING.MD)}
title={t("pages.statusPages.form.access.title")} >
subtitle={t("pages.statusPages.form.access.description")}
rightContent={
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.MD)}
>
<Controller
name="isPublished"
control={control}
render={({ field }) => (
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
<Typography>
{t("pages.statusPages.form.access.option.published.name")}
</Typography>
</Stack>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.basicInfo.title")}
subtitle={t("pages.statusPages.form.basicInfo.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="companyName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.name.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<Controller
name="url"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.url.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<Controller
name="customDomain"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
fieldLabel={t(
"pages.statusPages.form.basicInfo.option.customDomain.label"
)}
placeholder={t(
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
)}
helperText={
fieldState.error?.message ||
t("pages.statusPages.form.basicInfo.option.customDomain.helper")
}
error={!!fieldState.error}
/>
)}
/>
</Stack>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.monitors.title")}
subtitle={t("pages.statusPages.form.monitors.description")}
rightContent={
<Controller <Controller
name="monitors" name="isPublished"
control={control}
render={({ field, fieldState }) => {
const selectedMonitors = field.value
.map((id: string) => monitors.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const reordered = Array.from(field.value);
const [removed] = reordered.splice(result.source.index, 1);
reordered.splice(result.destination.index, 0, removed);
field.onChange(reordered);
};
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={monitors}
getOptionLabel={(option: Monitor) => option.name}
value={selectedMonitors}
onChange={(_, newValue) => {
field.onChange(newValue.map((m: Monitor) => m.id));
}}
fieldLabel={t(
"pages.statusPages.form.monitors.option.monitors.label"
)}
renderInput={(params) => (
<TextField
{...params}
placeholder={
selectedMonitors.length === 0
? t(
"pages.statusPages.form.monitors.option.monitors.placeholder"
)
: ""
}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
{selectedMonitors.length > 0 && (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="monitors-list">
{(provided) => (
<Stack
{...provided.droppableProps}
ref={provided.innerRef}
>
{selectedMonitors.map((monitor, index) => (
<Draggable
key={monitor.id}
draggableId={monitor.id}
index={index}
>
{(provided) => (
<Stack
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
cursor: "grab",
"&:active": { cursor: "grabbing" },
}}
>
<GripVertical size={20} />
<Typography
flexGrow={1}
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value.filter(
(id: string) => id !== monitor.id
)
);
}}
aria-label="Remove monitor"
>
<Trash2 size={16} />
</IconButton>
</Stack>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
)}
</Stack>
);
}}
/>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.timezone.title")}
subtitle={t("pages.statusPages.form.timezone.description")}
rightContent={
<Controller
name="timezone"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<Autocomplete <SwitchComponent
options={timezones} checked={field.value ?? false}
getOptionLabel={(option: TimezoneOption) => option.name} onChange={(e) => field.onChange(e.target.checked)}
value={
timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null
}
onChange={(_, newValue: TimezoneOption | null) => {
field.onChange(newValue?._id ?? "");
}}
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
renderInput={(params) => (
<TextField
{...params}
placeholder={t(
"pages.statusPages.form.timezone.option.timezone.placeholder"
)}
/>
)}
/> />
)} )}
/> />
} <Typography>
/> {t("pages.statusPages.form.access.option.published.name")}
)} </Typography>
{showStep(1) && ( </Stack>
<ConfigBox }
title={t("pages.statusPages.form.appearance.title")} />
subtitle={t("pages.statusPages.form.appearance.description")} <ConfigBox
rightContent={ title={t("pages.statusPages.form.basicInfo.title")}
<Stack spacing={theme.spacing(LAYOUT.MD)}> subtitle={t("pages.statusPages.form.basicInfo.description")}
<Stack alignItems={"center"}> rightContent={
<Controller <Stack spacing={theme.spacing(LAYOUT.MD)}>
name="logo"
control={control}
render={({ field }) => (
<ImageUpload
src={field.value?.data}
onChange={(file) => {
if (file) {
field.onChange({
data: file.src,
contentType: file.file.type,
});
} else {
field.onChange(null);
}
}}
/>
)}
/>
</Stack>
<Controller
name="color"
control={control}
render={({ field }) => (
<ColorInput
format="hex"
value={field.value}
onChange={field.onChange}
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
/>
)}
/>
</Stack>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.theme.title")}
subtitle={t("pages.statusPages.form.theme.description")}
rightContent={
<Controller <Controller
name="theme" name="companyName"
control={control}
render={({ field: themeField }) => (
<Controller
name="themeMode"
control={control}
render={({ field: modeField }) => (
<ThemePicker
theme={themeField.value ?? "refined"}
themeMode={modeField.value ?? "auto"}
onThemeChange={themeField.onChange}
onThemeModeChange={modeField.onChange}
/>
)}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.customCSS.title")}
subtitle={t("pages.statusPages.form.customCSS.description")}
rightContent={
<Controller
name="customCSS"
control={control} control={control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<TextField <TextField
{...field} {...field}
multiline fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
rows={8}
fieldLabel={t(
"pages.statusPages.form.customCSS.option.customCSS.label"
)}
placeholder={t( placeholder={t(
"pages.statusPages.form.customCSS.option.customCSS.placeholder" "pages.statusPages.form.basicInfo.option.name.placeholder"
)} )}
error={!!fieldState.error} error={!!fieldState.error}
helperText={fieldState.error?.message} helperText={fieldState.error?.message}
/> />
)} )}
/> />
} <Controller
/> name="url"
)} control={control}
{showStep(1) && ( render={({ field, fieldState }) => (
<ConfigBox <TextField
title={t("pages.statusPages.form.features.title")} {...field}
subtitle={t("pages.statusPages.form.features.description")} fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
rightContent={ placeholder={t(
<Stack spacing={theme.spacing(LAYOUT.MD)}> "pages.statusPages.form.basicInfo.option.url.placeholder"
<Controller )}
name="showCharts" error={!!fieldState.error}
control={control} helperText={fieldState.error?.message}
render={({ field }) => ( />
<FormControlLabel )}
control={ />
<Checkbox </Stack>
checked={field.value} }
onChange={field.onChange} />
<ConfigBox
title={t("pages.statusPages.form.monitors.title")}
subtitle={t("pages.statusPages.form.monitors.description")}
rightContent={
<Controller
name="monitors"
control={control}
render={({ field, fieldState }) => {
const selectedMonitors = field.value
.map((id: string) => monitors.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const reordered = Array.from(field.value);
const [removed] = reordered.splice(result.source.index, 1);
reordered.splice(result.destination.index, 0, removed);
field.onChange(reordered);
};
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={monitors}
getOptionLabel={(option: Monitor) => option.name}
value={selectedMonitors}
onChange={(_, newValue) => {
field.onChange(newValue.map((m: Monitor) => m.id));
}}
fieldLabel={t(
"pages.statusPages.form.monitors.option.monitors.label"
)}
renderInput={(params) => (
<TextField
{...params}
placeholder={
selectedMonitors.length === 0
? t(
"pages.statusPages.form.monitors.option.monitors.placeholder"
)
: ""
}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/> />
} )}
label={t("pages.statusPages.form.features.option.showCharts.label")}
/> />
)} {selectedMonitors.length > 0 && (
/> <DragDropContext onDragEnd={handleDragEnd}>
<Controller <Droppable droppableId="monitors-list">
name="showInfrastructure" {(provided) => (
control={control} <Stack
render={({ field }) => ( {...provided.droppableProps}
<FormControlLabel ref={provided.innerRef}
control={ >
<Checkbox {selectedMonitors.map((monitor, index) => (
checked={field.value} <Draggable
onChange={field.onChange} key={monitor.id}
/> draggableId={monitor.id}
} index={index}
label={t( >
"pages.statusPages.form.features.option.showInfrastructure.label" {(provided) => (
<Stack
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
cursor: "grab",
"&:active": { cursor: "grabbing" },
}}
>
<GripVertical size={20} />
<Typography
flexGrow={1}
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value.filter(
(id: string) => id !== monitor.id
)
);
}}
aria-label="Remove monitor"
>
<Trash2 size={16} />
</IconButton>
</Stack>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
)}
</Stack>
);
}}
/>
}
/>
<ConfigBox
title={t("pages.statusPages.form.timezone.title")}
subtitle={t("pages.statusPages.form.timezone.description")}
rightContent={
<Controller
name="timezone"
control={control}
render={({ field }) => (
<Autocomplete
options={timezones}
getOptionLabel={(option: TimezoneOption) => option.name}
value={
timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null
}
onChange={(_, newValue: TimezoneOption | null) => {
field.onChange(newValue?._id ?? "");
}}
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
renderInput={(params) => (
<TextField
{...params}
placeholder={t(
"pages.statusPages.form.timezone.option.timezone.placeholder"
)} )}
/> />
)} )}
/> />
{/* <Controller )}
/>
}
/>
<ConfigBox
title={t("pages.statusPages.form.appearance.title")}
subtitle={t("pages.statusPages.form.appearance.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack alignItems={"center"}>
<Controller
name="logo"
control={control}
render={({ field }) => (
<ImageUpload
src={field.value?.data}
onChange={(file) => {
if (file) {
field.onChange({
data: file.src,
contentType: file.file.type,
});
} else {
field.onChange(null);
}
}}
/>
)}
/>
</Stack>
<Controller
name="color"
control={control}
render={({ field }) => (
<ColorInput
format="hex"
value={field.value}
onChange={field.onChange}
fieldLabel={t("pages.statusPages.form.appearance.option.color.label")}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.statusPages.form.theme.title")}
subtitle={t("pages.statusPages.form.theme.description")}
rightContent={
<Controller
name="theme"
control={control}
render={({ field: themeField }) => (
<Controller
name="themeMode"
control={control}
render={({ field: modeField }) => (
<ThemePicker
theme={themeField.value ?? "refined"}
themeMode={modeField.value ?? "auto"}
onThemeChange={themeField.onChange}
onThemeModeChange={modeField.onChange}
/>
)}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="showCharts"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t("pages.statusPages.form.features.option.showCharts.label")}
/>
)}
/>
<Controller
name="showInfrastructure"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t(
"pages.statusPages.form.features.option.showInfrastructure.label"
)}
/>
)}
/>
{/* <Controller
name="showUptimePercentage" name="showUptimePercentage"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
@@ -630,46 +542,21 @@ const CreateStatusPage = () => {
/> />
)} )}
/> */} /> */}
</Stack> </Stack>
} }
/> />
)}
<Stack <Stack
direction="row" direction="row"
justifyContent={isCreate ? "space-between" : "flex-end"} justifyContent="flex-end"
> >
{isCreate && ( <Button
<Button loading={isSubmitting}
type="button" type="submit"
variant="outlined" variant="contained"
color="secondary" color="primary"
disabled={currentStep === 0} >
onClick={handleBack} {t("common.buttons.save")}
> </Button>
{t("common.buttons.back")}
</Button>
)}
{isCreate && currentStep < totalSteps - 1 ? (
<Button
key="wizard-next"
type="button"
variant="contained"
color="primary"
onClick={handleNext}
>
{t("common.buttons.next")}
</Button>
) : (
<Button
key="wizard-save"
loading={isSubmitting}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
)}
</Stack> </Stack>
<Dialog <Dialog
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
@@ -8,8 +8,7 @@ import { Settings, ExternalLink } from "lucide-react";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { type StatusPage } from "@/Types/StatusPage"; import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
import { getStatusPagePublicUrl } from "@/Utils/statusPageUrl";
interface HeaderStatusPageControlsProps { interface HeaderStatusPageControlsProps {
isAdmin: boolean; isAdmin: boolean;
@@ -51,7 +50,7 @@ export const HeaderStatusPageControls = ({
<Typography <Typography
onClick={() => { onClick={() => {
window.open( window.open(
getStatusPagePublicUrl(statusPage), `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`,
"_blank", "_blank",
"noopener,noreferrer" "noopener,noreferrer"
); );
+14 -45
View File
@@ -2,7 +2,6 @@ import { BasePage, BaseFallback } from "@/Components/design-elements";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -15,13 +14,6 @@ import {
type StatusPageResponse, type StatusPageResponse,
type StatusPageTheme, type StatusPageTheme,
} from "@/Types/StatusPage"; } from "@/Types/StatusPage";
import {
buildStatusPageApiPath,
getStatusPagePreviewUrl,
getStatusPagePublicUrl,
isCustomDomainHost,
} from "@/Utils/statusPageUrl";
import { cssReferencesExternalResource } from "@/Utils/customCss";
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls"; import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import { import {
@@ -41,7 +33,6 @@ import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero";
import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles"; import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles";
import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader"; import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader";
import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero"; import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero";
import { minimalStyles } from "@/Pages/StatusPage/Status/themes/minimal/styles";
const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = { const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
refined: { refined: {
@@ -67,11 +58,6 @@ const THEME_CONFIGS: Record<StatusPageTheme, ThemeConfig<any>> = {
HeroSlot: EditorialHero, HeroSlot: EditorialHero,
overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" }, overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" },
}, },
minimal: {
createStyles: minimalStyles,
HeaderSlot: RefinedHeader,
HeroSlot: RefinedHero,
},
}; };
const StatusPageView = () => { const StatusPageView = () => {
@@ -81,14 +67,9 @@ const StatusPageView = () => {
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const location = useLocation(); const location = useLocation();
const onCustomDomainHost = isCustomDomainHost(); const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const isPublic =
onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const apiUrl = buildStatusPageApiPath({ const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
url,
useCustomDomain: onCustomDomainHost,
});
const { data, isLoading, error } = useGet<StatusPageResponse>( const { data, isLoading, error } = useGet<StatusPageResponse>(
apiUrl, apiUrl,
@@ -130,33 +111,30 @@ const StatusPageView = () => {
); );
} }
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const themedRenderer = (
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
);
// Public route: render directly on the viewport, themed background covers everything. // Public route: render directly on the viewport, themed background covers everything.
if (isPublic) { if (isPublic) {
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const customCss =
statusPage.customCSS && !cssReferencesExternalResource(statusPage.customCSS)
? statusPage.customCSS
: "";
return ( return (
<StatusPageThemeProvider <StatusPageThemeProvider
theme={statusPage.theme} theme={statusPage.theme}
themeMode={statusPage.themeMode} themeMode={statusPage.themeMode}
timezone={statusPage.timezone} timezone={statusPage.timezone}
brandColor={statusPage.color}
paintBody paintBody
> >
{customCss && <style>{customCss}</style>} {themedRenderer}
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
</StatusPageThemeProvider> </StatusPageThemeProvider>
); );
} }
const publicUrl = getStatusPagePublicUrl(statusPage); const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
const previewUrl = getStatusPagePreviewUrl(statusPage);
return ( return (
<BasePage <BasePage
loading={isLoading} loading={isLoading}
@@ -175,16 +153,7 @@ const StatusPageView = () => {
timezone={statusPage.timezone} timezone={statusPage.timezone}
transparent transparent
> >
<BrowserFrame url={publicUrl}> <BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
<Box
component="iframe"
src={previewUrl}
title={t("pages.statusPages.preview.title")}
flex={1}
width="100%"
border={0}
/>
</BrowserFrame>
</StatusPageThemeProvider> </StatusPageThemeProvider>
</BasePage> </BasePage>
); );
@@ -64,13 +64,7 @@ export const BrowserFrame = ({ url, children }: Props) => {
{url} {url}
</Box> </Box>
</Box> </Box>
<Box <Box sx={{ flex: 1, minHeight: 0 }}>{children}</Box>
display="flex"
flexDirection="column"
sx={{ flex: 1, minHeight: 0 }}
>
{children}
</Box>
</Box> </Box>
); );
}; };
@@ -6,7 +6,6 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { alpha, darken } from "@mui/material/styles";
import { import {
DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME,
resolveStatusPageTheme, resolveStatusPageTheme,
@@ -37,15 +36,6 @@ interface Props {
theme?: StatusPageTheme; theme?: StatusPageTheme;
themeMode?: StatusPageThemeMode; themeMode?: StatusPageThemeMode;
timezone?: string; timezone?: string;
/**
* Brand color picked in the status-page appearance settings. When
* provided, it feeds `tokens.brand` / `brandStrong` / `brandSoft`,
* which theme styles use for accent surfaces (hero, active tabs,
* links). It does NOT recolor the semantic `up`/OK status color, so
* a red or orange brand cannot make the "all operational" indicator
* signal downtime.
*/
brandColor?: string;
/** /**
* When true, paint the document body with the theme background so it * When true, paint the document body with the theme background so it
* covers beyond the provider's wrapper (useful on the public route * covers beyond the provider's wrapper (useful on the public route
@@ -64,32 +54,10 @@ interface Props {
children: ReactNode; children: ReactNode;
} }
const HEX_COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
// Resolve the brand triple that theme styles read from. A customer's
// picked color (when valid) overrides the theme's default; otherwise
// the theme's own `brand*` values win, and finally we fall back to the
// `up*` triple so unmigrated theme styles have something to consume.
const resolveBrandTokens = (
tokens: StatusPageThemeTokens,
brandColor: string | undefined
): StatusPageThemeTokens => {
const validOverride = brandColor && HEX_COLOR.test(brandColor) ? brandColor : undefined;
const brand = validOverride ?? tokens.brand ?? tokens.up;
const brandStrong = validOverride
? darken(validOverride, 0.2)
: (tokens.brandStrong ?? tokens.upStrong);
const brandSoft = validOverride
? alpha(validOverride, 0.15)
: (tokens.brandSoft ?? tokens.upSoft);
return { ...tokens, brand, brandStrong, brandSoft };
};
export const StatusPageThemeProvider = ({ export const StatusPageThemeProvider = ({
theme, theme,
themeMode, themeMode,
timezone, timezone,
brandColor,
paintBody = false, paintBody = false,
transparent = false, transparent = false,
children, children,
@@ -141,11 +109,7 @@ export const StatusPageThemeProvider = ({
const resolvedMode: ResolvedMode = const resolvedMode: ResolvedMode =
resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode; resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
const baseTokens = themeTokens[resolvedTheme][resolvedMode]; const tokens = themeTokens[resolvedTheme][resolvedMode];
const tokens = useMemo(
() => resolveBrandTokens(baseTokens, brandColor),
[baseTokens, brandColor]
);
const value = useMemo( const value = useMemo(
() => ({ () => ({
@@ -99,9 +99,9 @@ export const boldStyles = (
}, },
top: { top: {
alignItems: { xs: "flex-start", md: "center" }, display: "flex",
alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: "12px",
mb: "36px", mb: "36px",
}, },
brand: { brand: {
@@ -130,7 +130,7 @@ export const boldStyles = (
hero: { hero: {
position: "relative", position: "relative",
borderRadius: "22px", borderRadius: "22px",
padding: { xs: "24px 20px", md: "36px 36px 32px" }, padding: "36px 36px 32px",
mb: "28px", mb: "28px",
background: isDark ? heroBgDark : heroBgLight, background: isDark ? heroBgDark : heroBgLight,
border: `1px solid ${tokens.border}`, border: `1px solid ${tokens.border}`,
@@ -149,14 +149,13 @@ export const boldStyles = (
}, },
heroTitle: { heroTitle: {
m: 0, m: 0,
fontSize: { xs: 24, md: 34 }, fontSize: 34,
fontWeight: 800, fontWeight: 800,
letterSpacing: "-0.02em", letterSpacing: "-0.02em",
lineHeight: 1.1, lineHeight: 1.1,
color: tokens.text, color: tokens.text,
display: "flex", display: "flex",
alignItems: { xs: "flex-start", md: "center" }, alignItems: "center",
flexWrap: "wrap",
gap: "10px", gap: "10px",
}, },
heroCheck: (tone) => ({ heroCheck: (tone) => ({
@@ -172,15 +171,9 @@ export const boldStyles = (
}), }),
heroSub: { mt: "10px", mb: 0, color: tokens.textMuted, fontSize: 14 }, heroSub: { mt: "10px", mb: 0, color: tokens.textMuted, fontSize: 14 },
chartSwitchWrap: { chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "16px" },
display: "flex",
justifyContent: { xs: "center", md: "flex-end" },
mb: "16px",
},
chartSwitch: { chartSwitch: {
display: "inline-flex", display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.border}`, border: `1px solid ${tokens.border}`,
borderRadius: "999px", borderRadius: "999px",
background: tokens.surface, background: tokens.surface,
@@ -188,20 +181,16 @@ export const boldStyles = (
gap: "2px", gap: "2px",
}, },
chartSwitchButton: (active) => ({ chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
border: 0, border: 0,
background: active ? tokens.upSoft : "transparent", background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit", fontFamily: "inherit",
fontSize: 11, fontSize: 11,
padding: { xs: "8px 6px", md: "8px 18px" }, padding: "8px 18px",
cursor: "pointer", cursor: "pointer",
color: active ? tokens.up : tokens.textMuted, color: active ? tokens.up : tokens.textMuted,
fontWeight: 700, fontWeight: 700,
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: { xs: "0.04em", md: "0.12em" }, letterSpacing: "0.12em",
transition: "background 0.2s, color 0.2s", transition: "background 0.2s, color 0.2s",
borderRadius: "999px", borderRadius: "999px",
"&:hover": { color: active ? tokens.up : tokens.text }, "&:hover": { color: active ? tokens.up : tokens.text },
@@ -228,21 +217,13 @@ export const boldStyles = (
}, },
cardRow: { cardRow: {
display: "grid", display: "grid",
alignItems: { xs: "start", md: "center" }, gridTemplateColumns: "1fr auto",
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" }, alignItems: "center",
gridTemplateAreas: { gap: "24px",
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "24px" },
rowGap: { xs: "10px", md: "6px" },
p: "22px 28px", p: "22px 28px",
}, },
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } }, cardLeft: { minWidth: 0 },
monitorName: { monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontWeight: 700, fontWeight: 700,
fontSize: 17, fontSize: 17,
letterSpacing: "-0.01em", letterSpacing: "-0.01em",
@@ -252,9 +233,10 @@ export const boldStyles = (
whiteSpace: "nowrap", whiteSpace: "nowrap",
}, },
monitorMeta: { monitorMeta: {
gridArea: "meta", display: "flex",
gap: "10px", gap: "10px",
alignItems: { xs: "flex-start", md: "center" }, alignItems: "center",
mt: "6px",
flexWrap: "wrap", flexWrap: "wrap",
}, },
pill: { pill: {
@@ -291,8 +273,6 @@ export const boldStyles = (
}, },
badge: (tone) => ({ badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontSize: 11, fontSize: 11,
fontWeight: 800, fontWeight: 800,
padding: "6px 14px", padding: "6px 14px",
@@ -316,10 +296,10 @@ export const boldStyles = (
}), }),
heatmap: { heatmap: {
padding: { xs: "0 12px 18px", md: "0 28px 22px" }, padding: "0 28px 22px",
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "3px" }, gap: "3px",
height: 48, height: 48,
}, },
heatmapCell: (kind) => ({ heatmapCell: (kind) => ({
@@ -332,10 +312,10 @@ export const boldStyles = (
}), }),
histogram: { histogram: {
padding: { xs: "0 12px", md: "0 28px" }, padding: "0 28px",
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "3px" }, gap: "3px",
alignItems: "flex-end", alignItems: "flex-end",
height: 48, height: 48,
}, },
@@ -90,9 +90,9 @@ export const editorialStyles = (
}, },
top: { top: {
alignItems: { xs: "flex-start", md: "flex-end" }, display: "flex",
alignItems: "flex-end",
justifyContent: "space-between", justifyContent: "space-between",
gap: "12px",
mb: "48px", mb: "48px",
borderBottom: `2px solid ${tokens.text}`, borderBottom: `2px solid ${tokens.text}`,
pb: "18px", pb: "18px",
@@ -119,7 +119,7 @@ export const editorialStyles = (
}, },
statusLine: { statusLine: {
fontSize: { xs: 18, md: 22 }, fontSize: 22,
fontWeight: 400, fontWeight: 400,
m: 0, m: 0,
mb: "8px", mb: "8px",
@@ -144,32 +144,26 @@ export const editorialStyles = (
letterSpacing: "0.12em", letterSpacing: "0.12em",
color: tokens.textMuted, color: tokens.textMuted,
m: 0, m: 0,
mb: { xs: "32px", md: "56px" }, mb: "56px",
}, },
chartSwitchWrap: { chartSwitchWrap: {
display: "flex", display: "flex",
justifyContent: { xs: "center", md: "flex-end" }, justifyContent: "flex-end",
mb: "20px", mb: "20px",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK, fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
}, },
chartSwitch: { chartSwitch: {
display: "inline-flex", display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.text}`, border: `1px solid ${tokens.text}`,
"& > button + button": { borderLeft: `1px solid ${tokens.text}` }, "& > button + button": { borderLeft: `1px solid ${tokens.text}` },
}, },
chartSwitchButton: (active) => ({ chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
fontFamily: "inherit", fontFamily: "inherit",
fontSize: 10, fontSize: 10,
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: { xs: "0.05em", md: "0.2em" }, letterSpacing: "0.2em",
padding: { xs: "6px 6px", md: "6px 14px" }, padding: "6px 14px",
border: 0, border: 0,
background: active ? tokens.text : "transparent", background: active ? tokens.text : "transparent",
color: active ? tokens.bg : tokens.text, color: active ? tokens.bg : tokens.text,
@@ -194,20 +188,12 @@ export const editorialStyles = (
}, },
cardRow: { cardRow: {
display: "grid", display: "grid",
alignItems: { xs: "start", md: "baseline" }, gridTemplateColumns: "1fr auto",
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" }, alignItems: "baseline",
gridTemplateAreas: { gap: "20px",
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "20px" },
rowGap: { xs: "10px", md: "6px" },
}, },
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } }, cardLeft: { minWidth: 0 },
monitorName: { monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontSize: 20, fontSize: 20,
fontWeight: 700, fontWeight: 700,
letterSpacing: "-0.015em", letterSpacing: "-0.015em",
@@ -217,13 +203,14 @@ export const editorialStyles = (
whiteSpace: "nowrap", whiteSpace: "nowrap",
}, },
monitorMeta: { monitorMeta: {
gridArea: "meta",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK, fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 12, fontSize: 12,
color: tokens.textMuted, color: tokens.textMuted,
mt: "6px",
display: "flex",
gap: "12px", gap: "12px",
flexWrap: "wrap", flexWrap: "wrap",
alignItems: { xs: "flex-start", md: "center" }, alignItems: "center",
}, },
pill: { pill: {
textTransform: "uppercase", textTransform: "uppercase",
@@ -247,8 +234,6 @@ export const editorialStyles = (
}, },
badge: (tone) => ({ badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontFamily: EDITORIAL_SECONDARY_SANS_STACK, fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 10, fontSize: 10,
fontWeight: 700, fontWeight: 700,
@@ -265,7 +250,7 @@ export const editorialStyles = (
mt: "16px", mt: "16px",
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "2px" }, gap: "2px",
height: 40, height: 40,
}, },
heatmapCell: (kind) => ({ heatmapCell: (kind) => ({
@@ -277,7 +262,7 @@ export const editorialStyles = (
mt: "16px", mt: "16px",
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "2px" }, gap: "2px",
alignItems: "flex-end", alignItems: "flex-end",
height: 40, height: 40,
}, },
@@ -1,66 +0,0 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import type { OverallTone } from "../shared/overallStatus";
import { refinedStyles, type RefinedStyles } from "../refined/styles";
type FlatSx = Exclude<SxProps<Theme>, readonly unknown[] | ((theme: Theme) => unknown)>;
const extend = (base: SxProps<Theme>, overrides: SxProps<Theme>): SxProps<Theme> => [
base as FlatSx,
overrides as FlatSx,
];
export const minimalStyles = (
tokens: StatusPageThemeTokens,
isDark: boolean
): RefinedStyles => {
const base = refinedStyles(tokens, isDark);
return {
...base,
page: extend(base.page, {
maxWidth: 820,
p: { xs: "32px 16px 64px", md: "48px 20px 72px" },
}),
top: extend(base.top, {
mb: "44px",
pb: "16px",
borderBottom: `1px solid ${tokens.border}`,
}),
logoMono: extend(base.logoMono, {
borderRadius: "50%",
background: tokens.text,
}),
hero: extend(base.hero, {
alignItems: "center",
background: "transparent",
border: 0,
boxShadow: "none",
p: 0,
mb: "40px",
}),
heroRow: extend(base.heroRow, { justifyContent: "center" }),
heroTitle: extend(base.heroTitle, {
flex: "initial",
fontSize: { xs: 22, md: 28 },
fontWeight: 650,
textAlign: "center",
}),
heroIcon: (_tone: OverallTone) => ({ display: "none" }),
heroSub: extend(base.heroSub, { textAlign: "center", mt: "4px" }),
chartSwitch: extend(base.chartSwitch, { borderRadius: tokens.radius }),
card: extend(base.card, {
boxShadow: "none",
transition: "none",
"&:hover": { transform: "none", boxShadow: "none" },
}),
cardRow: extend(base.cardRow, { p: "14px 16px" }),
badge: (tone) =>
extend(base.badge(tone), {
background: "transparent",
p: 0,
}),
gauge: extend(base.gauge, { borderRadius: tokens.radius }),
footer: extend(base.footer, { mt: "48px", fontSize: 11 }),
};
};
@@ -11,24 +11,24 @@ export const ModernHero = ({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Box sx={styles.hero}> <Box sx={styles.hero}>
<Box sx={styles.heroRow}> <Box sx={styles.pulse(overall.tone)} />
<Box sx={styles.pulse(overall.tone)} /> <Box sx={styles.statusCopy}>
<Box <Box
component="h1" component="h1"
sx={styles.heroTitle} sx={styles.heroTitle}
> >
{overall.message} {overall.message}
</Box> </Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box> <Box
</Box> component="p"
<Box sx={styles.heroSub}
component="p" >
sx={styles.heroSub} {t("pages.statusPages.statusBar.monitoringSummary", {
> count: monitorCount,
{t("pages.statusPages.statusBar.monitoringSummary", { })}
count: monitorCount, </Box>
})}
</Box> </Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box> </Box>
); );
}; };
@@ -17,7 +17,7 @@ export interface ModernStyles {
logoImg: SxProps<Theme>; logoImg: SxProps<Theme>;
hero: SxProps<Theme>; hero: SxProps<Theme>;
pulse: (tone: OverallTone) => SxProps<Theme>; pulse: (tone: OverallTone) => SxProps<Theme>;
heroRow: SxProps<Theme>; statusCopy: SxProps<Theme>;
heroTitle: SxProps<Theme>; heroTitle: SxProps<Theme>;
heroSub: SxProps<Theme>; heroSub: SxProps<Theme>;
heroIcon: (tone: OverallTone) => SxProps<Theme>; heroIcon: (tone: OverallTone) => SxProps<Theme>;
@@ -117,9 +117,9 @@ export const modernStyles = (
}, },
top: { top: {
alignItems: { xs: "flex-start", md: "center" }, display: "flex",
alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: "12px",
mb: "32px", mb: "32px",
}, },
brand: { brand: {
@@ -148,11 +148,11 @@ export const modernStyles = (
hero: { hero: {
position: "relative", position: "relative",
borderRadius: "20px", borderRadius: "20px",
padding: { xs: "20px", md: "28px 32px" }, padding: "28px 32px",
mb: "24px", mb: "24px",
display: "flex", display: "flex",
flexDirection: "column", alignItems: "center",
gap: "4px", gap: "20px",
background: tokens.surface, background: tokens.surface,
border: `1px solid ${tokens.border}`, border: `1px solid ${tokens.border}`,
boxShadow: cardShadow, boxShadow: cardShadow,
@@ -182,37 +182,25 @@ export const modernStyles = (
}, },
}; };
}, },
heroRow: { statusCopy: { flex: 1, minWidth: 0 },
display: "flex",
alignItems: "center",
gap: { xs: "14px", md: "20px" },
},
heroTitle: { heroTitle: {
m: 0, m: 0,
flex: 1, mb: "4px",
minWidth: 0, fontSize: 22,
fontSize: { xs: 18, md: 22 },
fontWeight: 700, fontWeight: 700,
letterSpacing: "-0.02em", letterSpacing: "-0.02em",
color: tokens.text, color: tokens.text,
}, },
heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 }, heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
heroIcon: (tone) => ({ heroIcon: (tone) => ({
display: { xs: "none", md: "flex" },
color: toneColor(tone, tokens), color: toneColor(tone, tokens),
display: "flex",
alignItems: "center", alignItems: "center",
flexShrink: 0,
}), }),
chartSwitchWrap: { chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "14px" },
display: "flex",
justifyContent: { xs: "center", md: "flex-end" },
mb: "14px",
},
chartSwitch: { chartSwitch: {
display: "inline-flex", display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.border}`, border: `1px solid ${tokens.border}`,
borderRadius: "999px", borderRadius: "999px",
background: tokens.surface, background: tokens.surface,
@@ -220,20 +208,16 @@ export const modernStyles = (
gap: "2px", gap: "2px",
}, },
chartSwitchButton: (active) => ({ chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
border: 0, border: 0,
background: active ? tokens.upSoft : "transparent", background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit", fontFamily: "inherit",
fontSize: 11, fontSize: 11,
padding: { xs: "6px 6px", md: "6px 16px" }, padding: "6px 16px",
cursor: "pointer", cursor: "pointer",
color: active ? tokens.up : tokens.textMuted, color: active ? tokens.up : tokens.textMuted,
fontWeight: 600, fontWeight: 600,
textTransform: "uppercase", textTransform: "uppercase",
letterSpacing: { xs: "0.04em", md: "0.08em" }, letterSpacing: "0.08em",
transition: "background 0.2s, color 0.2s", transition: "background 0.2s, color 0.2s",
borderRadius: "999px", borderRadius: "999px",
"&:hover": { color: active ? tokens.up : tokens.text }, "&:hover": { color: active ? tokens.up : tokens.text },
@@ -272,21 +256,13 @@ export const modernStyles = (
}, },
cardRow: { cardRow: {
display: "grid", display: "grid",
alignItems: { xs: "start", md: "center" }, gridTemplateColumns: "1fr auto",
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" }, alignItems: "center",
gridTemplateAreas: { gap: "20px",
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "20px" },
rowGap: { xs: "10px", md: "6px" },
p: "18px 24px", p: "18px 24px",
}, },
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } }, cardLeft: { minWidth: 0 },
monitorName: { monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontWeight: 600, fontWeight: 600,
fontSize: 15, fontSize: 15,
letterSpacing: "-0.005em", letterSpacing: "-0.005em",
@@ -296,9 +272,10 @@ export const modernStyles = (
whiteSpace: "nowrap", whiteSpace: "nowrap",
}, },
monitorMeta: { monitorMeta: {
gridArea: "meta", display: "flex",
gap: "10px", gap: "10px",
alignItems: { xs: "flex-start", md: "center" }, alignItems: "center",
mt: "6px",
flexWrap: "wrap", flexWrap: "wrap",
}, },
pill: { pill: {
@@ -322,8 +299,6 @@ export const modernStyles = (
}, },
badge: (tone) => ({ badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontSize: 11, fontSize: 11,
fontWeight: 700, fontWeight: 700,
padding: "5px 12px", padding: "5px 12px",
@@ -344,10 +319,10 @@ export const modernStyles = (
}), }),
heatmap: { heatmap: {
padding: { xs: "0 12px 16px", md: "0 24px 20px" }, padding: "0 24px 20px",
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "3px" }, gap: "3px",
height: 46, height: 46,
}, },
heatmapCell: (kind) => ({ heatmapCell: (kind) => ({
@@ -359,10 +334,10 @@ export const modernStyles = (
}), }),
histogram: { histogram: {
padding: { xs: "0 12px", md: "0 24px" }, padding: "0 24px",
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "3px" }, gap: "3px",
alignItems: "flex-end", alignItems: "flex-end",
height: 46, height: 46,
}, },
@@ -11,24 +11,24 @@ export const RefinedHero = ({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Box sx={styles.hero}> <Box sx={styles.hero}>
<Box sx={styles.heroRow}> <Box sx={styles.statusDot(overall.tone)} />
<Box sx={styles.statusDot(overall.tone)} /> <Box sx={styles.statusCopy}>
<Box <Box
component="h1" component="h1"
sx={styles.heroTitle} sx={styles.heroTitle}
> >
{overall.message} {overall.message}
</Box> </Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box> <Box
</Box> component="p"
<Box sx={styles.heroSub}
component="p" >
sx={styles.heroSub} {t("pages.statusPages.statusBar.monitoringSummary", {
> count: monitorCount,
{t("pages.statusPages.statusBar.monitoringSummary", { })}
count: monitorCount, </Box>
})}
</Box> </Box>
<Box sx={styles.heroIcon(overall.tone)}>{overall.icon}</Box>
</Box> </Box>
); );
}; };
@@ -17,7 +17,7 @@ export interface RefinedStyles {
company: SxProps<Theme>; company: SxProps<Theme>;
hero: SxProps<Theme>; hero: SxProps<Theme>;
statusDot: (tone: OverallTone) => SxProps<Theme>; statusDot: (tone: OverallTone) => SxProps<Theme>;
heroRow: SxProps<Theme>; statusCopy: SxProps<Theme>;
heroTitle: SxProps<Theme>; heroTitle: SxProps<Theme>;
heroSub: SxProps<Theme>; heroSub: SxProps<Theme>;
heroIcon: (tone: OverallTone) => SxProps<Theme>; heroIcon: (tone: OverallTone) => SxProps<Theme>;
@@ -102,9 +102,9 @@ export const refinedStyles = (
}, },
top: { top: {
alignItems: { xs: "flex-start", md: "center" }, display: "flex",
alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: "12px",
mb: "28px", mb: "28px",
}, },
brand: { brand: {
@@ -119,7 +119,7 @@ export const refinedStyles = (
width: 28, width: 28,
height: 28, height: 28,
borderRadius: "8px", borderRadius: "8px",
background: tokens.brand, background: tokens.up,
display: "grid", display: "grid",
placeItems: "center", placeItems: "center",
color: "#fff", color: "#fff",
@@ -133,10 +133,10 @@ export const refinedStyles = (
background: tokens.surface, background: tokens.surface,
border: `1px solid ${tokens.border}`, border: `1px solid ${tokens.border}`,
borderRadius: tokens.radius, borderRadius: tokens.radius,
padding: { xs: "18px 20px", md: "22px 24px" }, padding: "22px 24px",
display: "flex", display: "flex",
flexDirection: "column", alignItems: "center",
gap: "4px", gap: "16px",
boxShadow: cardShadow, boxShadow: cardShadow,
mb: "20px", mb: "20px",
}, },
@@ -148,15 +148,10 @@ export const refinedStyles = (
boxShadow: `0 0 0 4px ${toneSoft(tone, tokens)}`, boxShadow: `0 0 0 4px ${toneSoft(tone, tokens)}`,
flexShrink: 0, flexShrink: 0,
}), }),
heroRow: { statusCopy: { flex: 1, minWidth: 0 },
display: "flex",
alignItems: "center",
gap: { xs: "12px", md: "16px" },
},
heroTitle: { heroTitle: {
m: 0, m: 0,
flex: 1, mb: "2px",
minWidth: 0,
fontSize: 17, fontSize: 17,
fontWeight: 600, fontWeight: 600,
letterSpacing: "-0.01em", letterSpacing: "-0.01em",
@@ -164,21 +159,14 @@ export const refinedStyles = (
}, },
heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 }, heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
heroIcon: (tone) => ({ heroIcon: (tone) => ({
display: { xs: "none", md: "flex" },
color: toneColor(tone, tokens), color: toneColor(tone, tokens),
display: "flex",
alignItems: "center", alignItems: "center",
flexShrink: 0,
}), }),
chartSwitchWrap: { chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "12px" },
display: "flex",
justifyContent: { xs: "center", md: "flex-end" },
mb: "12px",
},
chartSwitch: { chartSwitch: {
display: "inline-flex", display: "inline-flex",
width: { xs: "100%", md: "auto" },
maxWidth: "100%",
border: `1px solid ${tokens.border}`, border: `1px solid ${tokens.border}`,
borderRadius: "8px", borderRadius: "8px",
background: tokens.surface, background: tokens.surface,
@@ -186,21 +174,17 @@ export const refinedStyles = (
gap: "2px", gap: "2px",
}, },
chartSwitchButton: (active) => ({ chartSwitchButton: (active) => ({
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
textAlign: "center",
border: 0, border: 0,
background: active ? tokens.brandSoft : "transparent", background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit", fontFamily: "inherit",
fontSize: 11, fontSize: 11,
padding: { xs: "5px 6px", md: "5px 14px" }, padding: "5px 14px",
cursor: "pointer", cursor: "pointer",
color: active ? tokens.brand : tokens.textMuted, color: active ? tokens.up : tokens.textMuted,
borderRadius: "5px", borderRadius: "5px",
transition: "background 0.15s ease, color 0.15s ease", transition: "background 0.15s ease, color 0.15s ease",
fontWeight: active ? 600 : 500, fontWeight: active ? 600 : 500,
"&:hover": { color: active ? tokens.brand : tokens.text }, "&:hover": { color: active ? tokens.up : tokens.text },
}), }),
monitorList: { monitorList: {
@@ -223,21 +207,13 @@ export const refinedStyles = (
}, },
cardRow: { cardRow: {
display: "grid", display: "grid",
alignItems: { xs: "start", md: "center" }, gridTemplateColumns: "1fr auto",
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "1fr auto" }, alignItems: "center",
gridTemplateAreas: { gap: "16px",
xs: `"name" "status" "meta"`,
md: `"name name" "meta status"`,
},
columnGap: { md: "16px" },
rowGap: { xs: "10px", md: "4px" },
p: "16px 20px", p: "16px 20px",
}, },
cardLeft: { minWidth: 0, flex: 1, width: { xs: "100%", md: "auto" } }, cardLeft: { minWidth: 0 },
monitorName: { monitorName: {
gridArea: "name",
minWidth: 0,
width: { xs: "100%", md: "auto" },
fontWeight: 600, fontWeight: 600,
fontSize: 14, fontSize: 14,
letterSpacing: "-0.005em", letterSpacing: "-0.005em",
@@ -247,9 +223,10 @@ export const refinedStyles = (
whiteSpace: "nowrap", whiteSpace: "nowrap",
}, },
monitorMeta: { monitorMeta: {
gridArea: "meta", display: "flex",
gap: "10px", gap: "10px",
alignItems: { xs: "flex-start", md: "center" }, alignItems: "center",
mt: "4px",
flexWrap: "wrap", flexWrap: "wrap",
}, },
pill: { pill: {
@@ -259,9 +236,9 @@ export const refinedStyles = (
}, },
pillHardware: { pillHardware: {
...pillBase, ...pillBase,
color: tokens.brand, color: tokens.up,
border: `1px solid ${tokens.border}`, border: `1px solid ${tokens.border}`,
background: tokens.brandSoft, background: tokens.upSoft,
}, },
monitorUrl: { monitorUrl: {
fontSize: 12, fontSize: 12,
@@ -274,8 +251,6 @@ export const refinedStyles = (
}, },
badge: (tone) => ({ badge: (tone) => ({
gridArea: "status",
justifySelf: "start",
fontSize: 11, fontSize: 11,
fontWeight: 600, fontWeight: 600,
padding: "4px 10px", padding: "4px 10px",
@@ -286,10 +261,10 @@ export const refinedStyles = (
}), }),
heatmap: { heatmap: {
padding: { xs: "0 10px 14px", md: "0 20px 16px" }, padding: "0 20px 16px",
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "3px" }, gap: "3px",
height: 42, height: 42,
}, },
heatmapCell: (kind) => ({ heatmapCell: (kind) => ({
@@ -301,10 +276,10 @@ export const refinedStyles = (
}), }),
histogram: { histogram: {
padding: { xs: "0 10px", md: "0 20px" }, padding: "0 20px",
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`, gridTemplateColumns: `repeat(${MAX_RECENT_CHECKS}, 1fr)`,
gap: { xs: "1px", md: "3px" }, gap: "3px",
alignItems: "flex-end", alignItems: "flex-end",
height: 42, height: 42,
}, },
@@ -382,11 +357,11 @@ export const refinedStyles = (
fontSize: 12, fontSize: 12,
mt: "40px", mt: "40px",
"& a": { "& a": {
color: tokens.brand, color: tokens.up,
textDecoration: "underline", textDecoration: "underline",
textUnderlineOffset: "3px", textUnderlineOffset: "3px",
fontWeight: 600, fontWeight: 600,
"&:hover": { color: tokens.brandStrong }, "&:hover": { color: tokens.upStrong || tokens.up },
}, },
}, },
}; };
@@ -27,7 +27,6 @@ import {
statusBadgeKey, statusBadgeKey,
} from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; } from "@/Pages/StatusPage/Status/themes/shared/overallStatus";
import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import { formatPercentage } from "@/Utils/FormatUtils";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
@@ -102,10 +101,8 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
return ( return (
<Box sx={styles.page}> <Box sx={styles.page}>
{statusPage.customCSS && <style>{statusPage.customCSS}</style>} <Box
<Stack
component="header" component="header"
direction={{ xs: "column", md: "row" }}
sx={styles.top} sx={styles.top}
> >
<HeaderSlot <HeaderSlot
@@ -115,7 +112,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
monitorCount={monitors.length} monitorCount={monitors.length}
styles={styles} styles={styles}
/> />
</Stack> </Box>
<HeroSlot <HeroSlot
statusPage={statusPage} statusPage={statusPage}
@@ -164,7 +161,6 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
const showInfra = isHardware && statusPage.showInfrastructure !== false; const showInfra = isHardware && statusPage.showInfrastructure !== false;
const showChart = !isHardware && statusPage.showCharts !== false; const showChart = !isHardware && statusPage.showCharts !== false;
const badgeTone = monitorBadgeTone(monitor.status); const badgeTone = monitorBadgeTone(monitor.status);
const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0);
return ( return (
<Box <Box
@@ -173,39 +169,32 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
sx={styles.card} sx={styles.card}
> >
<Box sx={styles.cardRow}> <Box sx={styles.cardRow}>
<Box sx={styles.monitorName}>{monitor.name}</Box> <Box sx={styles.cardLeft}>
<Box sx={styles.monitorName}>{monitor.name}</Box>
<Box sx={styles.monitorMeta}>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Box>
</Box>
<Box <Box
component="span" component="span"
sx={styles.badge(badgeTone)} sx={styles.badge(badgeTone)}
> >
{t(statusBadgeKey[monitor.status])} {t(statusBadgeKey[monitor.status])}
</Box> </Box>
<Stack
direction={{ xs: "column", md: "row" }}
sx={styles.monitorMeta}
>
<Box
component="span"
sx={isHardware ? styles.pillHardware : styles.pill}
>
{getMonitorTypeLabel(monitor.type, t)}
</Box>
<Box
component="span"
sx={{ ...styles.pill }}
>
{uptimePercentage}
</Box>
{monitor.url && (
<Box
component="span"
sx={styles.monitorUrl}
title={monitor.url}
>
{monitor.url}
</Box>
)}
</Stack>
</Box> </Box>
{showInfra && ( {showInfra && (
@@ -9,14 +9,6 @@ export interface StatusPageThemeTokens {
up: string; up: string;
upStrong: string; upStrong: string;
upSoft: string; upSoft: string;
// Accent used for brand-tinted surfaces (hero background, active tab
// underline, "view all" links). Distinct from `up` so a red/orange
// brand color cannot recolor the semantic OK/up status indicators.
// Themes leave these undefined by default; the provider falls back
// to `up`/`upStrong`/`upSoft` so styles have a value to consume.
brand?: string;
brandStrong?: string;
brandSoft?: string;
degraded: string; degraded: string;
degradedSoft: string; degradedSoft: string;
down: string; down: string;
@@ -199,47 +191,9 @@ const editorial: ThemeVariants = {
}, },
}; };
const minimal: ThemeVariants = {
light: {
bg: "#ffffff",
surface: "#ffffff",
border: "#dfe3e8",
text: "#17191c",
textMuted: "#667085",
up: "#12a174",
upStrong: "#087a57",
upSoft: "#e7f7f1",
degraded: "#b54708",
degradedSoft: "#fff1dc",
down: "#c4323f",
downSoft: "#fde8ea",
warn: "#c27803",
warnSoft: "#fff3d6",
radius: "6px",
},
dark: {
bg: "#0d0f12",
surface: "#111419",
border: "#2b3038",
text: "#f2f4f7",
textMuted: "#98a2b3",
up: "#32d6a0",
upStrong: "#1eb783",
upSoft: "rgba(50,214,160,0.14)",
degraded: "#f0a44b",
degradedSoft: "rgba(240,164,75,0.16)",
down: "#ff6675",
downSoft: "rgba(255,102,117,0.16)",
warn: "#f2b84b",
warnSoft: "rgba(242,184,75,0.16)",
radius: "6px",
},
};
export const themeTokens: Record<StatusPageTheme, ThemeVariants> = { export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
refined, refined,
modern, modern,
bold, bold,
editorial, editorial,
minimal,
}; };
@@ -11,9 +11,7 @@ import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getStatusPagePublicPath, getStatusPagePublicUrl } from "@/Utils/statusPageUrl"; import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
import { type StatusPage } from "@/Types/StatusPage";
interface StatusPagesTableProps { interface StatusPagesTableProps {
data: StatusPage[]; data: StatusPage[];
@@ -55,7 +53,8 @@ export const StatusPagesTable = ({
const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => { const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => {
if (row.isPublished) { if (row.isPublished) {
e.stopPropagation(); e.stopPropagation();
window.open(getStatusPagePublicUrl(row), "_blank", "noopener,noreferrer"); const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`;
window.open(url, "_blank", "noopener,noreferrer");
} }
}; };
@@ -71,9 +70,7 @@ export const StatusPagesTable = ({
content: t("pages.statusPages.table.headers.url"), content: t("pages.statusPages.table.headers.url"),
render: (row) => { render: (row) => {
const content = row.isPublished const content = row.isPublished
? row.customDomain ? `/${row.url}`
? getStatusPagePublicPath(row)
: `/${row.url}`
: t("pages.statusPages.table.unpublished"); : t("pages.statusPages.table.unpublished");
return ( return (
<Stack <Stack
@@ -25,8 +25,6 @@ import type { RootState } from "@/Types/state";
import { Checkbox } from "@/Components/inputs"; import { Checkbox } from "@/Components/inputs";
import type { Tag } from "@/Types/Tag"; import type { Tag } from "@/Types/Tag";
import { SPACING } from "@/Utils/Theme/constants"; import { SPACING } from "@/Utils/Theme/constants";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
interface MonitorTableProps { interface MonitorTableProps {
monitors: Monitor[]; monitors: Monitor[];
@@ -261,24 +259,6 @@ export const MonitorTable = ({
} }
}, },
}, },
{
id: "uptime",
content: t("pages.uptime.table.headers.uptime"),
render: (row) => {
const uptimePercentage = row.uptimePercentage ?? 0;
const formattedPercentage = formatPercentage(uptimePercentage);
const paletteKey = getUptimePercentageColor(uptimePercentage);
const color = theme.palette[paletteKey].main;
return (
<Stack alignItems={"center"}>
<ColoredLabel
text={formattedPercentage}
color={color}
/>
</Stack>
);
},
},
{ {
id: "tags", id: "tags",
content: t("common.table.headers.tags"), content: t("common.table.headers.tags"),
@@ -324,9 +304,7 @@ export const MonitorTable = ({
</Stack> </Stack>
), ),
render: (row) => { render: (row) => {
return row.type === "http" && row.method return row.type;
? `${row.type} (${row.method})`
: row.type;
}, },
}, },
{ {
-16
View File
@@ -54,24 +54,8 @@ import Logs from "@/Pages/Logs";
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected"; import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
import CreateMonitor from "@/Pages/CreateMonitor"; import CreateMonitor from "@/Pages/CreateMonitor";
import { isCustomDomainHost } from "@/Utils/statusPageUrl";
const Routes = () => { const Routes = () => {
if (isCustomDomainHost()) {
return (
<LibRoutes>
<Route
path="/"
element={<Status />}
/>
<Route
path="*"
element={<Status />}
/>
</LibRoutes>
);
}
return ( return (
<LibRoutes> <LibRoutes>
<Route <Route
-5
View File
@@ -23,11 +23,6 @@ export interface V8HeapStats {
export interface MongoStats { export interface MongoStats {
readyState: number; readyState: number;
readsPerSecond: number;
insertsPerSecond: number;
updatesPerSecond: number;
deletesPerSecond: number;
writesPerSecond: number;
host: string; host: string;
port: number; port: number;
dbName: string; dbName: string;
-6
View File
@@ -48,10 +48,6 @@ export const PageSpeedStrategies = ["desktop", "mobile"] as const;
export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number]; export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number];
export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop"; export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
export const HttpMethods = ["GET", "HEAD"] as const;
export type HttpMethod = (typeof HttpMethods)[number];
export const DefaultHttpMethod: HttpMethod = "GET";
export interface Monitor { export interface Monitor {
id: string; id: string;
userId: string; userId: string;
@@ -68,7 +64,6 @@ export interface Monitor {
jsonPath?: string; jsonPath?: string;
expectedValue?: string; expectedValue?: string;
matchMethod?: MonitorMatchMethod; matchMethod?: MonitorMatchMethod;
method?: HttpMethod;
url: string; url: string;
port?: number; port?: number;
isActive: boolean; isActive: boolean;
@@ -76,7 +71,6 @@ export interface Monitor {
uptimePercentage?: number; uptimePercentage?: number;
notifications: string[]; notifications: string[];
tags: string[]; tags: string[];
customUpCodes?: number[];
secret?: string; secret?: string;
cpuAlertThreshold: number; cpuAlertThreshold: number;
cpuAlertCounter: number; cpuAlertCounter: number;
-2
View File
@@ -9,7 +9,6 @@ export const NotificationChannels = [
"telegram", "telegram",
"pushover", "pushover",
"twilio", "twilio",
"ntfy",
] as const; ] as const;
export type NotificationChannel = (typeof NotificationChannels)[number]; export type NotificationChannel = (typeof NotificationChannels)[number];
@@ -26,7 +25,6 @@ export interface Notification {
accessToken?: string; accessToken?: string;
accountSid?: string; accountSid?: string;
twilioPhoneNumber?: string; twilioPhoneNumber?: string;
topic?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
+13 -19
View File
@@ -1,14 +1,5 @@
import type { MonitorType } from "./Monitor"; import type { MonitorType } from "./Monitor";
export const QueueModes = ["primary", "worker"] as const;
export type QueueMode = (typeof QueueModes)[number];
export type QueueWorker = {
workerId: string; // hostname:pid:uuid
mode: QueueMode;
lastSeenAt: number; // epoch ms of the last heartbeat
};
export interface QueueJobFailure { export interface QueueJobFailure {
monitorId: string | number; monitorId: string | number;
monitorUrl: string | null; monitorUrl: string | null;
@@ -18,32 +9,35 @@ export interface QueueJobFailure {
failReason: string | null; failReason: string | null;
} }
export type QueueMetrics = { export interface QueueMetrics {
jobs: number; jobs: number;
activeJobs: number; activeJobs: number;
failingJobs: number; failingJobs: number;
jobsWithFailures: QueueJobFailure[]; jobsWithFailures: QueueJobFailure[];
totalRuns: number; totalRuns: number;
totalFailures: number; totalFailures: number;
workers: QueueWorker[]; }
};
export type QueueJobSummary = { export interface QueueJobSummary {
monitorId: string | number; monitorId: string | number;
monitorType: string | null; monitorUrl: string | null;
monitorType: MonitorType | null;
monitorInterval: number | null; monitorInterval: number | null;
monitorGeoInterval: number | null;
monitorActive: boolean | null; monitorActive: boolean | null;
lockedBy: string | null; active: boolean;
lockedUntil: number | null; lockedAt: number | null;
nextScheduledAt: number;
runCount: number; runCount: number;
failCount: number; failCount: number;
failReason: string | null; failReason: string | null;
lastRunAt: number | null;
lastFinishedAt: number | null; lastFinishedAt: number | null;
}; lastRunTook: number | null;
lastFailedAt: number | null;
repeat: number | null;
}
export interface QueueData { export interface QueueData {
jobs: QueueJobSummary[]; jobs: QueueJobSummary[];
count: number;
metrics: QueueMetrics; metrics: QueueMetrics;
} }
+1 -8
View File
@@ -21,13 +21,7 @@ export const getMonitorTypeLabel = (
return key ? t(key) : type; return key ? t(key) : type;
}; };
export const STATUS_PAGE_THEMES = [ export const STATUS_PAGE_THEMES = ["refined", "modern", "bold", "editorial"] as const;
"refined",
"modern",
"bold",
"editorial",
"minimal",
] as const;
export type StatusPageTheme = (typeof STATUS_PAGE_THEMES)[number]; export type StatusPageTheme = (typeof STATUS_PAGE_THEMES)[number];
export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined"; export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined";
@@ -53,7 +47,6 @@ export interface StatusPage {
type: MonitorDisplayType[]; type: MonitorDisplayType[];
companyName: string; companyName: string;
url: string; url: string;
customDomain?: string | null;
timezone?: string; timezone?: string;
color: string; color: string;
monitors: string[]; monitors: string[];
+1 -2
View File
@@ -3,8 +3,7 @@
declare const __APP_VERSION__: string; declare const __APP_VERSION__: string;
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_APP_API_BASE_URL?: string; readonly VITE_APP_API_V2_BASE_URL?: string;
readonly VITE_APP_CLIENT_HOST?: string;
readonly VITE_APP_LOG_LEVEL?: "debug" | "info" | "warn" | "error"; readonly VITE_APP_LOG_LEVEL?: "debug" | "info" | "warn" | "error";
} }
+1 -3
View File
@@ -1,10 +1,8 @@
import axios from "axios"; import axios from "axios";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import type { AxiosRequestConfig, AxiosResponse } from "axios"; import type { AxiosRequestConfig, AxiosResponse } from "axios";
import { runtimeConfig } from "@/Utils/runtimeConfig";
const BASE_URL = const BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
runtimeConfig.apiBaseUrl || import.meta.env.VITE_APP_API_BASE_URL || "/api/v1";
const api = axios.create({ const api = axios.create({
baseURL: BASE_URL, baseURL: BASE_URL,
-10
View File
@@ -63,16 +63,6 @@ export const getResponseTimeColor = (responseTime: number): PaletteKey => {
} }
}; };
export const getUptimePercentageColor = (uptimePercentage: number): PaletteKey => {
if (uptimePercentage >= 0.75) {
return "success";
} else if (uptimePercentage >= 0.5) {
return "warning";
} else {
return "error";
}
};
export const getInfraGaugeColor = (val: number, theme: any) => { export const getInfraGaugeColor = (val: number, theme: any) => {
if (val < 50) { if (val < 50) {
return theme.palette.success.main; return theme.palette.success.main;
-16
View File
@@ -1,16 +0,0 @@
import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer";
// Mirrors the server check (server/src/utils/customCss.ts) so the form rejects
// the same CSS the API would.
const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i;
export const cssReferencesExternalResource = (css?: string | null): boolean => {
if (typeof css !== "string" || css === "") {
return false;
}
return tokenize({ css }).some(
(token) =>
(isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value)
);
};
+1 -3
View File
@@ -1,5 +1,4 @@
import type { LogLevel } from "@/Types/Log"; import type { LogLevel } from "@/Types/Log";
import { runtimeConfig } from "@/Utils/runtimeConfig";
interface LogContext { interface LogContext {
[key: string]: any; [key: string]: any;
@@ -26,8 +25,7 @@ interface ErrorLogData {
userAgent: string; userAgent: string;
} }
const configuredLevel: LogLevel = const configuredLevel: LogLevel = import.meta.env.VITE_APP_LOG_LEVEL || "error";
runtimeConfig.logLevel || import.meta.env.VITE_APP_LOG_LEVEL || "error";
const configuredPriority = LOG_LEVEL_PRIORITY[configuredLevel]; const configuredPriority = LOG_LEVEL_PRIORITY[configuredLevel];
const shouldLog = (level: LogLevel): boolean => { const shouldLog = (level: LogLevel): boolean => {
-18
View File
@@ -1,18 +0,0 @@
import type { LogLevel } from "@/Types/Log";
// Served by GET /config.js (rendered from server env) or a static config.js
// next to index.html; loaded before the bundle in index.html.
export interface RuntimeConfig {
apiBaseUrl?: string;
clientHost?: string;
logLevel?: LogLevel;
}
declare global {
interface Window {
__CHECKMATE_CONFIG__?: RuntimeConfig;
}
}
export const runtimeConfig: RuntimeConfig =
(typeof window !== "undefined" && window.__CHECKMATE_CONFIG__) || {};
-93
View File
@@ -74,96 +74,3 @@ export const getStatusCodeValueType = (code: number): ValueType => {
if (code < 400) return "neutral"; if (code < 400) return "neutral";
return "negative"; return "negative";
}; };
export interface HttpStatusCodeOption {
id: number;
name: string;
}
/**
* Must stay in sync with HttpStatusCodes in server/src/domain/monitors/monitor.types.ts
*/
export const ALL_HTTP_STATUS_CODES: HttpStatusCodeOption[] = [
{ id: 100, name: "100 - Continue" },
{ id: 101, name: "101 - Switching Protocols" },
{ id: 102, name: "102 - Processing" },
{ id: 103, name: "103 - Early Hints" },
{ id: 200, name: "200 - OK" },
{ id: 201, name: "201 - Created" },
{ id: 202, name: "202 - Accepted" },
{ id: 203, name: "203 - Non-Authoritative Information" },
{ id: 204, name: "204 - No Content" },
{ id: 205, name: "205 - Reset Content" },
{ id: 206, name: "206 - Partial Content" },
{ id: 207, name: "207 - Multi-Status" },
{ id: 208, name: "208 - Already Reported" },
{ id: 226, name: "226 - IM Used" },
{ id: 300, name: "300 - Multiple Choices" },
{ id: 301, name: "301 - Moved Permanently" },
{ id: 302, name: "302 - Found" },
{ id: 303, name: "303 - See Other" },
{ id: 304, name: "304 - Not Modified" },
{ id: 305, name: "305 - Use Proxy" },
{ id: 307, name: "307 - Temporary Redirect" },
{ id: 308, name: "308 - Permanent Redirect" },
{ id: 400, name: "400 - Bad Request" },
{ id: 401, name: "401 - Unauthorized" },
{ id: 402, name: "402 - Payment Required" },
{ id: 403, name: "403 - Forbidden" },
{ id: 404, name: "404 - Not Found" },
{ id: 405, name: "405 - Method Not Allowed" },
{ id: 406, name: "406 - Not Acceptable" },
{ id: 407, name: "407 - Proxy Authentication Required" },
{ id: 408, name: "408 - Request Timeout" },
{ id: 409, name: "409 - Conflict" },
{ id: 410, name: "410 - Gone" },
{ id: 411, name: "411 - Length Required" },
{ id: 412, name: "412 - Precondition Failed" },
{ id: 413, name: "413 - Payload Too Large" },
{ id: 414, name: "414 - URI Too Long" },
{ id: 415, name: "415 - Unsupported Media Type" },
{ id: 416, name: "416 - Range Not Satisfiable" },
{ id: 417, name: "417 - Expectation Failed" },
{ id: 418, name: "418 - I'm a Teapot" },
{ id: 419, name: "419 - Page Expired (Laravel)" },
{ id: 420, name: "420 - Enhance Your Calm (Twitter)" },
{ id: 421, name: "421 - Misdirected Request" },
{ id: 422, name: "422 - Unprocessable Entity" },
{ id: 423, name: "423 - Locked" },
{ id: 424, name: "424 - Failed Dependency" },
{ id: 425, name: "425 - Too Early" },
{ id: 426, name: "426 - Upgrade Required" },
{ id: 428, name: "428 - Precondition Required" },
{ id: 429, name: "429 - Too Many Requests" },
{ id: 431, name: "431 - Request Header Fields Too Large" },
{ id: 440, name: "440 - Login Time-out (IIS)" },
{ id: 449, name: "449 - Retry With (IIS)" },
{ id: 451, name: "451 - Unavailable For Legal Reasons" },
{ id: 460, name: "460 - Client Closed Connection (AWS ELB)" },
{ id: 463, name: "463 - X-Forwarded-For Too Large (AWS ELB)" },
{ id: 497, name: "497 - HTTP Request Sent to HTTPS Port (NGINX)" },
{ id: 499, name: "499 - Client Closed Request (NGINX)" },
{ id: 500, name: "500 - Internal Server Error" },
{ id: 501, name: "501 - Not Implemented" },
{ id: 502, name: "502 - Bad Gateway" },
{ id: 503, name: "503 - Service Unavailable" },
{ id: 504, name: "504 - Gateway Timeout" },
{ id: 505, name: "505 - HTTP Version Not Supported" },
{ id: 506, name: "506 - Variant Also Negotiates" },
{ id: 507, name: "507 - Insufficient Storage" },
{ id: 508, name: "508 - Loop Detected" },
{ id: 509, name: "509 - Bandwidth Limit Exceeded (Apache)" },
{ id: 510, name: "510 - Not Extended" },
{ id: 511, name: "511 - Network Authentication Required" },
{ id: 520, name: "520 - Web Server Returned an Unknown Error (Cloudflare)" },
{ id: 521, name: "521 - Web Server Is Down (Cloudflare)" },
{ id: 522, name: "522 - Connection Timed Out (Cloudflare)" },
{ id: 523, name: "523 - Origin Is Unreachable (Cloudflare)" },
{ id: 524, name: "524 - A Timeout Occurred (Cloudflare)" },
{ id: 525, name: "525 - SSL Handshake Failed (Cloudflare)" },
{ id: 526, name: "526 - Invalid SSL Certificate (Cloudflare)" },
{ id: 527, name: "527 - Railgun Error (Cloudflare)" },
{ id: 529, name: "529 - Site is overloaded" },
{ id: 530, name: "530 - Site is frozen (Cloudflare)" },
{ id: 561, name: "561 - Unauthorized (AWS ELB)" },
];
-102
View File
@@ -1,102 +0,0 @@
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
import { runtimeConfig } from "@/Utils/runtimeConfig";
const CLIENT_HOST =
runtimeConfig.clientHost ||
(import.meta.env.VITE_APP_CLIENT_HOST as string | undefined);
const getClientOriginProtocol = (): string => {
const source =
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
if (!source) {
return "https:";
}
try {
return new URL(source).protocol;
} catch {
return "https:";
}
};
const buildCustomDomainOrigin = (customDomain: string): string =>
`${getClientOriginProtocol()}//${customDomain}`;
export const getAppHostname = (): string | null => {
const source =
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
if (!source) {
return null;
}
try {
return new URL(source).hostname.toLowerCase();
} catch {
return null;
}
};
export const isCustomDomainHost = (): boolean => {
if (typeof window === "undefined") {
return false;
}
const appHostname = getAppHostname();
if (!appHostname) {
return false;
}
return window.location.hostname.toLowerCase() !== appHostname;
};
export const getStatusPagePublicPath = (
statusPage: Pick<StatusPage, "url" | "customDomain">
): string => {
if (statusPage.customDomain) {
return buildCustomDomainOrigin(statusPage.customDomain);
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
export const getStatusPagePublicUrl = (
statusPage: Pick<StatusPage, "url" | "customDomain">
): string => {
if (statusPage.customDomain) {
return buildCustomDomainOrigin(statusPage.customDomain);
}
if (typeof window !== "undefined") {
return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
// Same-origin URL for the admin preview iframe; a custom domain would be cross
// origin and blocked by the admin document's CSP frame-src.
export const getStatusPagePreviewUrl = (statusPage: Pick<StatusPage, "url">): string => {
if (typeof window !== "undefined") {
return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
export const buildStatusPageApiPath = (options: {
url?: string;
useCustomDomain?: boolean;
}): string | null => {
const query = "type=uptime&type=infrastructure";
if (options.useCustomDomain && typeof window !== "undefined") {
const domain = encodeURIComponent(window.location.hostname);
return `/status-page/resolve?domain=${domain}&${query}`;
}
if (options.url) {
return `/status-page/${options.url}?${query}`;
}
return null;
};
+5 -25
View File
@@ -1,13 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import type { FieldPath } from "react-hook-form"; import type { FieldPath } from "react-hook-form";
import { GeoContinents } from "@/Types/GeoCheck"; import { GeoContinents } from "@/Types/GeoCheck";
import { import { DnsRecordTypes, PageSpeedStrategies, type MonitorType } from "@/Types/Monitor";
DnsRecordTypes,
HttpMethods,
PageSpeedStrategies,
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
// Wizard step a field is validated on. Attached inline to each field below so // Wizard step a field is validated on. Attached inline to each field below so
// the grouping lives next to the field definition; unannotated fields default // the grouping lives next to the field definition; unannotated fields default
@@ -59,15 +53,9 @@ const geoCheckFields = {
}; };
// HTTP monitor schema // HTTP monitor schema
const httpStatusCodeSet = new Set(ALL_HTTP_STATUS_CODES.map((c) => c.id));
const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code), {
message: "Must be a valid HTTP status code",
});
const httpSchema = baseSchema.extend({ const httpSchema = baseSchema.extend({
type: z.literal("http"), type: z.literal("http"),
url: urlSchema, url: urlSchema,
method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }),
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }), ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }), useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }),
matchMethod: z matchMethod: z
@@ -76,10 +64,6 @@ const httpSchema = baseSchema.extend({
.register(monitorStepRegistry, { step: 2 }), .register(monitorStepRegistry, { step: 2 }),
expectedValue: z.string().optional().register(monitorStepRegistry, { step: 2 }), expectedValue: z.string().optional().register(monitorStepRegistry, { step: 2 }),
jsonPath: z.string().optional().register(monitorStepRegistry, { step: 2 }), jsonPath: z.string().optional().register(monitorStepRegistry, { step: 2 }),
customUpCodes: z
.array(httpStatusCode)
.optional()
.register(monitorStepRegistry, { step: 2 }),
...geoCheckFields, ...geoCheckFields,
}); });
@@ -144,23 +128,19 @@ const hardwareSchema = baseSchema.extend({
cpuAlertThreshold: z cpuAlertThreshold: z
.number() .number()
.min(0, "CPU threshold must be at least 0") .min(0, "CPU threshold must be at least 0")
.max(100, "CPU threshold must be at most 100") .max(100, "CPU threshold must be at most 100"),
.register(monitorStepRegistry, { step: 1 }),
memoryAlertThreshold: z memoryAlertThreshold: z
.number() .number()
.min(0, "Memory threshold must be at least 0") .min(0, "Memory threshold must be at least 0")
.max(100, "Memory threshold must be at most 100") .max(100, "Memory threshold must be at most 100"),
.register(monitorStepRegistry, { step: 1 }),
diskAlertThreshold: z diskAlertThreshold: z
.number() .number()
.min(0, "Disk threshold must be at least 0") .min(0, "Disk threshold must be at least 0")
.max(100, "Disk threshold must be at most 100") .max(100, "Disk threshold must be at most 100"),
.register(monitorStepRegistry, { step: 1 }),
tempAlertThreshold: z tempAlertThreshold: z
.number() .number()
.min(0, "Temperature threshold must be at least 0") .min(0, "Temperature threshold must be at least 0")
.max(150, "Temperature threshold must be at most 150") .max(150, "Temperature threshold must be at most 150"),
.register(monitorStepRegistry, { step: 1 }),
selectedDisks: z.array(z.string()), selectedDisks: z.array(z.string()),
}); });
-7
View File
@@ -70,12 +70,6 @@ const twilioSchema = baseSchema.extend({
twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
}); });
const ntfySchema = baseSchema.extend({
type: z.literal("ntfy"),
address: z.string().min(1, "Server URL is required").url("Please enter a valid URL"),
topic: z.string().min(1, "Topic is required"),
});
export const notificationSchema = z.discriminatedUnion("type", [ export const notificationSchema = z.discriminatedUnion("type", [
emailSchema, emailSchema,
slackSchema, slackSchema,
@@ -87,7 +81,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
telegramSchema, telegramSchema,
pushoverSchema, pushoverSchema,
twilioSchema, twilioSchema,
ntfySchema,
]); ]);
export type NotificationFormData = z.infer<typeof notificationSchema>; export type NotificationFormData = z.infer<typeof notificationSchema>;
+9 -76
View File
@@ -1,31 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import type { FieldPath } from "react-hook-form";
import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage"; import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage";
import { cssReferencesExternalResource } from "@/Utils/customCss";
// Wizard step a field is validated on. Attached inline to each field below so
// the grouping lives next to the field definition; unannotated fields default
// to step 0. Read via `statusPageStepFieldsFor`. Mirrors the monitor schema's
// `monitorStepRegistry` so the create-status-page wizard can't drift from the
// field definitions.
export const statusPageStepRegistry = z.registry<{ step: number }>();
const statusPageHostnameRegex =
/^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
const normalizeCustomDomainInput = (raw: string | null): string | null => {
if (raw === null) {
return null;
}
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
return null;
}
const withoutScheme = trimmed.replace(/^https?:\/\//, "");
return withoutScheme.split("/")[0]?.split(":")[0] ?? null;
};
export const statusPageSchema = z.object({ export const statusPageSchema = z.object({
companyName: z companyName: z
@@ -40,68 +14,27 @@ export const statusPageSchema = z.object({
/^[a-z0-9-]+$/, /^[a-z0-9-]+$/,
"URL can only contain lowercase letters, numbers, and hyphens" "URL can only contain lowercase letters, numbers, and hyphens"
), ),
customDomain: z
.union([z.string(), z.null()])
.transform(normalizeCustomDomainInput)
.refine((domain) => domain === null || statusPageHostnameRegex.test(domain), {
message: "Enter a valid domain name (e.g. status.example.com)",
}),
timezone: z.string().optional(), timezone: z.string().optional(),
type: z type: z
.array(z.enum(["uptime", "infrastructure"])) .array(z.enum(["uptime", "infrastructure"]))
.min(1, "At least one type is required"), .min(1, "At least one type is required"),
color: z.string().min(1, "Color is required").register(statusPageStepRegistry, { color: z.string().min(1, "Color is required"),
step: 1,
}),
monitors: z.array(z.string()).min(1, "At least one monitor is required"), monitors: z.array(z.string()).min(1, "At least one monitor is required"),
isPublished: z.boolean(), isPublished: z.boolean(),
showCharts: z.boolean().register(statusPageStepRegistry, { step: 1 }), showCharts: z.boolean(),
showUptimePercentage: z.boolean().register(statusPageStepRegistry, { step: 1 }), showUptimePercentage: z.boolean(),
showAdminLoginLink: z.boolean().register(statusPageStepRegistry, { step: 1 }), showAdminLoginLink: z.boolean(),
showInfrastructure: z.boolean().register(statusPageStepRegistry, { step: 1 }), showInfrastructure: z.boolean(),
customCSS: z customCSS: z.string().optional(),
.string() theme: z.enum(STATUS_PAGE_THEMES).optional(),
.max(100000, "Custom CSS must be at most 100000 characters") themeMode: z.enum(STATUS_PAGE_THEME_MODES).optional(),
.refine((css) => !cssReferencesExternalResource(css), {
message: "Custom CSS cannot reference external URLs or use @import",
})
.optional()
.register(statusPageStepRegistry, { step: 1 }),
theme: z
.enum(STATUS_PAGE_THEMES)
.optional()
.register(statusPageStepRegistry, { step: 1 }),
themeMode: z
.enum(STATUS_PAGE_THEME_MODES)
.optional()
.register(statusPageStepRegistry, { step: 1 }),
logo: z logo: z
.object({ .object({
data: z.string(), data: z.string(),
contentType: z.string(), contentType: z.string(),
}) })
.nullable() .nullable()
.optional() .optional(),
.register(statusPageStepRegistry, { step: 1 }),
}); });
export type StatusPageFormData = z.infer<typeof statusPageSchema>; export type StatusPageFormData = z.infer<typeof statusPageSchema>;
// The step each field is validated on, from the per-field
// `statusPageStepRegistry` metadata (unannotated fields default to step 0).
const fieldSteps = (): [name: FieldPath<StatusPageFormData>, step: number][] =>
Object.entries(statusPageSchema.shape).map(([name, field]) => [
name as FieldPath<StatusPageFormData>,
statusPageStepRegistry.get(field)?.step ?? 0,
]);
// The fields that belong to a given wizard step. Derived from the schema so it
// can't drift from the field definitions.
export const statusPageStepFieldsFor = (step: number): FieldPath<StatusPageFormData>[] =>
fieldSteps()
.filter(([, fieldStep]) => fieldStep === step)
.map(([name]) => name);
// Number of wizard steps, derived from the highest step its fields declare.
export const statusPageStepCount = (): number =>
Math.max(0, ...fieldSteps().map(([, step]) => step)) + 1;
+3
View File
@@ -941,7 +941,10 @@
"monitorId": "معرّف المراقب", "monitorId": "معرّف المراقب",
"runCount": "عدد التشغيلات", "runCount": "عدد التشغيلات",
"failCount": "عدد حالات الفشل", "failCount": "عدد حالات الفشل",
"lastRunAt": "آخر تشغيل في",
"lockedAt": "مقفل في",
"lastFinishedAt": "آخر انتهاء في", "lastFinishedAt": "آخر انتهاء في",
"lastRunTook": "مدة آخر تشغيل",
"lastFailedAt": "آخر فشل في", "lastFailedAt": "آخر فشل في",
"failReason": "سبب الفشل", "failReason": "سبب الفشل",
"state": "الحالة" "state": "الحالة"
+3
View File
@@ -945,7 +945,10 @@
"monitorId": "ID del monitor", "monitorId": "ID del monitor",
"runCount": "Recompte d'execucions", "runCount": "Recompte d'execucions",
"failCount": "Recompte", "failCount": "Recompte",
"lastRunAt": "Darrera execució",
"lockedAt": "Bloquejat el",
"lastFinishedAt": "Darrera finalització", "lastFinishedAt": "Darrera finalització",
"lastRunTook": "Durada de la darrera execució",
"lastFailedAt": "Darrera fallada", "lastFailedAt": "Darrera fallada",
"failReason": "Motiu de la fallada", "failReason": "Motiu de la fallada",
"state": "Estat" "state": "Estat"
+3
View File
@@ -941,7 +941,10 @@
"monitorId": "ID monitoru", "monitorId": "ID monitoru",
"runCount": "Počet spuštění", "runCount": "Počet spuštění",
"failCount": "Počet selhání", "failCount": "Počet selhání",
"lastRunAt": "Poslední spuštění",
"lockedAt": "Uzamčeno v",
"lastFinishedAt": "Naposledy dokončeno", "lastFinishedAt": "Naposledy dokončeno",
"lastRunTook": "Poslední běh trval",
"lastFailedAt": "Poslední selhání", "lastFailedAt": "Poslední selhání",
"failReason": "Důvod selhání", "failReason": "Důvod selhání",
"state": "Stav" "state": "Stav"
+3
View File
@@ -941,7 +941,10 @@
"monitorId": "Monitor-ID", "monitorId": "Monitor-ID",
"runCount": "Ausführungen", "runCount": "Ausführungen",
"failCount": "Fehlerzahl", "failCount": "Fehlerzahl",
"lastRunAt": "Letzter Lauf am",
"lockedAt": "Gesperrt am",
"lastFinishedAt": "Zuletzt abgeschlossen am", "lastFinishedAt": "Zuletzt abgeschlossen am",
"lastRunTook": "Letzter Lauf dauerte",
"lastFailedAt": "Letzter Fehler am", "lastFailedAt": "Letzter Fehler am",
"failReason": "Fehlergrund", "failReason": "Fehlergrund",
"state": "Status" "state": "Status"
+5 -59
View File
@@ -490,13 +490,6 @@
"advanced": { "advanced": {
"description": "Optional settings for advanced use cases", "description": "Optional settings for advanced use cases",
"option": { "option": {
"method": {
"label": "Request method",
"description": {
"GET": "GET fetches the full response including the body. Advanced matching fully supported",
"HEAD": "HEAD fetches only response headers for reducing load on the target. Supports status code matching but not advanced matching as there is no body."
}
},
"advancedMatching": { "advancedMatching": {
"label": "Use advanced matching" "label": "Use advanced matching"
}, },
@@ -512,11 +505,6 @@
"equal": "Equal", "equal": "Equal",
"include": "Include", "include": "Include",
"regex": "Regex" "regex": "Regex"
},
"customUpCodes": {
"label": "Accepted status codes",
"description": "Add any non-2xx codes that should also be considered successful.",
"removeAriaLabel": "Remove status code"
} }
}, },
"title": "Advanced settings" "title": "Advanced settings"
@@ -844,7 +832,7 @@
"memory": "Memory usage", "memory": "Memory usage",
"netBytesRecv": "{{name}} - Bytes Received", "netBytesRecv": "{{name}} - Bytes Received",
"netBytesSent": "{{name}} - Bytes Sent", "netBytesSent": "{{name}} - Bytes Sent",
"loopbackOnlyNotice": "Capture is only reporting loopback or container-bridge interfaces (lo, lo0, docker0). This is normal when Capture runs inside a container that can only see its own network namespace — run Capture directly on the host to see real network activity.", "netNoTraffic": "Capture is reporting zero traffic on every interface. This is normal when Capture runs inside a container that can only see loopback interfaces — run Capture directly on the host to see real network activity.",
"temp": "Temp" "temp": "Temp"
} }
}, },
@@ -945,8 +933,6 @@
"logLevelSelect": { "logLevelSelect": {
"label": "Log level" "label": "Log level"
}, },
"workers": "Workers",
"workersExplainers": "Shows worker processes. Worker IDs are truncated, hover for full ID",
"jobQueue": "Job queue", "jobQueue": "Job queue",
"jobQueueExplainer": "<highlight>Highlighted rows</highlight> are jobs currently being processed by a worker.", "jobQueueExplainer": "<highlight>Highlighted rows</highlight> are jobs currently being processed by a worker.",
"failedJobs": "Failed jobs", "failedJobs": "Failed jobs",
@@ -987,11 +973,6 @@
}, },
"mongoDBStats": { "mongoDBStats": {
"readyState": "Ready state", "readyState": "Ready state",
"readsPerSecond": "Reads/s",
"insertsPerSecond": "Inserts/s",
"updatesPerSecond": "Updates/s",
"deletesPerSecond": "Deletes/s",
"writesPerSecond": "Total writes/s",
"host": "Host", "host": "Host",
"port": "Port", "port": "Port",
"dbName": "Database name", "dbName": "Database name",
@@ -1019,11 +1000,6 @@
}, },
"table": { "table": {
"headers": { "headers": {
"host": "Host",
"processId": "Process ID",
"workerId": "Worker ID",
"workerMode": "Worker mode",
"lastSeenAt": "Last seen at",
"state": "State", "state": "State",
"timestamp": "Timestamp", "timestamp": "Timestamp",
"level": "Level", "level": "Level",
@@ -1032,9 +1008,10 @@
"monitorId": "Monitor ID", "monitorId": "Monitor ID",
"runCount": "Run count", "runCount": "Run count",
"failCount": "Count", "failCount": "Count",
"nextScheduledAt": "Next run at", "lastRunAt": "Last run at",
"lockedBy": "Locked by", "lockedAt": "Locked at",
"lastFinishedAt": "Last finished at", "lastFinishedAt": "Last finished at",
"lastRunTook": "Last run took",
"lastFailedAt": "Last failed", "lastFailedAt": "Last failed",
"failReason": "Fail reason" "failReason": "Fail reason"
} }
@@ -1180,14 +1157,6 @@
"placeholderFromNumber": "+15551234567", "placeholderFromNumber": "+15551234567",
"optionToNumber": "To number (recipient)", "optionToNumber": "To number (recipient)",
"placeholderToNumber": "+15559876543" "placeholderToNumber": "+15559876543"
},
"ntfy": {
"title": "ntfy configuration",
"description": "Configure an ntfy server and topic for push notifications.",
"optionServerUrl": "Server URL",
"placeholderServerUrl": "https://ntfy.sh",
"optionTopic": "Topic",
"placeholderTopic": "checkmate-alerts"
} }
}, },
"table": { "table": {
@@ -1524,11 +1493,6 @@
"url": { "url": {
"label": "Your status page address", "label": "Your status page address",
"placeholder": "my-status-page" "placeholder": "my-status-page"
},
"customDomain": {
"label": "Custom domain",
"placeholder": "status.example.com",
"helper": "Optional. Point a DNS record to this Checkmate instance to serve the status page at your own domain."
} }
} }
}, },
@@ -1585,10 +1549,6 @@
"editorial": { "editorial": {
"name": "Editorial", "name": "Editorial",
"description": "Serif headings and hairline dividers. Distinctive and calm." "description": "Serif headings and hairline dividers. Distinctive and calm."
},
"minimal": {
"name": "Minimal",
"description": "Flat, focused, and compact with only the essentials."
} }
} }
}, },
@@ -1598,16 +1558,6 @@
"light": "Light only", "light": "Light only",
"dark": "Dark only" "dark": "Dark only"
}, },
"customCSS": {
"title": "Custom CSS",
"description": "Add custom CSS to fine-tune the look of your public status page.",
"option": {
"customCSS": {
"label": "Custom CSS",
"placeholder": "footer { display: none; }"
}
}
},
"features": { "features": {
"title": "Features", "title": "Features",
"description": "Configure what information is displayed on your status page.", "description": "Configure what information is displayed on your status page.",
@@ -1630,9 +1580,6 @@
"header": { "header": {
"title": "Status pages", "title": "Status pages",
"description": "Publish public pages that show real-time uptime and incident history to your customers and stakeholders." "description": "Publish public pages that show real-time uptime and incident history to your customers and stakeholders."
},
"preview": {
"title": "Status page preview"
} }
}, },
"uptime": { "uptime": {
@@ -1644,8 +1591,7 @@
}, },
"table": { "table": {
"headers": { "headers": {
"responseTime": "Response time", "responseTime": "Response time"
"uptime": "Uptime %"
} }
}, },
"fallback": { "fallback": {
+3
View File
@@ -941,7 +941,10 @@
"monitorId": "ID de monitor", "monitorId": "ID de monitor",
"runCount": "Conteo de ejecuciones", "runCount": "Conteo de ejecuciones",
"failCount": "Conteo de fallas", "failCount": "Conteo de fallas",
"lastRunAt": "Última ejecución",
"lockedAt": "Bloqueado a las",
"lastFinishedAt": "Última finalización", "lastFinishedAt": "Última finalización",
"lastRunTook": "Última ejecución tomó",
"lastFailedAt": "Última falla", "lastFailedAt": "Última falla",
"failReason": "Razón de falla", "failReason": "Razón de falla",
"state": "Estado" "state": "Estado"
+3
View File
@@ -941,7 +941,10 @@
"monitorId": "Monitorin tunnus", "monitorId": "Monitorin tunnus",
"runCount": "Suorituskerrat", "runCount": "Suorituskerrat",
"failCount": "Epäonnistumiskerrat", "failCount": "Epäonnistumiskerrat",
"lastRunAt": "Viimeisin suoritus",
"lockedAt": "Lukittu",
"lastFinishedAt": "Viimeisin valmistuminen", "lastFinishedAt": "Viimeisin valmistuminen",
"lastRunTook": "Viimeisin suoritus kesti",
"lastFailedAt": "Viimeisin epäonnistuminen", "lastFailedAt": "Viimeisin epäonnistuminen",
"failReason": "Epäonnistumisen syy", "failReason": "Epäonnistumisen syy",
"state": "Tila" "state": "Tila"
+3
View File
@@ -941,7 +941,10 @@
"monitorId": "ID du moniteur", "monitorId": "ID du moniteur",
"runCount": "Nombre d'exécutions", "runCount": "Nombre d'exécutions",
"failCount": "Nombre d'échecs", "failCount": "Nombre d'échecs",
"lastRunAt": "Dernière exécution",
"lockedAt": "Verrouillé le",
"lastFinishedAt": "Dernière fin", "lastFinishedAt": "Dernière fin",
"lastRunTook": "Durée dernière exécution",
"lastFailedAt": "Dernier échec", "lastFailedAt": "Dernier échec",
"failReason": "Raison de l'échec", "failReason": "Raison de l'échec",
"state": "État" "state": "État"
File diff suppressed because it is too large. Load diff
+3
View File
@@ -941,7 +941,10 @@
"monitorId": "モニターID", "monitorId": "モニターID",
"runCount": "実行回数", "runCount": "実行回数",
"failCount": "失敗回数", "failCount": "失敗回数",
"lastRunAt": "最終実行日時",
"lockedAt": "ロック日時",
"lastFinishedAt": "最終完了日時", "lastFinishedAt": "最終完了日時",
"lastRunTook": "最終実行時間",
"lastFailedAt": "最終失敗日時", "lastFailedAt": "最終失敗日時",
"failReason": "失敗理由", "failReason": "失敗理由",
"state": "状態" "state": "状態"
File diff suppressed because it is too large. Load diff
+3
View File
@@ -941,7 +941,10 @@
"monitorId": "ID do monitor", "monitorId": "ID do monitor",
"runCount": "Contagem de execuções", "runCount": "Contagem de execuções",
"failCount": "Contagem de falhas", "failCount": "Contagem de falhas",
"lastRunAt": "Última execução em",
"lockedAt": "Bloqueado em",
"lastFinishedAt": "Última conclusão em", "lastFinishedAt": "Última conclusão em",
"lastRunTook": "Última execução durou",
"lastFailedAt": "Última falha em", "lastFailedAt": "Última falha em",
"failReason": "Motivo da falha", "failReason": "Motivo da falha",
"state": "Estado" "state": "Estado"
+3
View File
@@ -941,7 +941,10 @@
"monitorId": "ID монитора", "monitorId": "ID монитора",
"runCount": "Кол-во запусков", "runCount": "Кол-во запусков",
"failCount": "Кол-во ошибок", "failCount": "Кол-во ошибок",
"lastRunAt": "Последний запуск",
"lockedAt": "Заблокировано в",
"lastFinishedAt": "Последнее завершение", "lastFinishedAt": "Последнее завершение",
"lastRunTook": "Последний запуск занял",
"lastFailedAt": "Последняя ошибка", "lastFailedAt": "Последняя ошибка",
"failReason": "Причина ошибки", "failReason": "Причина ошибки",
"state": "Состояние" "state": "Состояние"
+3
View File
@@ -941,7 +941,10 @@
"monitorId": "รหัสมอนิเตอร์", "monitorId": "รหัสมอนิเตอร์",
"runCount": "จำนวนรัน", "runCount": "จำนวนรัน",
"failCount": "จำนวนล้มเหลว", "failCount": "จำนวนล้มเหลว",
"lastRunAt": "รันล่าสุดเมื่อ",
"lockedAt": "ล็อกเมื่อ",
"lastFinishedAt": "เสร็จสิ้นล่าสุดเมื่อ", "lastFinishedAt": "เสร็จสิ้นล่าสุดเมื่อ",
"lastRunTook": "รันล่าสุดใช้เวลา",
"lastFailedAt": "ล้มเหลวล่าสุดเมื่อ", "lastFailedAt": "ล้มเหลวล่าสุดเมื่อ",
"failReason": "สาเหตุที่ล้มเหลว", "failReason": "สาเหตุที่ล้มเหลว",
"state": "สถานะ" "state": "สถานะ"
+3
View File
@@ -941,7 +941,10 @@
"monitorId": "Monitör ID", "monitorId": "Monitör ID",
"runCount": "Çalışma sayısı", "runCount": "Çalışma sayısı",
"failCount": "Başarısızlık sayısı", "failCount": "Başarısızlık sayısı",
"lastRunAt": "Son çalışma zamanı",
"lockedAt": "Kilitlenme zamanı",
"lastFinishedAt": "Son tamamlanma zamanı", "lastFinishedAt": "Son tamamlanma zamanı",
"lastRunTook": "Son çalışma süresi",
"lastFailedAt": "Son başarısızlık zamanı", "lastFailedAt": "Son başarısızlık zamanı",
"failReason": "Başarısızlık nedeni", "failReason": "Başarısızlık nedeni",
"state": "Durum" "state": "Durum"
Loaded 100 of 548 files, more files were not shown because too many files have changed in this diff. Show more