Compare commits

..
Author SHA1 Message Date
Alexander Holliday d368ec5bd7 Merge pull request #3761 from bluewave-labs/develop
develop -> master
2026-07-07 10:19:52 -07:00
Alexander Holliday 825bcc5fdb Merge pull request #3751 from bluewave-labs/develop
develop -> master
2026-07-02 11:06:47 -07:00
Alexander Holliday 69cdf948da Merge pull request #3750 from bluewave-labs/develop
develop -> master
2026-07-02 10:57:17 -07:00
Alexander Holliday d337bb235c Merge pull request #3749 from bluewave-labs/develop
migration fix
2026-07-02 10:43:59 -07:00
Alexander Holliday 0c0f43174d Merge pull request #3748 from bluewave-labs/develop
fix/migration
2026-07-02 10:30:55 -07:00
Alexander Holliday 51149d1dd4 Merge pull request #3747 from bluewave-labs/develop
develop -> master
2026-07-02 10:18:55 -07:00
Alexander Holliday 448fcc4628 Merge pull request #3707 from bluewave-labs/develop
develop -> master
2026-06-05 12:04:15 -07:00
Alexander Holliday ee5462d039 Merge pull request #3699 from bluewave-labs/develop
develop -> master
2026-06-05 10:39:06 -07:00
Alexander Holliday 7ee6d342e9 Merge pull request #3606 from bluewave-labs/develop
develop -> master
2026-04-29 12:11:29 -07:00
Alexander Holliday 49d3737a93 Merge pull request #3604 from bluewave-labs/develop
develop -> master
2026-04-29 11:51:43 -07:00
Alexander Holliday 4402e4fbfa Merge pull request #3567 from bluewave-labs/develop
develop -> master
2026-04-23 11:26:21 -07:00
Alexander Holliday 721f77ed26 Merge pull request #3565 from bluewave-labs/develop
develop -> master
2026-04-23 10:15:31 -07:00
Alexander Holliday 34f29e5ae6 Merge pull request #3563 from bluewave-labs/develop
develop -> master
2026-04-23 09:52:58 -07:00
Alexander Holliday c2170267e8 Merge pull request #3394 from bluewave-labs/develop
develop -> master 3.5.1 release
2026-03-06 12:56:20 -08:00
Alexander Holliday 321ca59380 Merge pull request #3368 from bluewave-labs/develop
Develop -> master (3.5.0)
2026-03-04 10:41:02 -08:00
Alexander Holliday e3ecfe6ad8 Merge pull request #3323 from bluewave-labs/develop
develop -> master
2026-02-20 12:38:33 -08:00
Alexander Holliday f4664eec16 Merge pull request #3321 from bluewave-labs/develop
develop->master
2026-02-20 12:20:49 -08:00
Alexander Holliday 56f601e34b Merge pull request #3319 from bluewave-labs/develop
develop -> master
2026-02-20 12:07:29 -08:00
Alexander Holliday 1244ff6d93 Merge pull request #3212 from bluewave-labs/develop
develop -> master
2026-01-27 13:58:48 -08:00
Alexander Holliday c0ea804c98 Merge pull request #3209 from bluewave-labs/develop
develop -> master
2026-01-27 13:40:25 -08:00
Alexander Holliday c21cf29db6 Merge pull request #3207 from bluewave-labs/develop
develop -> master
2026-01-27 13:20:18 -08:00
Alexander Holliday 3d0da70ce6 Merge pull request #3204 from bluewave-labs/develop
develop -> master
2026-01-27 13:09:47 -08:00
Alexander Holliday feb061240b Merge pull request #3042 from bluewave-labs/develop
develp->master
2025-10-27 12:09:00 -07:00
Alexander Holliday a924227805 Merge pull request #2964 from bluewave-labs/develop
develop -> master
2025-09-18 13:40:45 -07:00
Alexander Holliday 2144d3f8c0 Merge pull request #2962 from bluewave-labs/develop
develop -> master
2025-09-18 12:04:51 -07:00
Alexander Holliday fcd6786149 Merge pull request #2936 from bluewave-labs/develop
develop -> master
2025-09-08 20:43:21 -07:00
Alexander Holliday ea968ebf3c Merge pull request #2933 from bluewave-labs/develop
develop -> master
2025-09-08 20:33:35 -07:00
Alexander Holliday 8a7b852974 Merge pull request #2929 from bluewave-labs/develop
develop -> master
2025-09-08 07:24:16 -07:00
Alexander Holliday c8d3e3b70c Merge pull request #2921 from bluewave-labs/develop
develop -> master
2025-09-04 15:46:30 -07:00
Alexander Holliday 7a90a6f9f4 Merge pull request #2915 from bluewave-labs/develop
develop->master
2025-09-01 10:33:09 -07:00
461 changed files with 15746 additions and 21144 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
-22
View File
@@ -1,22 +0,0 @@
name: Lockfile Check (Client & Server)
on:
pull_request:
workflow_dispatch:
jobs:
check-lockfiles:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Validate with the same node image the production build uses, so a lock
# that passes here cannot fail the image build's npm ci.
- name: Resolve node image from Dockerfile
id: node-image
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
- name: Check server lockfile
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
- name: Check client lockfile
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
@@ -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
+138
View File
@@ -0,0 +1,138 @@
name: POEditor Translation Synchronization (disabled)
# This workflow is intentionally disabled — kept for reference only.
# The full definition is preserved commented-out below. To re-enable,
# delete the stub job and uncomment the section starting at `on:`.
on:
workflow_dispatch:
jobs:
disabled:
if: false
runs-on: ubuntu-latest
steps:
- run: echo "This workflow is disabled. See comments below for the original definition."
# ---------------------------------------------------------------------------
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
# ---------------------------------------------------------------------------
# on:
# # For manual triggering
# workflow_dispatch:
# inputs:
# languages:
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
# required: false
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
# format:
# description: "Export format (key_value_json or json)"
# required: false
# default: "key_value_json"
# permissions:
# contents: write
# pull-requests: write
# jobs:
# sync-translations:
# runs-on: ubuntu-latest
# permissions:
# contents: write
# issues: write
# pull-requests: write
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# fetch-depth: 0
# - name: Setup Node.js
# uses: actions/setup-node@v3
# with:
# node-version: "22"
# - name: Create package.json for scripts
# run: |
# mkdir -p .github/scripts
# cat > .github/scripts/package.json << EOF
# {
# "name": "poeditor-scripts",
# "version": "1.0.0",
# "private": true,
# "type": "module",
# "dependencies": {
# "axios": "^1.6.0",
# "fs-extra": "^11.1.1"
# }
# }
# EOF
# - name: Install dependencies
# run: |
# cd .github/scripts
# npm install
# - name: Download translations from POEditor
# env:
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
# run: |
# mkdir -p temp
# node .github/scripts/download-translations.js
# - name: Verify translation files
# run: |
# echo "Verifying translation files..."
# for file in temp/*.json; do
# echo "Checking $file"
# if [ ! -s "$file" ]; then
# echo "Error: $file is empty or does not exist"
# exit 1
# fi
# # Validate JSON format
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
# done
# echo "All translation files are valid"
# - name: Copy translations to project
# run: |
# mkdir -p client/src/locales
# cp -r temp/* client/src/locales/
# echo "Translation files copied to client/src/locales/"
# - name: Get current date
# id: date
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
# - name: Format client code
# run: |
# cd client
# npm ci
# npm run format
# - name: Create Pull Request
# uses: peter-evans/create-pull-request@v7
# with:
# token: ${{ secrets.GITHUB_TOKEN }}
# commit-message: "feat: translations updated from POEditor"
# title: "🌐 Updated Translations from POEditor"
# body: |
# This PR contains the latest translations from POEditor.
# 📅 Update Date: ${{ steps.date.outputs.date }}
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
# ⚠️ Please review the translations and approve the PR if everything looks correct.
# branch: translation-update-${{ github.run_number }}
# delete-branch: true
# base: develop
# add-paths: |
# client/src/locales/*.json
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
+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
+113
View File
@@ -0,0 +1,113 @@
name: Upload Translations to POEditor on PR Merge
on:
pull_request:
types: [closed]
branches:
- develop
paths:
- "client/src/locales/**"
jobs:
upload-translations:
# Only run if the PR was merged (not just closed) or manually triggered
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0 # Fetch all history to get changed files
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "22"
- name: Create package.json for scripts
run: |
mkdir -p .github/scripts
cat > .github/scripts/package.json << EOF
{
"name": "poeditor-scripts",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"axios": "^1.6.0",
"fs-extra": "^11.1.1",
"form-data": "^4.0.0"
}
}
EOF
- name: Install dependencies
run: |
cd .github/scripts
npm install
- name: Get changed locale files
id: changed-files
if: github.event_name == 'pull_request'
run: |
# Get base and head commits
BASE_SHA=${{ github.event.pull_request.base.sha }}
HEAD_SHA=${{ github.event.pull_request.head.sha }}
echo "Base SHA: $BASE_SHA"
echo "Head SHA: $HEAD_SHA"
# Get list of changed files in client/src/locales directory
CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json')
if [ -z "$CHANGED_FILES" ]; then
echo "No changes detected in locale files"
echo "CHANGED_FILES=" >> $GITHUB_ENV
else
echo "Changed files:"
echo "$CHANGED_FILES"
echo "CHANGED_FILES<<EOF" >> $GITHUB_ENV
echo "$CHANGED_FILES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
fi
- name: Upload changed translations to POEditor
if: env.CHANGED_FILES != ''
env:
POEDITOR_API: ${{ secrets.POEDITOR_API }}
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
run: |
# Process each changed file
for FILE in $CHANGED_FILES; do
if [[ -f "$FILE" ]]; then
# Extract language code from filename (e.g., client/src/locales/en.json -> en)
FILENAME=$(basename "$FILE")
# Special case: map gb.json to en language code
if [ "$FILENAME" == "gb.json" ]; then
LANG="en"
echo "Found gb.json, mapping to language code 'en'"
else
LANG=$(basename "$FILE" .json)
fi
echo "Processing $FILE for language $LANG"
# Upload to POEditor
LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js
fi
done
- name: Notify on success
if: success() && env.CHANGED_FILES != ''
run: |
echo "Successfully uploaded translation files to POEditor."
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "Manual trigger comment: ${{ github.event.inputs.comment }}"
fi
- name: Notify on no changes
if: env.CHANGED_FILES == ''
run: |
echo "No translation files were found to upload."
+3 -3
View File
@@ -35,9 +35,9 @@ npm run format # Prettier formatting
### Docker Development
```bash
docker build -f docker/Dockerfile -t checkmate . # build the mono image (server + built client)
docker compose -f docker/dev/docker-compose.yaml up # local full stack (builds image, runs mongo + worker)
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:8.0 # just a dev database
cd docker/dev
./build_images.sh
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:6.0
```
## Environment Setup
+25 -30
View File
@@ -88,10 +88,34 @@ cd Checkmate
#### 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:
```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)
@@ -232,35 +256,6 @@ docker rm uptime_database_mongo
Start with [good first issues](https://github.com/bluewave-labs/checkmate/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
### Improve the documentation?
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?
Follow the [pull request checklist](#pull-request-checklist). Your PR should:
+6 -44
View File
@@ -9,7 +9,6 @@
🇪🇸 <a href="docs/translations/README.es.md">Español</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.it.md">Italiano</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.ru.md">Русский</a> |
@@ -32,13 +31,12 @@
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong>
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate)
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
</p>
<br>
@@ -81,43 +79,7 @@ Usage instructions can be found [here](https://checkmate.so/docs).
<a id="installation"></a>
## Installation
The quickest way to run Checkmate is the reference Docker Compose file. It starts two services: the all-in-one Checkmate application image (`ghcr.io/bluewave-labs/checkmate`) and a separate MongoDB service.
> **What “all-in-one” means:** the Checkmate application is packaged in a single image; MongoDB is not embedded in that image and remains required. The reference Compose file starts MongoDB for you. For custom deployments, configure `DB_CONNECTION_STRING` to use an external MongoDB instance.
```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.
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
### 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).
See installation instructions in [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.
@@ -158,7 +120,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
- Selective disk monitoring with mountpoint selection
- Incidents at a glance
- Status pages with 4 beautiful themes
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- Scheduled maintenance
- JSON query monitoring
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
@@ -212,9 +174,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
<a id="contributing"></a>
## Contributing
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
-4
View File
@@ -3,8 +3,4 @@ name: checkmate-chart
description: A Helm chart for Checkmate App
type: application
version: 0.2.0
# DO NOT bump appVersion past v3.8.1 until this chart is migrated to the all-in-one
# 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)
+4 -6
View File
@@ -1,6 +1,4 @@
# Production builds use same-origin defaults: API at /api/v1, client host from
# window.location.origin. The empty values below override any local .env so a
# production build never bakes in a developer's localhost URLs.
VITE_APP_API_BASE_URL=
VITE_APP_CLIENT_HOST=
VITE_APP_LOG_LEVEL=
VITE_APP_API_BASE_URL=UPTIME_APP_API_BASE_URL
VITE_APP_API_V2_BASE_URL=UPTIME_APP_API_V2_BASE_URL
VITE_APP_CLIENT_HOST=UPTIME_APP_CLIENT_HOST
VITE_APP_LOG_LEVEL=UPTIME_APP_LOG_LEVEL
+1
View File
@@ -28,4 +28,5 @@ dist-ssr
# Scripts
*.sh
!env.sh
.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>
<script src="/config.js"></script>
</head>
<body>
+24 -4
View File
@@ -1,12 +1,12 @@
{
"name": "client",
"version": "3.11.0",
"version": "3.9.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
"version": "3.11.0",
"version": "3.9.2",
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
@@ -92,6 +92,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -407,6 +408,7 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -444,6 +446,7 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1223,6 +1226,7 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1327,6 +1331,7 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2371,6 +2376,7 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2386,6 +2392,7 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2476,6 +2483,7 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -2756,6 +2764,7 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3112,6 +3121,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -3555,7 +3565,8 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3954,6 +3965,7 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4766,6 +4778,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5577,6 +5590,7 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -6358,6 +6372,7 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6369,6 +6384,7 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6572,7 +6588,8 @@
},
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6722,6 +6739,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.9"
},
@@ -7563,6 +7581,7 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7700,6 +7719,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
+1 -2
View File
@@ -1,10 +1,9 @@
{
"name": "client",
"private": true,
"version": "3.11.0",
"version": "3.9.2",
"type": "module",
"scripts": {
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
"dev": "vite",
"build": "tsc -b && vite build",
"build-dev": "tsc -b && vite build --mode development",
-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__ = {};
@@ -1,7 +1,7 @@
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { CheckSnapshot } from "@/Types/Check";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
@@ -58,7 +58,7 @@ export const HeatmapResponseTimeTooltip = ({
</Typography>
<Typography>
{t("common.labels.responseTime")}:{" "}
{check?.responseTime ? formatMs(check.responseTime) : "N/A"}
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
</Typography>
<Typography textTransform={"capitalize"}>
Status:{" "}
@@ -8,8 +8,6 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import { computeBarHeights } from "@/Utils/DataUtils";
import { formatMs } from "@/Utils/TimeUtils";
interface HistogramResponseTimeProps {
checks: CheckSnapshot[];
@@ -20,25 +18,25 @@ interface HistogramResponseTimeProps {
}
interface ResponseTimeStats {
max: string | null;
avg: string | null;
max: number | string;
avg: number | string;
}
const DEFAULT_HEIGHT = 50;
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
if (!Array.isArray(checks) || checks.length === 0) {
return { max: null, avg: null };
return { max: "-", avg: "-" };
}
const validChecks = checks.filter((check) => check.responseTime != null);
const validChecks = checks.filter((check) => check.originalResponseTime != null);
if (validChecks.length === 0) {
return { max: null, avg: null };
return { max: "-", avg: "-" };
}
const responseTimes = validChecks.map((check) => check.responseTime);
const max = formatMs(Math.max(...responseTimes));
const avg = formatMs(
const responseTimes = validChecks.map((check) => check.originalResponseTime);
const max = Math.round(Math.max(...responseTimes));
const avg = Math.round(
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
);
@@ -56,7 +54,7 @@ export const HistogramResponseTime = ({
const { t } = useTranslation();
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
const barHeights = useMemo(() => computeBarHeights(checks), [checks]);
if (!Array.isArray(checks) || checks.length === 0) return null;
const data =
@@ -70,28 +68,29 @@ export const HistogramResponseTime = ({
const chartHeight = typeof height === "number" ? `${height}px` : height;
const gridGap = gap ?? theme.spacing(0.5);
const statsContent = showStats && (stats.max !== null || stats.avg !== null) && (
<Stack
justifyContent="center"
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
width={110}
pr={statsPosition === "left" ? theme.spacing(8) : 0}
pl={statsPosition === "right" ? theme.spacing(8) : 0}
>
<Typography
variant="body2"
noWrap
const statsContent = showStats &&
(typeof stats.max === "number" || typeof stats.avg === "number") && (
<Stack
justifyContent="center"
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
width={110}
pr={statsPosition === "left" ? theme.spacing(8) : 0}
pl={statsPosition === "right" ? theme.spacing(8) : 0}
>
{t("common.charts.histogram.avg", { value: stats.avg })}
</Typography>
<Typography
variant="body2"
noWrap
>
{t("common.charts.histogram.max", { value: stats.max })}
</Typography>
</Stack>
);
<Typography
variant="body2"
noWrap
>
{t("common.charts.histogram.avg", { value: stats.avg })}
</Typography>
<Typography
variant="body2"
noWrap
>
{t("common.charts.histogram.max", { value: stats.max })}
</Typography>
</Stack>
);
return (
<Stack
@@ -112,7 +111,7 @@ export const HistogramResponseTime = ({
>
{data.map((check, index) => {
const isPlaceholder = (check as any).status === "placeholder";
const heightPct = `${barHeights[index] ?? 0}%`;
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
const barColor =
check.status === true
? theme.palette.success.light
@@ -13,7 +13,6 @@ type BaseChartProps = React.PropsWithChildren<{
width?: number | string;
maxWidth?: number | string;
padding?: number | string | ResponsiveStyleValue<number | string>;
onClick?: () => void;
}>;
export const BaseChart = ({
@@ -22,22 +21,16 @@ export const BaseChart = ({
width = "100%",
maxWidth = "100%",
padding,
onClick,
}: BaseChartProps) => {
const theme = useTheme();
return (
<BaseBox
onClick={onClick}
sx={{
padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex",
flex: 1,
width: width,
maxWidth: maxWidth,
...(onClick && {
cursor: "pointer",
"& .recharts-wrapper": { cursor: "pointer !important" },
}),
}}
>
<Stack
@@ -47,7 +40,6 @@ export const BaseChart = ({
<Typography
variant="eyebrow"
color="text.secondary"
sx={{ overflowWrap: "anywhere" }}
>
{title}
</Typography>
+4 -14
View File
@@ -1,28 +1,18 @@
import type { CSSProperties } from "react";
export const DotSizes = ["xs", "sm", "md", "lg"] as const;
export type DotSize = (typeof DotSizes)[number];
const DOT_SIZE_PX: Record<DotSize, string> = {
xs: "2px",
sm: "4px",
md: "8px",
lg: "12px",
};
interface DotProps {
color?: string;
size?: DotSize;
size?: string;
style?: CSSProperties;
}
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => {
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
return (
<span
style={{
content: '""',
width: DOT_SIZE_PX[size],
height: DOT_SIZE_PX[size],
width: size,
height: size,
borderRadius: "50%",
backgroundColor: color,
opacity: 0.8,
@@ -5,15 +5,13 @@ interface IconProps {
size?: number;
strokeWidth?: number;
stroke?: string;
color?: string;
}
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => {
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => {
return (
<Icon
size={size}
strokeWidth={strokeWidth}
color={color}
/>
);
};
@@ -4,10 +4,10 @@ import Typography from "@mui/material/Typography";
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import prettyMilliseconds from "pretty-ms";
import { typographyLevels } from "@/Utils/Theme/Palette";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
import { formatDuration } from "@/Utils/TimeUtils";
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
@@ -52,7 +52,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
<>
<Dot />
<Typography>
Checking every {formatDuration(monitor?.interval, true)}
Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
</Typography>
</>
)}
+1 -11
View File
@@ -6,7 +6,6 @@ import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
type AutoCompleteInputProps = Omit<
AutocompleteProps<any, boolean, boolean, boolean>,
@@ -16,9 +15,6 @@ type AutoCompleteInputProps = Omit<
fieldLabel?: string;
required?: boolean;
renderOptionContent?: (option: any) => React.ReactNode;
placeholder?: string;
error?: boolean;
helperText?: string;
};
export const AutoCompleteInput = ({
@@ -26,21 +22,15 @@ export const AutoCompleteInput = ({
required,
renderInput,
renderOptionContent,
placeholder,
error,
helperText,
...props
}: AutoCompleteInputProps) => {
const theme = useTheme();
const { t } = useTranslation();
const multiple = props.multiple;
const { renderOption: customRenderOption, ...autocompleteProps } = props;
const defaultRenderInput = (params: any) => (
<TextField
{...params}
placeholder={placeholder ?? t("common.form.autocomplete.placeholder")}
error={error}
helperText={helperText}
placeholder="Type to search"
/>
);
@@ -13,9 +13,7 @@ export const languageNames: Record<string, string> = {
ca: "Català",
es: "Español",
fr: "Français",
it: "Italiano",
"pt-BR": "Português (BR)",
pl: "Polski",
cs: "Čeština",
fi: "Suomi",
tr: "Türkçe",
-3
View File
@@ -42,9 +42,6 @@ export const RadioWithDescription = ({
"& .MuiButtonBase-root": {
mr: theme.spacing(6),
},
"&:has(.Mui-focusVisible)": {
backgroundColor: theme.palette.action.hover,
},
}}
/>
);
+4 -15
View File
@@ -11,11 +11,10 @@ interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string;
required?: boolean;
placeholder?: string;
helperText?: string;
}
const SelectInputInner = <T,>(
{ fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps<T>,
{ fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
ref: React.ForwardedRef<HTMLDivElement>
) => {
const theme = useTheme();
@@ -28,7 +27,7 @@ const SelectInputInner = <T,>(
: selected === undefined || selected === null || selected === "";
if (isEmpty && placeholder) {
return <Typography color={emptyPlaceholderColor}>{placeholder}</Typography>;
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
}
if (isMultiple) {
@@ -93,21 +92,11 @@ const SelectInputInner = <T,>(
/>
);
const helper = helperText ? (
<Typography
variant="caption"
color={props.error ? theme.palette.error.main : theme.palette.text.secondary}
>
{helperText}
</Typography>
) : null;
if (fieldLabel || helper) {
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{select}
{helper}
</Stack>
);
}
+12 -21
View File
@@ -3,7 +3,6 @@ import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker
import type { Dayjs } from "dayjs";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { FieldLabel } from "./FieldLabel";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
@@ -11,15 +10,11 @@ import { LocalizationProvider } from "@mui/x-date-pickers";
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
required?: boolean;
error?: boolean;
helperText?: string;
}
export const TimePickerComponent = ({
fieldLabel,
required,
error,
helperText,
...props
}: TimePickerComponentProps) => {
const theme = useTheme();
@@ -38,12 +33,12 @@ export const TimePickerComponent = ({
px: theme.spacing(5),
},
"& fieldset": {
borderColor: error ? theme.palette.error.main : theme.palette.divider,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
borderColor: error ? theme.palette.error.main : theme.palette.divider,
borderColor: theme.palette.divider,
},
},
},
@@ -53,18 +48,14 @@ export const TimePickerComponent = ({
</LocalizationProvider>
);
return (
<Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
{picker}
{helperText && (
<Typography
variant="caption"
color={error ? "error" : "text.secondary"}
>
{helperText}
</Typography>
)}
</Stack>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{picker}
</Stack>
);
}
return picker;
};
@@ -1,52 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { Autocomplete } from "@/Components/inputs";
export interface AutocompleteOption {
id: string;
name: string;
}
interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOption> {
name: FieldPath<T>;
options: O[];
fieldLabel?: string;
placeholder?: string;
}
export const FormAutocompleteField = <
T extends FieldValues,
O extends AutocompleteOption,
>({
name,
options,
fieldLabel,
placeholder,
}: FormAutocompleteProps<T, O>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Autocomplete
options={options}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={options.find((o) => o.id === field.value) ?? null}
onChange={(_: unknown, newValue: O | null) => {
field.onChange(newValue?.id ?? "");
}}
fieldLabel={fieldLabel}
placeholder={placeholder}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
);
};
@@ -1,38 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import FormControlLabel from "@mui/material/FormControlLabel";
import { Checkbox } from "@/Components/inputs";
interface FormCheckboxFieldProps<T extends FieldValues> {
name: FieldPath<T>;
label: string;
}
export const FormCheckboxField = <T extends FieldValues>({
name,
label,
}: FormCheckboxFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
}
label={label}
/>
)}
/>
);
};
@@ -1,35 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { ColorInput } from "@/Components/inputs";
interface FormColorFieldProps<T extends FieldValues> {
name: FieldPath<T>;
fieldLabel?: string;
}
export const FormColorField = <T extends FieldValues>({
name,
fieldLabel,
}: FormColorFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<ColorInput
format="hex"
value={field.value}
onChange={field.onChange}
fieldLabel={fieldLabel}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
);
};
@@ -1,81 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import dayjs, { type Dayjs } from "dayjs";
import { DatePicker, TimePicker } from "@/Components/inputs";
export interface FormPickerFieldProps<T extends FieldValues> {
name: FieldPath<T>;
fieldLabel?: string;
format?: string;
onValueChange?: (value: string) => void; // e.g. () => trigger("startDate")
}
interface DayjsPickerProps {
fieldLabel?: string;
value: Dayjs | null;
onChange: (value: Dayjs | null) => void;
error?: boolean;
helperText?: string;
}
interface FormDayjsPickerFieldProps<
T extends FieldValues,
> extends FormPickerFieldProps<T> {
picker: React.ComponentType<DayjsPickerProps>;
format: string;
}
const FormDayjsPickerField = <T extends FieldValues>({
name,
fieldLabel,
format,
onValueChange,
picker: Picker,
}: FormDayjsPickerFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Picker
fieldLabel={fieldLabel}
value={field.value ? dayjs(field.value, format) : null}
onChange={(value) => {
const next = value ? value.format(format) : "";
field.onChange(next);
onValueChange?.(next);
}}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
);
};
export const FormDatePickerField = <T extends FieldValues>({
format = "YYYY-MM-DD",
...rest
}: FormPickerFieldProps<T>) => (
<FormDayjsPickerField
{...rest}
format={format}
picker={DatePicker}
/>
);
export const FormTimePickerField = <T extends FieldValues>({
format = "HH:mm",
...rest
}: FormPickerFieldProps<T>) => (
<FormDayjsPickerField
{...rest}
format={format}
picker={TimePicker}
/>
);
@@ -1,187 +0,0 @@
import { useMemo } from "react";
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useTheme } from "@mui/material/styles";
import { Trash2, GripVertical } from "lucide-react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import type { DropResult } from "@hello-pangea/dnd";
import { useTranslation } from "react-i18next";
import { Autocomplete } from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
export interface MultiSelectOption {
id: string | number;
name: string;
}
interface FormMultiSelectFieldProps<T extends FieldValues, O extends MultiSelectOption> {
name: FieldPath<T>;
options: O[];
fieldLabel?: string;
placeholder?: string;
description?: string;
renderRow?: (option: O) => React.ReactNode;
renderOptionContent?: (option: O) => React.ReactNode;
sortable?: boolean;
}
export const FormMultiSelectField = <T extends FieldValues, O extends MultiSelectOption>({
name,
options,
fieldLabel,
placeholder,
description,
renderRow,
renderOptionContent,
sortable = false,
}: FormMultiSelectFieldProps<T, O>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
const { t } = useTranslation();
const optionsById = useMemo(
() => new Map<O["id"], O>(options.map((o) => [o.id, o])),
[options]
);
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => {
const selectedIds: O["id"][] = field.value ?? [];
const selected = selectedIds
.map((id) => optionsById.get(id))
.filter((o): o is O => o !== undefined);
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const reordered = selected.map((o) => o.id);
const [moved] = reordered.splice(result.source.index, 1);
reordered.splice(result.destination.index, 0, moved);
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
field.onChange([...reordered, ...unresolved]);
};
const rowContent = (option: O) =>
renderRow ? (
renderRow(option)
) : (
<Typography flexGrow={1}>{option.name}</Typography>
);
const removeButton = (option: O) => (
<IconButton
size="small"
onClick={() => {
field.onChange(selectedIds.filter((id) => id !== option.id));
}}
aria-label={t("common.buttons.removeItem", { item: option.name })}
>
<Trash2 size={16} />
</IconButton>
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
autoHighlight
options={options}
value={selected}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
onChange={(_: unknown, newValue: O[]) => {
// The Autocomplete only sees resolved options, so ids missing
// from options must be re-appended or they'd be silently
// dropped (e.g. a role excluded from the selectable list).
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
field.onChange([...newValue.map((o) => o.id), ...unresolved]);
}}
fieldLabel={fieldLabel}
placeholder={selected.length > 0 ? "" : placeholder}
renderOptionContent={renderOptionContent}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
{description && (
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{description}
</Typography>
)}
{sortable
? selected.length > 0 && (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId={`${name}-list`}>
{(provided) => (
<Stack
{...provided.droppableProps}
ref={provided.innerRef}
>
{selected.map((option, index) => (
<Draggable
key={option.id}
draggableId={String(option.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}
border={`1px solid ${theme.palette.divider}`}
sx={{
cursor: "grab",
"&:active": { cursor: "grabbing" },
}}
>
<GripVertical size={20} />
{rowContent(option)}
{removeButton(option)}
</Stack>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
)
: selected.map((option) => (
<Stack
key={option.id}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
border={`1px solid ${theme.palette.divider}`}
width="100%"
>
{rowContent(option)}
{removeButton(option)}
</Stack>
))}
</Stack>
);
}}
/>
);
};
@@ -1,46 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { TextField } from "@/Components/inputs";
interface FormNumberFieldProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof TextField>,
"name" | "error" | "helperText" | "type"
> {
name: FieldPath<T>;
emptyValue?: number; // what an empty input writes to the form; 0 keeps today's sentinel
}
export const FormNumberField = <T extends FieldValues>({
name,
emptyValue = 0,
...rest
}: FormNumberFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="number"
value={
field.value === undefined || field.value === emptyValue ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? emptyValue : Number(val));
}}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
{...rest}
/>
)}
/>
);
};
@@ -1,54 +0,0 @@
import { RadioGroup, useTheme } from "@mui/material";
import FormControl from "@mui/material/FormControl";
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { LAYOUT } from "@/Utils/Theme/constants";
import { RadioWithDescription } from "@/Components/inputs/Radio";
import { FieldLabel } from "@/Components/inputs/FieldLabel";
export interface FormRadioOption {
value: string;
label: string;
description: string;
}
interface FormRadioGroupProps<T extends FieldValues> {
name: FieldPath<T>;
options: FormRadioOption[];
fieldLabel?: string;
}
export const FormRadioGroup = <T extends FieldValues>({
name,
options,
fieldLabel,
}: FormRadioGroupProps<T>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<FormControl error={!!fieldState.error}>
{fieldLabel && <FieldLabel>{fieldLabel}</FieldLabel>}
<RadioGroup
{...field}
sx={{ gap: theme.spacing(LAYOUT.MD) }}
>
{options.map((option) => (
<RadioWithDescription
key={option.value}
value={option.value}
label={option.label}
description={option.description}
/>
))}
</RadioGroup>
</FormControl>
)}
/>
);
};
@@ -1,61 +0,0 @@
import {
Controller,
useFormContext,
type FieldValues,
type FieldPath,
} from "react-hook-form";
import MenuItem from "@mui/material/MenuItem";
import { Select } from "@/Components/inputs";
export interface FormSelectOption {
value: string | number;
label: string;
}
interface FormSelectProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof Select>,
"name" | "error" | "value" | "onChange"
> {
name: FieldPath<T>;
options: FormSelectOption[];
fallbackValue?: string | number; // display value while the field is undefined
onValueChange?: (value: string | number) => void; // side effects, see Notes
}
export const FormSelectField = <T extends FieldValues>({
name,
options,
fallbackValue,
onValueChange,
...rest
}: FormSelectProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Select
{...field}
value={field.value ?? fallbackValue ?? ""}
onChange={(e) => {
field.onChange(e.target.value);
onValueChange?.(e.target.value as string | number);
}}
error={!!fieldState.error}
{...rest}
helperText={fieldState.error?.message ?? rest.helperText}
>
{options.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
)}
/>
);
};
@@ -1,34 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { SliderWithLabel } from "@/Components/inputs";
interface FormSliderProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof SliderWithLabel>,
"name" | "value" | "onChange"
> {
name: FieldPath<T>;
}
export const FormSliderField = <T extends FieldValues>({
name,
...rest
}: FormSliderProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<SliderWithLabel
{...field}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
{...rest}
/>
)}
/>
);
};
@@ -1,59 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { SwitchComponent } from "@/Components/inputs";
import { SPACING } from "@/Utils/Theme/constants";
interface FormSwitchProps<T extends FieldValues> {
name: FieldPath<T>;
label: string;
labelPlacement?: "start" | "end"; // left | right
}
export const FormSwitchField = <T extends FieldValues>({
name,
label,
labelPlacement = "end",
}: FormSwitchProps<T>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={labelPlacement === "start" ? undefined : theme.spacing(SPACING.LG)}
justifyContent={labelPlacement === "start" ? "space-between" : undefined}
>
{labelPlacement === "start" ? (
<>
<Typography>{label}</Typography>
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
</>
) : (
<>
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>{label}</Typography>
</>
)}
</Stack>
)}
/>
);
};
@@ -1,40 +0,0 @@
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { TextField } from "@/Components/inputs";
interface FormTextFieldProps<T extends FieldValues> extends Omit<
React.ComponentProps<typeof TextField>,
"name" | "error" | "helperText"
> {
name: FieldPath<T>;
helperText?: string;
}
export const FormTextField = <T extends FieldValues>({
name,
...rest
}: FormTextFieldProps<T>) => {
const { control } = useFormContext<T>();
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
{...rest}
value={field.value ?? ""}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? rest.helperText ?? ""}
fullWidth
/>
)}
/>
);
};
+9 -7
View File
@@ -18,13 +18,15 @@ const RootLayout = () => {
flex={1}
padding={6}
overflow={"hidden"}
bgcolor={
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.01)"
: "rgba(124, 116, 116, 0.01)"
}
alignItems="center"
paddingLeft={isSmall ? `${collapsedWidth + 12}px` : 12}
sx={{
backgroundColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.01)"
: "rgba(0, 0, 0, 0.01)",
display: "flex",
alignItems: "center",
paddingLeft: isSmall ? `${collapsedWidth + 12}px` : 12,
}}
>
<Stack
maxWidth={1280}
@@ -6,7 +6,6 @@ import type { FlatGeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next";
import { formatStatusCode } from "@/Utils/statusCode";
import "maplibre-gl/dist/maplibre-gl.css";
import { formatMs } from "@/Utils/TimeUtils";
interface GeoChecksMapProps {
geoChecks: FlatGeoCheck[];
@@ -84,6 +83,10 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
return status ? theme.palette.success.main : theme.palette.error.main;
};
const formatResponseTime = (timing: number): string => {
return `${timing.toFixed(0)}ms`;
};
return (
<>
{mapPopupStyles}
@@ -183,7 +186,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
>
Response Time:
</Typography>{" "}
{formatMs(selectedCheck.timings.total)}
{formatResponseTime(selectedCheck.timings.total)}
</Typography>
<Typography
variant="caption"
@@ -1,156 +0,0 @@
import { HeaderCreate } from "@/Components/common";
import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements";
import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar";
import { ControlsFilter } from "@/Components/monitors/ControlsFilter";
import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { MonitorListController } from "@/Hooks/useMonitorListController";
import { useMediaQuery, useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { TextField, Button, Dialog } from "@/Components/inputs";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pause, Play } from "lucide-react";
interface MonitorListPageProps {
headerKey: string;
page: string;
actionLink: string;
controller: MonitorListController;
bulkActions?: boolean;
showTypeFilter?: boolean;
summaryProps?: { showBreached?: boolean };
priorityFallback?: ReactNode;
extraLoading?: boolean;
extraError?: unknown;
children: ReactNode;
}
// Base monitor list page, all monitors pages are nearly identical, but with different tables.
export const MonitorListPage = ({
headerKey,
page,
actionLink,
controller: c,
bulkActions,
showTypeFilter,
summaryProps,
priorityFallback,
extraLoading,
extraError,
children,
}: MonitorListPageProps) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const isLoading = c.isLoading || Boolean(extraLoading);
return (
<MonitorBasePageWithStates
headerKey={headerKey}
loading={isLoading}
error={c.error || extraError}
totalCount={c.effectiveTotalCount}
page={page}
actionLink={actionLink}
priorityFallback={priorityFallback}
>
<HeaderCreate
path={actionLink}
isLoading={isLoading}
isAdmin={isAdmin}
/>
<HeaderMonitorsSummary
summary={c.summary}
{...summaryProps}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
{...(showTypeFilter === false
? { showTypes: false }
: {
selectedTypes: c.selectedTypes,
setSelectedTypes: c.setSelectedTypes,
})}
selectedStatus={c.selectedStatus}
setSelectedStatus={c.setSelectedStatus}
selectedState={c.selectedState}
setSelectedState={c.setSelectedState}
tagOptions={c.tags ?? []}
selectedTags={c.selectedTags}
setSelectedTags={c.setSelectedTags}
onClearFilters={c.handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={c.search}
onChange={(event) => {
c.setSearch(event.target.value);
}}
/>
</Stack>
{c.selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{c.selectedTags.map((tagId) => {
const tag = c.tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{bulkActions && !isLoading && (
<BulkActionsBar
selectedCount={c.selectedRows.length}
onCancel={c.handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={c.handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={c.handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
{children}
<Dialog
open={c.isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={c.handleConfirmDelete}
onCancel={c.handleCancelDelete}
loading={c.isDeleting}
/>
</MonitorBasePageWithStates>
);
};
@@ -1,24 +1,22 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/design-elements";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material/styles";
import type { MonitorStats, Monitor } from "@/Types/Monitor";
import { getStatusPalette } from "@/Utils/MonitorUtils";
import { useTranslation } from "react-i18next";
import { formatMs, formatDuration } from "@/Utils/TimeUtils";
interface MonitorStatBoxesProps {
monitor?: Monitor;
monitorStats: MonitorStats | null;
certificateExpiry?: string;
domainExpiry?: string;
}
export const MonitorStatBoxes = ({
monitor,
monitorStats,
certificateExpiry,
domainExpiry,
}: MonitorStatBoxesProps) => {
const theme = useTheme();
const { t } = useTranslation();
@@ -33,9 +31,14 @@ export const MonitorStatBoxes = ({
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
const streakTime = formatDuration(timeSinceLastFailure);
const options = {
secondsDecimalDigits: 0,
millisecondsDecimalDigits: 0,
};
const lastCheckTime = formatDuration(timeSinceLastCheck);
const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
const isActive =
monitor?.status === "up" ||
monitor?.status === "paused" ||
@@ -64,20 +67,14 @@ export const MonitorStatBoxes = ({
/>
<StatBox
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
subtitle={formatMs(monitorStats?.lastResponseTime ?? 0)}
subtitle={prettyMilliseconds(monitorStats?.lastResponseTime ?? 0)}
/>
{monitor?.type === "http" && (
<>
<StatBox
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
subtitle={certificateExpiry || "N/A"}
/>
<StatBox
title={t("pages.common.monitors.statBoxes.domainExpiry")}
subtitle={domainExpiry || "N/A"}
/>
</>
<StatBox
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
subtitle={certificateExpiry || "N/A"}
/>
)}
</Stack>
);
@@ -1,34 +0,0 @@
export const computeYAxisCap = (values: number[]): number | undefined => {
const sorted = values.slice().sort((a, b) => a - b);
if (sorted.length < 2) return undefined;
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
};
export const PHASE_KEYS = [
"avgDns",
"avgTcp",
"avgTls",
"avgRequest",
"avgFirstByte",
"avgDownload",
] as const;
export const PHASE_COLOR_KEYS = {
avgDns: "dns",
avgTcp: "tcp",
avgTls: "tls",
avgRequest: "request",
avgFirstByte: "firstByte",
avgDownload: "download",
} as const;
// i18n keys, resolved with t() at render
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
avgDns: "common.charts.phases.dns",
avgTcp: "common.charts.phases.tcp",
avgTls: "common.charts.phases.tls",
avgRequest: "common.charts.phases.request",
avgFirstByte: "common.charts.phases.firstByte",
avgDownload: "common.charts.phases.download",
};
@@ -1,39 +1,53 @@
import Stack from "@mui/material/Stack";
import { BaseChart, BaseBox } from "@/Components/design-elements";
import { Clock } from "lucide-react";
import { Dot } from "@/Components/design-elements";
import {
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
Text,
} from "recharts";
import type { TooltipProps } from "recharts";
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import Typography from "@mui/material/Typography";
import { XTick } from "@/Components/monitors/charts/XTick";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { formatDateWithTz, formatMs, tooltipDateFormatLookup } from "@/Utils/TimeUtils";
import {
formatDateWithTz,
tickDateFormatLookup,
tooltipDateFormatLookup,
} from "@/Utils/TimeUtils";
import { useTheme } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles";
import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check";
import type { GroupedCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state";
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
import { useMemo, useState } from "react";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import {
PHASE_COLOR_KEYS,
PHASE_KEYS,
PHASE_LABEL_KEYS,
} from "@/Components/monitors/charts/ChartUtils";
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
type DetailsCheck = GroupedCheck &
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>;
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
@@ -49,65 +63,17 @@ const ResponseTimeToolTip = ({
theme,
uiTimezone,
}: ResponseTimeToolTipProps) => {
const { t } = useTranslation();
if (!label) return null;
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
const responseTime = payload[0]?.payload?.avgResponseTime ?? 0;
console.log(payload);
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
<Typography>
{t("common.charts.histogram.responseTime", {
value: formatMs(responseTime),
})}
</Typography>
</BaseBox>
);
};
type TimingPhaseToolTipProps = TooltipProps<ValueType, NameType> & {
range: string;
theme: Theme;
uiTimezone: string;
};
const TimingPhasesToolTip = ({
active,
payload,
range,
theme,
uiTimezone,
}: TimingPhaseToolTipProps) => {
const { t } = useTranslation();
if (!active || !payload?.length) return null;
const bucket = payload[0].payload as GroupedUptimeCheck;
const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0);
const format = tooltipDateFormatLookup(range);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(bucket.bucketDate, format, uiTimezone)}</Typography>
<Typography>
{t("common.charts.histogram.total", { value: formatMs(total) })}
</Typography>
{PHASE_KEYS.map((key) => (
<Stack
alignItems={"center"}
direction="row"
key={key}
gap={SPACING.LG}
>
<Dot
size="lg"
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
/>
<Typography>
{t(PHASE_LABEL_KEYS[key])}: {formatMs(bucket[key])}
</Typography>
</Stack>
))}
<Typography>Response time: {responseTime} ms</Typography>
</BaseBox>
);
};
@@ -116,28 +82,11 @@ export const HistogramDetails = ({
checks,
range,
}: {
checks: DetailsCheck[];
checks: GroupedCheck[];
range: string;
}) => {
const [showTimingPhases, setShowTimingPhases] = useState(false);
const [hoveredPhase, setHoveredPhase] = useState<string | null>(null);
const { t } = useTranslation();
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const yMax = useMemo(() => {
const totals = checks.map((check) => {
const phaseSum = PHASE_KEYS.reduce((sum, key) => sum + (check[key] ?? 0), 0);
return Math.max(check.avgResponseTime ?? 0, phaseSum);
});
return computeYAxisCap(totals);
}, [checks]);
const hasTimingPhases = useMemo(
() => (checks ?? []).some((check) => PHASE_KEYS.some((key) => (check[key] ?? 0) > 0)),
[checks]
);
return (
<BaseChart
icon={
@@ -146,15 +95,7 @@ export const HistogramDetails = ({
strokeWidth={1.5}
/>
}
title={
showTimingPhases
? t("common.charts.labels.timingPhases")
: t("common.charts.labels.responseTime")
}
onClick={() => {
if (!hasTimingPhases) return;
setShowTimingPhases((prev) => !prev);
}}
title="Response times"
>
<ResponsiveContainer
width="100%"
@@ -187,34 +128,7 @@ export const HistogramDetails = ({
stopOpacity={0}
/>
</linearGradient>
{PHASE_KEYS.map((key) => (
<linearGradient
key={key}
id={`phaseGradient-${key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
stopOpacity={0.2}
/>
</linearGradient>
))}
</defs>
<YAxis
hide
// scale="sqrt"
domain={[0, yMax ?? "auto"]}
allowDataOverflow
/>
<XAxis
axisLine={false}
tickLine={false}
@@ -227,99 +141,24 @@ export const HistogramDetails = ({
)}
/>
{showTimingPhases ? (
<Tooltip
content={(props) => (
<TimingPhasesToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
) : (
<Tooltip
content={(props) => (
<ResponseTimeToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
)}
{!showTimingPhases && (
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
)}
{hasTimingPhases &&
showTimingPhases &&
PHASE_KEYS.map((key) => {
const dimmed = hoveredPhase !== null && hoveredPhase !== key;
return (
<Area
key={key}
type="monotone"
stackId={1}
dataKey={key}
stroke={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
fill={`url(#phaseGradient-${key})`}
fillOpacity={dimmed ? 0.2 : undefined}
strokeOpacity={dimmed ? 0.2 : undefined}
/>
);
})}
<Tooltip
content={(props) => (
<ResponseTimeToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
<Stack
justifyContent={"center"}
gap={LAYOUT.XS}
direction={"row"}
flexWrap={"wrap"}
>
{showTimingPhases ? (
PHASE_KEYS.map((key) => {
return (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
key={key}
onMouseEnter={() => setHoveredPhase(key)}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
size="lg"
/>
<Typography lineHeight={1}>{t(PHASE_LABEL_KEYS[key])}</Typography>
</Stack>
);
})
) : (
<Stack
direction="row"
alignItems="center"
gap={SPACING.LG}
onMouseEnter={() => setHoveredPhase("avgResponseTime")}
onMouseLeave={() => setHoveredPhase(null)}
>
<Dot
color={theme.palette.primary.main}
size="lg"
/>
<Typography lineHeight={1}>
{t("common.charts.labels.totalResponseTime")}
</Typography>
</Stack>
)}
</Stack>
</BaseChart>
);
};
@@ -8,7 +8,7 @@ import {
ResponsiveContainer,
} from "recharts";
import { Fragment, useId } from "react";
import { XTick } from "@/Components/monitors/charts/XTick";
import { XTick } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor";
@@ -1,6 +1,6 @@
import { BaseChart } from "@/Components/design-elements";
import { TrendingUp } from "lucide-react";
import { XTick } from "@/Components/monitors/charts/XTick";
import { XTick } from "@/Components/monitors";
import {
XAxis,
AreaChart,
@@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography";
import { BaseChart } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
@@ -100,7 +100,7 @@ export const HistogramStatus = ({
</Typography>
) : null}
<Typography variant="caption">{titleText}</Typography>
<Typography variant="body2">{formatMs(avg)}</Typography>
<Typography variant="body2">{Math.floor(avg)} ms</Typography>
</Stack>
);
};
@@ -7,7 +7,6 @@ import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
import { useTranslation } from "react-i18next";
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import { formatMs } from "@/Utils/TimeUtils";
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
const { t } = useTranslation();
@@ -21,7 +20,7 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
const msg: Record<string, string> = {
success: "Excellent",
warning: "Average",
error: "Poor",
danger: "Poor",
};
return (
@@ -85,9 +84,7 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
<Typography
variant="h1"
textAlign={"center"}
>
{formatMs(avg)}
</Typography>
>{`${avg?.toFixed()}ms`}</Typography>
</Stack>
</Stack>
</BaseChart>
@@ -1,30 +0,0 @@
import { formatDateWithTz, tickDateFormatLookup } from "@/Utils/TimeUtils";
import { useSelector } from "react-redux";
import { Text } from "recharts";
import { useTheme } from "@mui/material";
import type { RootState } from "@/Types/state";
type XTickProps = {
x: number;
y: number;
payload: { value: string };
range: string;
};
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
-1
View File
@@ -15,4 +15,3 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar";
export * from "./MonitorListPage";
+9 -21
View File
@@ -10,11 +10,10 @@ import { useSelector, useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useNavigate } from "react-router";
import { MoreVertical, LogOut, Moon, Sun } from "lucide-react";
import { MoreVertical, LogOut } from "lucide-react";
import { Avatar, Icon } from "@/Components/design-elements";
import { clearAuthState } from "@/Features/Auth/authSlice.js";
import type { RootState } from "@/Types/state.js";
import { setMode } from "@/Features/UI/uiSlice.js";
interface AuthFooterProps {
collapsed: boolean;
@@ -28,7 +27,6 @@ interface AuthFooterProps {
export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {
const { t } = useTranslation();
const theme = useTheme();
const mode = useSelector((state: RootState) => state.ui.mode);
const user = useSelector((state: RootState) => state.auth.user);
const navigate = useNavigate();
const dispatch = useDispatch();
@@ -107,14 +105,13 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
<Typography
fontWeight={500}
noWrap
color={theme.palette.text.primary}
sx={{ color: theme.palette.text.primary }}
>
{user?.firstName} {user?.lastName}
</Typography>
<Typography
noWrap
textTransform={"capitalize"}
color={theme.palette.text.secondary}
sx={{ textTransform: "capitalize", color: theme.palette.text.secondary }}
>
{getRoleText()}
</Typography>
@@ -157,12 +154,15 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
pb={2}
sx={{ pointerEvents: "none" }}
>
<Typography fontWeight={500}>
<Typography
fontWeight={500}
fontSize={13}
>
{user?.firstName} {user?.lastName}
</Typography>
<Typography
variant="body2"
textTransform={"capitalize"}
fontSize={12}
sx={{ textTransform: "capitalize" }}
>
{getRoleText()}
</Typography>
@@ -178,18 +178,6 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
{item.name}
</MenuItem>
))}
<MenuItem
onClick={() => {
dispatch(setMode(mode === "light" ? "dark" : "light"));
handleMenuClose();
}}
sx={menuItemSx}
>
<Icon icon={mode === "light" ? Moon : Sun} />
{mode === "light"
? t("pages.settings.form.ui.option.theme.dark")
: t("pages.settings.form.ui.option.theme.light")}
</MenuItem>
<MenuItem
onClick={handleLogout}
sx={menuItemSx}
+1 -6
View File
@@ -18,8 +18,8 @@ import {
Lock,
Users,
Tag,
Waypoints,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
@@ -47,11 +47,6 @@ export const getMenu = (t: Function) => {
path: "tags",
icon: <Icon icon={Tag} />,
},
{
name: t("components.sidebar.menu.proxies"),
path: "proxies",
icon: <Icon icon={Waypoints} />,
},
{
name: t("components.sidebar.menu.checks"),
path: "checks",
-1
View File
@@ -102,7 +102,6 @@ export const Sidebar = () => {
})}
</List>
<StarPrompt />
<Divider sx={{ borderColor: theme.palette.divider }} />
<AuthFooter
+1 -11
View File
@@ -2,13 +2,7 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
type TableName =
| "monitors"
| "team"
| "maintenance"
| "infrastructure"
| "logs"
| "pagespeed";
type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs";
interface TableState {
rowsPerPage: number;
@@ -20,7 +14,6 @@ interface SidebarState {
interface UIState {
monitors: TableState;
pagespeed: TableState;
team: TableState;
maintenance: TableState;
infrastructure: TableState;
@@ -44,9 +37,6 @@ const initialState: UIState = {
monitors: {
rowsPerPage: 10,
},
pagespeed: {
rowsPerPage: 10,
},
team: {
rowsPerPage: 5,
},
+3 -12
View File
@@ -1,11 +1,6 @@
import { useMemo } from "react";
import { monitorSchema, type MonitorFormData } from "@/Validation/monitor";
import {
DefaultHttpMethod,
DefaultMonitorMatchMethod,
DefaultPageSpeedStrategy,
DefaultProxyMode,
} from "@/Types/Monitor";
import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
interface UseMonitorFormOptions {
@@ -47,10 +42,8 @@ export const getMonitorDefaults = (
url: data?.url || "",
method: data?.method ?? DefaultHttpMethod,
ignoreTlsErrors: data?.ignoreTlsErrors || false,
proxyMode: data?.proxyMode ?? DefaultProxyMode,
proxyId: data?.proxyId || "",
useAdvancedMatching: data?.useAdvancedMatching || false,
matchMethod: data?.matchMethod || DefaultMonitorMatchMethod,
matchMethod: data?.matchMethod || "",
expectedValue: data?.expectedValue || "",
jsonPath: data?.jsonPath || "",
customUpCodes: data?.customUpCodes || [],
@@ -144,10 +137,8 @@ export const getMonitorDefaults = (
url: "",
method: DefaultHttpMethod,
ignoreTlsErrors: false,
proxyMode: DefaultProxyMode,
proxyId: "",
useAdvancedMatching: false,
matchMethod: DefaultMonitorMatchMethod,
matchMethod: "",
expectedValue: "",
jsonPath: "",
customUpCodes: [],
@@ -1,209 +0,0 @@
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
import { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce";
import type { RootState } from "@/store";
import {
type MonitorsWithChecksResponse,
SelectableMonitorTypes,
type Monitor,
type MonitorType,
} from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
interface MonitorListConfig {
types: MonitorType[] | "selectable";
checksLimit: number;
refreshInterval: number;
rowsPerPageTable: TableName;
rowsPerPageDefault: number;
initialSortField?: string; // default ""
}
export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) =>
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
);
// Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
const effectiveTypes = useMemo(() => {
return config.types === "selectable"
? selectedTypes.length > 0
? selectedTypes
: [...SelectableMonitorTypes]
: config.types;
}, [config.types, selectedTypes]);
// Fetching
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
effectiveTypes.forEach((type) => params.append("type", type));
selectedTags.forEach((tag) => params.append("tags", tag));
params.append("limit", String(config.checksLimit));
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [
effectiveTypes,
selectedTags,
page,
rowsPerPage,
filter,
field,
sortOrder,
config.checksLimit,
]);
const {
data: monitorsWithChecksData,
isLoading,
error,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: config.refreshInterval, keepPreviousData: true }
);
const { data: tags } = useGet<Tag[]>("/tags/team");
const bulk = useBulkMonitorActions(refetch, page);
// Delete ops
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirmDelete = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancelDelete = () => setSelectedMonitor(null);
// Handle filters
const handleClearFilters = useCallback(() => {
setSelectedTypes([]);
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const handleSetRowsPerPage = (value: number) => {
dispatch(setRowsPerPage({ value, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
selectedStatus ||
selectedState ||
selectedTags.length > 0 ||
search
);
// Show empty state only when there are truly no monitors (not just filtered out)
// If filters are active and count is 0, pass 1 to prevent empty state fallback
const { monitors, summary, count } = monitorsWithChecksData ?? {
monitors: null,
summary: null,
count: 0,
};
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return {
// Data
monitors,
summary,
count,
tags,
isLoading,
error,
refetch,
effectiveTotalCount,
hasActiveFilters,
// Filter state and setters
selectedTypes,
setSelectedTypes,
selectedStatus,
setSelectedStatus,
selectedState,
setSelectedState,
selectedTags,
setSelectedTags,
search,
setSearch,
handleClearFilters,
// Paging and Sorting
page,
setPage,
rowsPerPage,
handleSetRowsPerPage,
sortField,
setSortField,
sortOrder,
setSortOrder,
// Delete
selectedMonitor,
setSelectedMonitor,
isDialogOpen: Boolean(selectedMonitor),
isDeleting,
handleConfirmDelete,
handleCancelDelete,
// Rendered only when config.bulkActions
...bulk,
};
};
export type MonitorListController = ReturnType<typeof useMonitorListController>;
+80 -8
View File
@@ -8,17 +8,89 @@ interface UseNotificationFormOptions {
}
function buildDefaults(data: Notification | null): NotificationFormData {
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "telegram") {
return {
type: "telegram",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "twilio") {
return {
type: "twilio",
notificationName: data.notificationName || "",
accountSid: data.accountSid || "",
accessToken: data.accessToken || "",
phone: data.phone || "",
twilioPhoneNumber: data.twilioPhoneNumber || "",
};
}
if (data?.type === "pushover") {
return {
type: "pushover",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
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")
return {
type: data?.type ?? "email",
type: "email",
notificationName: data?.notificationName || "",
address: data?.address || "",
accessToken: data?.accessToken || "",
accountSid: data?.accountSid || "",
phone: data?.phone || "",
twilioPhoneNumber: data?.twilioPhoneNumber || "",
homeserverUrl: data?.homeserverUrl || "",
roomId: data?.roomId || "",
topic: data?.topic || "",
};
}
-22
View File
@@ -1,22 +0,0 @@
import { useMemo } from "react";
import { proxySchema, type ProxyFormData } from "@/Validation/proxy";
import type { ProxyResponse } from "@/Types/Proxy";
interface UseProxiesFormOptions {
data?: ProxyResponse | null;
}
export const useProxiesForm = ({ data }: UseProxiesFormOptions) => {
return useMemo(() => {
const defaults: ProxyFormData = {
name: data?.name ?? "",
protocol: data?.protocol ?? "http",
host: data?.host ?? "",
port: data?.port ?? 8080,
username: "",
password: "",
};
return { schema: proxySchema, defaults };
}, [data]);
};
-2
View File
@@ -43,8 +43,6 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "",
systemEmailPassword: "",
globalProxyEnabled: data?.globalProxyEnabled ?? false,
globalProxyId: data?.globalProxyId ?? null,
};
return { schema: settingsSchema, defaults };
+103 -39
View File
@@ -1,6 +1,11 @@
import { TextField, Button } from "@/Components/inputs";
import { Trash2 } from "lucide-react";
import { TextField, Button, Autocomplete } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import { FormProvider, useForm } from "react-hook-form";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Divider from "@mui/material/Divider";
import FormHelperText from "@mui/material/FormHelperText";
import { useForm, Controller } from "react-hook-form";
import { ConfigBox, BasePage } from "@/Components/design-elements";
import { UserRoles } from "@/Types/User";
@@ -17,8 +22,6 @@ import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
interface RoleOption {
id: UserRole;
@@ -40,11 +43,10 @@ const EditUserPage = () => {
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
const form = useForm<EditUserFormData>({
const { control, handleSubmit, reset, watch, setValue } = useForm<EditUserFormData>({
resolver,
defaultValues: defaults,
});
const { handleSubmit, reset } = form;
useEffect(() => {
if (user) {
@@ -62,6 +64,13 @@ const EditUserPage = () => {
name: t(`common.auth.roles.${role}`),
}));
const watchedRoles = watch("role");
const selectedRoles = roleOptions.filter((r) => watchedRoles?.includes(r.id));
const handleRemoveRole = (roleToRemove: UserRole) => {
const newRoles = watchedRoles.filter((role) => role !== roleToRemove);
setValue("role", newRoles, { shouldValidate: true });
};
const canDeleteUser =
isAdmin &&
userId !== currentUser?.id &&
@@ -81,31 +90,42 @@ const EditUserPage = () => {
};
return (
<FormProvider {...form}>
<BasePage
loading={isLoading}
component={"form"}
onSubmit={handleSubmit(onSubmit)}
>
<BasePage loading={isLoading}>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap={theme.spacing(8)}>
<ConfigBox
title={t("pages.account.form.name.title")}
subtitle={t("pages.account.form.name.description")}
rightContent={
<Stack gap={theme.spacing(8)}>
<FormTextField
<Controller
name="firstName"
autoComplete="given-name"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
autoComplete="given-name"
/>
)}
/>
<FormTextField
<Controller
name="lastName"
autoComplete="family-name"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message}
autoComplete="family-name"
/>
)}
/>
<TextField
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
@@ -119,11 +139,55 @@ const EditUserPage = () => {
title={t("pages.editUser.form.roles.title")}
subtitle={t("pages.editUser.form.roles.description")}
rightContent={
<FormMultiSelectField
name="role"
fieldLabel={t("common.form.role.option.role.label")}
options={roleOptions}
/>
<Stack spacing={theme.spacing(4)}>
<Controller
name="role"
control={control}
render={({ field, fieldState }) => (
<>
<Autocomplete
fieldLabel={t("common.form.role.option.role.label")}
multiple
options={roleOptions}
value={selectedRoles}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: RoleOption[]) => {
field.onChange(newValue.map((r) => r.id));
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
/>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
)}
/>
{selectedRoles.length > 0 && (
<Stack
flex={1}
width="100%"
>
{selectedRoles.map((role, index) => (
<Stack
direction="row"
alignItems="center"
key={role.id}
width="100%"
>
<Typography flexGrow={1}>{role.name}</Typography>
<IconButton
size="small"
onClick={() => handleRemoveRole(role.id)}
aria-label="Remove role"
>
<Trash2 size={16} />
</IconButton>
{index < selectedRoles.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
}
/>
<Stack
@@ -153,19 +217,19 @@ const EditUserPage = () => {
</Button>
</Stack>
</Stack>
<DialogInput
open={showDeleteDialog}
title={t("pages.editUser.dialog.removeUser.title")}
content={t("pages.editUser.dialog.removeUser.content", {
name: `${user?.firstName} ${user?.lastName}`,
})}
onCancel={() => setShowDeleteDialog(false)}
onConfirm={handleDeleteUser}
confirmText={t("common.buttons.removeUser")}
loading={isDeleting}
/>
</BasePage>
</FormProvider>
</form>
<DialogInput
open={showDeleteDialog}
title={t("pages.editUser.dialog.removeUser.title")}
content={t("pages.editUser.dialog.removeUser.content", {
name: `${user?.firstName} ${user?.lastName}`,
})}
onCancel={() => setShowDeleteDialog(false)}
onConfirm={handleDeleteUser}
confirmText={t("common.buttons.removeUser")}
loading={isDeleting}
/>
</BasePage>
);
};
+82 -61
View File
@@ -1,15 +1,13 @@
import { Stack } from "@mui/material";
import { ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { TextField, Button } from "@/Components/inputs";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { usePasswordForm } from "@/Hooks/usePasswordForm";
import { usePatch } from "@/Hooks/UseApi";
import type { PasswordFormData } from "@/Validation/password";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { LAYOUT } from "@/Utils/Theme/constants";
export const TabPassword = () => {
const { t } = useTranslation();
@@ -17,11 +15,10 @@ export const TabPassword = () => {
const { resolver, defaults } = usePasswordForm();
const { patch, loading } = usePatch<FormData, void>();
const form = useForm<PasswordFormData>({
const { control, handleSubmit, reset } = useForm<PasswordFormData>({
resolver,
defaultValues: defaults,
});
const { handleSubmit, reset } = form;
const onSubmit = async (data: PasswordFormData) => {
const fd = new FormData();
@@ -35,61 +32,85 @@ export const TabPassword = () => {
};
return (
<FormProvider {...form}>
<Stack
gap={theme.spacing(LAYOUT.MD)}
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.account.form.currentPassword.title")}
subtitle={t("pages.account.form.currentPassword.description")}
rightContent={
<FormTextField
name="currentPassword"
type="password"
autoComplete="current-password"
fieldLabel={t("pages.account.form.currentPassword.option.label")}
placeholder={t("pages.account.form.currentPassword.option.placeholder")}
<Stack
gap={theme.spacing(8)}
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.account.form.currentPassword.title")}
subtitle={t("pages.account.form.currentPassword.description")}
rightContent={
<Controller
name="currentPassword"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.account.form.currentPassword.option.label")}
placeholder={t("pages.account.form.currentPassword.option.placeholder")}
autoComplete="current-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.account.form.newPassword.title")}
subtitle={t("pages.account.form.newPassword.description")}
rightContent={
<Stack gap={theme.spacing(8)}>
<Controller
name="newPassword"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t(
"pages.account.form.newPassword.option.newPassword.label"
)}
placeholder={t(
"pages.account.form.newPassword.option.newPassword.placeholder"
)}
autoComplete="new-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.account.form.newPassword.title")}
subtitle={t("pages.account.form.newPassword.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="newPassword"
type="password"
autoComplete="new-password"
fieldLabel={t("pages.account.form.newPassword.option.newPassword.label")}
placeholder={t(
"pages.account.form.newPassword.option.newPassword.placeholder"
)}
/>
<FormTextField
name="confirm"
type="password"
autoComplete="new-password"
fieldLabel={t("pages.account.form.newPassword.option.confirm.label")}
placeholder={t(
"pages.account.form.newPassword.option.confirm.placeholder"
)}
/>
</Stack>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={loading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
</FormProvider>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.account.form.newPassword.option.confirm.label")}
placeholder={t(
"pages.account.form.newPassword.option.confirm.placeholder"
)}
autoComplete="new-password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={loading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
);
};
+64 -52
View File
@@ -1,11 +1,11 @@
import { Stack } from "@mui/material";
import { ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { TextField, Button } from "@/Components/inputs";
import { ImageUpload } from "@/Components/inputs";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { useSelector, useDispatch } from "react-redux";
import { useProfileForm } from "@/Hooks/useProfileForm";
import { usePatch } from "@/Hooks/UseApi";
@@ -13,8 +13,6 @@ import { setUser } from "@/Features/Auth/authSlice";
import type { ProfileFormData } from "@/Validation/profile";
import type { RootState } from "@/Types/state";
import type { User } from "@/Types/User";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { LAYOUT } from "@/Utils/Theme/constants";
export const TabProfile = () => {
const theme = useTheme();
@@ -24,7 +22,7 @@ export const TabProfile = () => {
const { resolver, defaults } = useProfileForm();
const { patch, loading: patchLoading } = usePatch<FormData, User>();
const form = useForm<ProfileFormData>({
const { control, handleSubmit, setValue, watch } = useForm<ProfileFormData>({
resolver,
defaultValues: {
firstName: user?.firstName ?? defaults.firstName,
@@ -33,7 +31,7 @@ export const TabProfile = () => {
deleteProfileImage: defaults.deleteProfileImage,
},
});
const { handleSubmit, setValue, watch } = form;
const currentImage = watch("profileImage");
const deleteImage = watch("deleteProfileImage");
@@ -76,52 +74,66 @@ export const TabProfile = () => {
};
return (
<FormProvider {...form}>
<Stack
gap={theme.spacing(LAYOUT.MD)}
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.account.form.name.title")}
subtitle={t("pages.account.form.name.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="firstName"
autoComplete="given-name"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
/>
<FormTextField
name="lastName"
autoComplete="family-name"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.account.form.photo.title")}
subtitle={t("pages.account.form.photo.description")}
rightContent={
<ImageUpload
src={getCurrentImageSrc()}
onChange={handleImageChange}
<Stack
gap={theme.spacing(8)}
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.account.form.name.title")}
subtitle={t("pages.account.form.name.description")}
rightContent={
<Stack gap={theme.spacing(8)}>
<Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
autoComplete="given-name"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={patchLoading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
</FormProvider>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
autoComplete="family-name"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.account.form.photo.title")}
subtitle={t("pages.account.form.photo.description")}
rightContent={
<ImageUpload
src={getCurrentImageSrc()}
onChange={handleImageChange}
/>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={patchLoading}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
);
};
@@ -1,16 +1,15 @@
import { Stack } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { Dialog } from "@/Components/inputs";
import { Dialog, TextField, Select } from "@/Components/inputs";
import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm";
import type { AddTeamMemberFormData } from "@/Validation/addTeamMember";
import type { User } from "@/Types/User";
import type { UserRole, User } from "@/Types/User";
import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { RoleSelectField } from "./RoleSelectField";
interface AddTeamMemberDialogProps {
open: boolean;
@@ -36,13 +35,17 @@ export const AddTeamMemberDialog = ({
const { schema, defaults } = useAddTeamMemberForm();
const { post, loading } = usePost<RegisterPayload, User>();
const form = useForm<AddTeamMemberFormData>({
const { control, handleSubmit, reset, setError } = useForm<AddTeamMemberFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
values: defaults,
});
const { handleSubmit, reset, setError } = form;
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleClose = () => {
reset();
@@ -52,7 +55,7 @@ export const AddTeamMemberDialog = ({
const onSubmit = async (data: AddTeamMemberFormData) => {
if (loading) return;
const { confirm: _confirm, ...userData } = data;
const { confirm, ...userData } = data;
const payload: RegisterPayload = userData;
const result = await post("/auth/users", payload);
@@ -69,64 +72,124 @@ export const AddTeamMemberDialog = ({
};
return (
<FormProvider {...form}>
<Dialog
open={open}
title={t("pages.account.team.addMember.title")}
content={t("pages.account.team.addMember.description")}
onCancel={handleClose}
onConfirm={handleSubmit(onSubmit)}
confirmText={t("common.buttons.addMember")}
loading={loading}
maxWidth="sm"
fullWidth
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<FormTextField
name="firstName"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
/>
<FormTextField
name="lastName"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
/>
</Stack>
<FormTextField
name="email"
type="email"
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
<Dialog
open={open}
title={t("pages.account.team.addMember.title")}
content={t("pages.account.team.addMember.description")}
onCancel={handleClose}
onConfirm={handleSubmit(onSubmit)}
confirmText={t("common.buttons.addMember")}
loading={loading}
maxWidth="sm"
fullWidth
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
<RoleSelectField />
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<FormTextField
name="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
sx={{ flex: 1 }}
/>
<FormTextField
name="confirm"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
/>
</Stack>
</Stack>
</Dialog>
</FormProvider>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
type="email"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
fullWidth
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value[0] ?? "user"}
onChange={(e) => field.onChange([e.target.value])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
)}
/>
<Stack
direction="row"
gap={theme.spacing(LAYOUT.XS)}
>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
type="password"
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ flex: 1 }}
/>
)}
/>
</Stack>
</Stack>
</Dialog>
);
};
@@ -1,18 +1,17 @@
import { Stack } from "@mui/material";
import { useTheme, Typography } from "@mui/material";
import MenuItem from "@mui/material/MenuItem";
import { useTheme, FormHelperText, Typography } from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form";
import { Dialog, TextField, Button } from "@/Components/inputs";
import { useForm, Controller } from "react-hook-form";
import { Dialog, TextField, Select, Button } from "@/Components/inputs";
import type { UserRole } from "@/Types/User";
import { useInviteForm } from "@/Hooks/useInviteForm";
import type { InviteFormData } from "@/Validation/invite";
import { usePost } from "@/Hooks/UseApi";
import { LAYOUT } from "@/Utils/Theme/constants";
import { runtimeConfig } from "@/Utils/runtimeConfig";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { RoleSelectField } from "./RoleSelectField";
const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST;
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST;
interface InviteResponse {
token: string;
@@ -31,12 +30,11 @@ export const InviteTeamMemberDialog = ({
const { t } = useTranslation();
const { resolver, defaults } = useInviteForm();
const form = useForm<InviteFormData>({
const { control, handleSubmit, reset } = useForm<InviteFormData>({
resolver,
defaultValues: defaults,
values: defaults,
});
const { handleSubmit, reset } = form;
const { post: generateToken, loading: generateLoading } = usePost<
InviteFormData,
@@ -48,11 +46,17 @@ export const InviteTeamMemberDialog = ({
>();
const [inviteLink, setInviteLink] = useState<string | null>(null);
const roleOptions: { value: UserRole; label: string }[] = [
{ value: "admin", label: t("common.auth.roles.admin") },
{ value: "user", label: t("common.auth.roles.user") },
{ value: "demo", label: t("common.auth.roles.demo") },
];
const handleGenerateToken = async (data: InviteFormData) => {
const result = await generateToken("/invite", data);
if (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);
}
};
@@ -71,55 +75,88 @@ export const InviteTeamMemberDialog = ({
};
return (
<FormProvider {...form}>
<Dialog
open={open}
title={t("pages.account.team.invite.title")}
content={t("pages.account.team.invite.description")}
onCancel={handleClose}
onConfirm={handleSubmit(handleSendInvite)}
confirmText={t("common.buttons.sendInvite")}
loading={sendLoading || generateLoading}
maxWidth="sm"
fullWidth
additionalButtons={
<Button
variant="contained"
color="primary"
onClick={handleSubmit(handleGenerateToken)}
loading={generateLoading || sendLoading}
>
{t("common.buttons.generateToken")}
</Button>
}
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<FormTextField
name="email"
type="email"
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
/>
<RoleSelectField />
{inviteLink && (
<Dialog
open={open}
title={t("pages.account.team.invite.title")}
content={t("pages.account.team.invite.description")}
onCancel={handleClose}
onConfirm={handleSubmit(handleSendInvite)}
confirmText={t("common.buttons.sendInvite")}
loading={sendLoading || generateLoading}
maxWidth="sm"
fullWidth
additionalButtons={
<Button
variant="contained"
color="primary"
onClick={handleSubmit(handleGenerateToken)}
loading={generateLoading || sendLoading}
>
{t("common.buttons.generateToken")}
</Button>
}
>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
type="email"
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field: { value, onChange, ...field }, fieldState }) => (
<>
<Typography variant="body2">
{t("pages.account.team.invite.linkLabel")}
</Typography>
<TextField
value={inviteLink}
<Select
{...field}
value={Array.isArray(value) ? (value[0] ?? "") : ""}
onChange={(e) => onChange([e.target.value as UserRole])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
slotProps={{
input: {
readOnly: true,
},
}}
/>
error={!!fieldState.error}
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
)}
</Stack>
</Dialog>
</FormProvider>
/>
{inviteLink && (
<>
<Typography variant="body2">
{t("pages.account.team.invite.linkLabel")}
</Typography>
<TextField
value={inviteLink}
fullWidth
slotProps={{
input: {
readOnly: true,
},
}}
/>
</>
)}
</Stack>
</Dialog>
);
};
@@ -1,45 +0,0 @@
import MenuItem from "@mui/material/MenuItem";
import FormHelperText from "@mui/material/FormHelperText";
import { useController } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Select } from "@/Components/inputs";
import { UserRoles } from "@/Types/User";
import type { UserRole } from "@/Types/User";
export const RoleSelectField = () => {
const { t } = useTranslation();
const { field, fieldState } = useController<{ role: UserRole[] }, "role">({
name: "role",
});
const editableRoles = UserRoles.filter((role) => role !== "superadmin");
const roleOptions: { value: UserRole; label: string }[] = editableRoles.map((role) => ({
value: role,
label: t(`common.auth.roles.${role}`),
}));
return (
<>
<Select
{...field}
value={field.value[0] ?? "user"}
onChange={(e) => field.onChange([e.target.value])}
fieldLabel={t("common.form.role.option.role.label")}
fullWidth
error={!!fieldState.error}
>
{roleOptions.map((option) => (
<MenuItem
key={option.value}
value={option.value}
>
{option.label}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText error>{fieldState.error.message}</FormHelperText>
)}
</>
);
};
+49 -38
View File
@@ -1,9 +1,9 @@
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { Button, TextField } from "@/Components/inputs";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useLoginForm } from "@/Hooks/useLoginForm";
import type { LoginFormData } from "@/Validation/login";
@@ -11,7 +11,6 @@ import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { setAuthState } from "@/Features/Auth/authSlice";
import { useLazyGet, usePost } from "@/Hooks/UseApi";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const LoginPage = () => {
const { t } = useTranslation();
@@ -32,13 +31,11 @@ const LoginPage = () => {
const { schema, defaults } = useLoginForm();
const form = useForm<LoginFormData>({
const { control, handleSubmit } = useForm<LoginFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { handleSubmit } = form;
if (isCheckingAdmin) return null;
const onSubmit = async (data: LoginFormData) => {
@@ -53,39 +50,53 @@ const LoginPage = () => {
};
return (
<FormProvider {...form}>
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.login.title")}
subtitle={t("pages.auth.login.subtitle")}
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.login.title")}
subtitle={t("pages.auth.login.subtitle")}
>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
<FormTextField
name="email"
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
/>
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.login.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.forgotPassword.text")}
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password"
/>
</BaseAuthPage>
</FormProvider>
{t("pages.auth.login.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.login.links.forgotPassword.text")}
linkText={t("pages.auth.login.links.forgotPassword.linkText")}
href="/forgot-password"
/>
</BaseAuthPage>
);
};
+35 -31
View File
@@ -1,13 +1,12 @@
import { Button } from "@/Components/inputs";
import { Button, TextField } from "@/Components/inputs";
import { BaseAuthPage, TextLink } from "@/Components/design-elements";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRecoveryForm } from "@/Hooks/useRecoveryForm";
import type { RecoveryFormData } from "@/Validation/recovery";
import { usePost } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const ForgotPasswordPage = () => {
const { t } = useTranslation();
@@ -15,11 +14,10 @@ const ForgotPasswordPage = () => {
const { schema, defaults } = useRecoveryForm();
const form = useForm<RecoveryFormData>({
const { control, handleSubmit } = useForm<RecoveryFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { handleSubmit } = form;
const onSubmit = async (data: RecoveryFormData) => {
if (loading) return;
@@ -32,33 +30,39 @@ const ForgotPasswordPage = () => {
};
return (
<FormProvider {...form}>
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.forgotPassword.title")}
subtitle={t("pages.auth.forgotPassword.subtitle")}
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.forgotPassword.title")}
subtitle={t("pages.auth.forgotPassword.subtitle")}
>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error ? t(fieldState.error.message ?? "") : ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
<FormTextField
name="email"
fieldLabel={t("pages.auth.common.form.option.email.label")}
placeholder={t("pages.auth.common.form.option.email.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.forgotPassword.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
</FormProvider>
{t("pages.auth.forgotPassword.submit")}
</Button>
<TextLink
alignSelf={"center"}
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
);
};
+103 -70
View File
@@ -1,7 +1,7 @@
import { BaseAuthPage } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { Button, TextField } from "@/Components/inputs";
import Alert from "@mui/material/Alert";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useRegisterForm } from "@/Hooks/useRegisterForm";
import type { RegisterFormData } from "@/Validation/register";
@@ -12,7 +12,6 @@ import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import type { AuthResponse } from "@/Types/User";
import { useEffect, useRef, useState } from "react";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
interface RegisterPayload {
user: Omit<RegisterFormData, "confirm">;
@@ -46,11 +45,10 @@ const RegisterPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const form = useForm<RegisterFormData>({
const { control, handleSubmit, setError, reset } = useForm<RegisterFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { handleSubmit, setError, reset } = form;
useEffect(() => {
if (!token || hasVerified.current) return;
@@ -66,14 +64,14 @@ const RegisterPage = () => {
navigate("/register", { replace: true });
}
});
}, [token, defaults, navigate, reset, verifyToken]);
}, [token]);
if (isCheckingAdmin) return null;
const onSubmit = async (data: RegisterFormData) => {
if (loading) return;
const { confirm: _confirm, ...userData } = data;
const { confirm, ...userData } = data;
const payload: RegisterPayload = { user: userData };
if (token) {
payload.token = token;
@@ -91,70 +89,105 @@ const RegisterPage = () => {
};
return (
<FormProvider {...form}>
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.register.title")}
subtitle={t("pages.auth.register.subtitle")}
>
{!token && (
<Alert
severity="info"
icon={false}
sx={(theme) => ({
fontSize: 13,
lineHeight: 1.55,
color: theme.palette.text.secondary,
backgroundColor: theme.palette.action.hover,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
"& .MuiAlert-message": { padding: 0 },
})}
>
{t("pages.auth.register.setupNotice")}
</Alert>
)}
<FormTextField
name="firstName"
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
/>
<FormTextField
name="lastName"
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
/>
<FormTextField
name="email"
disabled={!!token}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
/>
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<FormTextField
name="confirm"
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Button
variant="contained"
type="submit"
loading={loading}
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.register.title")}
subtitle={t("pages.auth.register.subtitle")}
>
{!token && (
<Alert
severity="info"
icon={false}
sx={(theme) => ({
fontSize: 13,
lineHeight: 1.55,
color: theme.palette.text.secondary,
backgroundColor: theme.palette.action.hover,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
"& .MuiAlert-message": { padding: 0 },
})}
>
{t("pages.auth.register.submit")}
</Button>
</BaseAuthPage>
</FormProvider>
{t("pages.auth.register.setupNotice")}
</Alert>
)}
<Controller
name="firstName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.firstName.label")}
placeholder={t("common.form.name.option.firstName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
fieldLabel={t("common.form.name.option.lastName.label")}
placeholder={t("common.form.name.option.lastName.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
disabled={!!token}
fieldLabel={t("common.form.email.option.email.label")}
placeholder={t("common.form.email.option.email.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Button
variant="contained"
type="submit"
loading={loading}
>
{t("pages.auth.register.submit")}
</Button>
</BaseAuthPage>
);
};
+78 -66
View File
@@ -1,9 +1,9 @@
import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { Button, TextField } from "@/Components/inputs";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm";
import type { SetNewPasswordFormData } from "@/Validation/setNewPassword";
@@ -12,7 +12,6 @@ import { useParams, useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import { useDispatch } from "react-redux";
import { setAuthState } from "@/Features/Auth/authSlice";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
const SetNewPasswordPage = () => {
const theme = useTheme();
@@ -24,11 +23,11 @@ const SetNewPasswordPage = () => {
const { schema, defaults } = useSetNewPasswordForm();
const form = useForm<SetNewPasswordFormData>({
const { control, handleSubmit, watch } = useForm<SetNewPasswordFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { handleSubmit, watch } = form;
const password = watch("password");
const confirm = watch("confirm");
@@ -58,68 +57,81 @@ const SetNewPasswordPage = () => {
};
return (
<FormProvider {...form}>
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.setNewPassword.title")}
subtitle={t("pages.auth.setNewPassword.subtitle")}
<BaseAuthPage
component="form"
onSubmit={handleSubmit(onSubmit)}
title={t("pages.auth.setNewPassword.title")}
subtitle={t("pages.auth.setNewPassword.subtitle")}
>
<Controller
name="password"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="confirm"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Stack gap={theme.spacing(4)}>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.length")}
variant={getVariant(hasLength)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.special")}
variant={getVariant(hasSpecial)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.number")}
variant={getVariant(hasNumber)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.uppercase")}
variant={getVariant(hasUppercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.lowercase")}
variant={getVariant(hasLowercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.match")}
variant={getVariant(passwordsMatch)}
/>
</Stack>
<Button
variant="contained"
type="submit"
fullWidth
loading={loading}
>
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.auth.common.form.option.password.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<FormTextField
name="confirm"
type="password"
fieldLabel={t("pages.auth.common.form.option.confirmPassword.label")}
placeholder={t("pages.auth.common.form.option.password.placeholder")}
/>
<Stack gap={theme.spacing(4)}>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.length")}
variant={getVariant(hasLength)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.special")}
variant={getVariant(hasSpecial)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.number")}
variant={getVariant(hasNumber)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.uppercase")}
variant={getVariant(hasUppercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.lowercase")}
variant={getVariant(hasLowercase)}
/>
<BulletPointCheck
text={t("pages.auth.common.passwordRules.match")}
variant={getVariant(passwordsMatch)}
/>
</Stack>
<Button
variant="contained"
type="submit"
fullWidth
loading={loading}
>
{t("common.buttons.resetPassword")}
</Button>
<TextLink
alignSelf="center"
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
</FormProvider>
{t("common.buttons.resetPassword")}
</Button>
<TextLink
alignSelf="center"
text={t("pages.auth.forgotPassword.links.login.text")}
linkText={t("pages.auth.forgotPassword.links.login.linkText")}
href="/login"
/>
</BaseAuthPage>
);
};
File diff suppressed because it is too large. Load diff
+5 -2
View File
@@ -1,5 +1,5 @@
import type { Incident, IncidentSummaryItem } from "@/Types/Incident";
import { formatDuration } from "@/Utils/TimeUtils";
import prettyMilliseconds from "pretty-ms";
type IncidentLike = Pick<Incident, "startTime" | "endTime" | "status">;
@@ -24,5 +24,8 @@ export const getIncidentsDuration = (incident: IncidentLike | IncidentSummaryIte
return "-";
}
return formatDuration(durationMs);
return prettyMilliseconds(durationMs, {
secondsDecimalDigits: 0,
millisecondsDecimalDigits: 0,
});
};
@@ -63,20 +63,10 @@ const getChartConfigs = (
return configs;
};
// Loopback / bridge names Capture sees when it can only observe the container's
// own network namespace. When ALL reported interfaces match this set we assume
// the "no real interfaces visible" case and surface the notice.
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;
};
const hasAnyNetworkTraffic = (checks: HardwareCheckStats[]): boolean =>
checks.some((c) =>
c.net?.some((iface) => iface.bytesSentPerSecond > 0 || iface.deltaBytesRecv > 0)
);
export const InfraNetworkCharts = ({
checks,
@@ -92,14 +82,13 @@ export const InfraNetworkCharts = ({
() => getChartConfigs(theme, checks, t),
[theme, checks, t]
);
const showLoopbackOnlyNotice =
chartConfigs.length > 0 && onlyContainerVisibleInterfaces(checks);
const showNoTrafficNotice = chartConfigs.length > 0 && !hasAnyNetworkTraffic(checks);
return (
<Stack gap={theme.spacing(LAYOUT.XS)}>
{showLoopbackOnlyNotice && (
{showNoTrafficNotice && (
<NoticeBanner severity="warning">
{t("pages.infrastructure.charts.labels.loopbackOnlyNotice")}
{t("pages.infrastructure.charts.labels.netNoTraffic")}
</NoticeBanner>
)}
<Grid
@@ -25,11 +25,10 @@ export const InfraDetailsGauges = ({
<Stack
direction={isSmall ? "column" : "row"}
spacing={theme.spacing(8)}
alignItems={"stretch"}
alignItems={"center"}
>
<DetailGauge
title={t("pages.infrastructure.gauges.memory.title")}
maxWidth={260}
progress={(snapshot?.memory?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.memory.upperLabel")}
upperValue={prettyBytes(snapshot?.memory?.used_bytes || 0)}
@@ -38,7 +37,6 @@ export const InfraDetailsGauges = ({
/>
<DetailGauge
title={t("pages.infrastructure.gauges.cpu.title")}
maxWidth={260}
progress={(snapshot?.cpu?.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.cpu.upperLabel")}
upperValue={getFrequency(snapshot?.cpu?.current_frequency || 0)}
@@ -51,7 +49,6 @@ export const InfraDetailsGauges = ({
key={disk?.device || 0 + idx}
// title={`Disk ${idx} usage`}
title={t("pages.infrastructure.gauges.disk.title", { idx })}
maxWidth={260}
progress={(disk.usage_percent || 0) * 100}
upperLabel={t("pages.infrastructure.gauges.disk.upperLabel")}
upperValue={prettyBytes(disk?.used_bytes || 0)}
@@ -11,8 +11,7 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
const { t } = useTranslation();
const theme = useTheme();
const recentChecks = monitor?.recentChecks ?? [];
const latestCheck = recentChecks[recentChecks.length - 1];
const latestCheck = monitor?.recentChecks?.[0];
// Get data from latest check
const physicalCores = getCores(latestCheck?.cpu?.physical_core);
const logicalCores = getCores(latestCheck?.cpu?.logical_core);
@@ -22,7 +21,13 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => {
const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0;
const diskTotalBytes =
latestCheck?.disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0;
const osPlatform = getOsAndPlatform(latestCheck?.host);
const os = getOsAndPlatform(latestCheck?.host);
const platform = latestCheck?.host?.platform ?? undefined;
const osPlatform =
typeof os === "undefined" && typeof platform === "undefined"
? undefined
: `${os} ${platform}`;
return (
<Stack
@@ -21,11 +21,10 @@ export const TabOverview = ({
}
const checks = stats?.checks || [];
const recentChecks = monitor.recentChecks ?? [];
return (
<Stack gap={theme.spacing(8)}>
<StatusBoxes monitor={monitor} />
<InfraDetailsGauges snapshot={recentChecks[recentChecks.length - 1]} />
<InfraDetailsGauges snapshot={monitor.recentChecks?.[0]} />
<InfraDetailsCharts
checks={checks}
dateRange={dateRange}
@@ -248,8 +248,7 @@ export const InfraMonitorsTable = ({
id: "cpu",
content: t("pages.infrastructure.table.headers.cpu"),
render: (row) => {
const recentChecks = row.recentChecks ?? [];
const check = recentChecks[recentChecks.length - 1];
const check = row.recentChecks?.[0];
const cpuUsage = (check?.cpu?.usage_percent || 0) * 100;
return <Gauge progress={cpuUsage} />;
},
@@ -258,8 +257,7 @@ export const InfraMonitorsTable = ({
id: "memory",
content: t("pages.infrastructure.table.headers.memory"),
render: (row) => {
const recentChecks = row.recentChecks ?? [];
const check = recentChecks[recentChecks.length - 1];
const check = row.recentChecks?.[0];
const memoryUsage = (check?.memory?.usage_percent || 0) * 100;
return <Gauge progress={memoryUsage} />;
},
@@ -268,8 +266,7 @@ export const InfraMonitorsTable = ({
id: "disk",
content: t("pages.infrastructure.table.headers.disk"),
render: (row) => {
const recentChecks = row.recentChecks ?? [];
const check = recentChecks[recentChecks.length - 1];
const check = row.recentChecks?.[0];
const totalDiskUsage = check?.disk?.reduce(
(acc, disk) => acc + (disk?.usage_percent || 0),
@@ -1,44 +1,274 @@
import { MonitorListPage } from "@/Components/monitors";
import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
import { HeaderCreate } from "@/Components/common";
import {
ControlsFilter,
HeaderMonitorsSummary,
BulkActionsBar,
} from "@/Components/monitors";
import { TextField, Dialog, Button } from "@/Components/inputs";
import { Play, Pause } from "lucide-react";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
import useDebounce from "@/Hooks/useDebounce";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
const InfrastructureMonitors = () => {
const c = useMonitorListController({
types: ["hardware"],
checksLimit: 1,
refreshInterval: 5000,
rowsPerPageTable: "infrastructure",
rowsPerPageDefault: 5,
});
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const dispatch = useDispatch();
// Redux state
const rowsPerPage = useSelector(
(state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5
);
// Local state
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [search, setSearch] = useState<string>("");
const [page, setPage] = useState<number>(0);
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const { data: tags } = useGet<Tag[]>("/tags/team");
const isDialogOpen = Boolean(selectedMonitor);
const debouncedSearch = useDebounce<string>(search, 300);
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
// Convert filter selections to API filter values
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "false";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch || undefined;
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "hardware");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "1");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, filter, field, sortOrder, selectedTags]);
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000, keepPreviousData: true }
);
const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
const isLoading = monitorsWithChecksLoading;
// Bulk actions
const {
selectedRows,
setSelectedRows,
handleBulkPause,
handleBulkResume,
handleCancelSelection,
} = useBulkMonitorActions(refetch, page);
// Check if any filters are active
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
// Show empty state only when there are truly no monitors (not just filtered out)
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
return (
<MonitorListPage
<MonitorBasePageWithStates
headerKey="infrastructure"
loading={isLoading}
error={monitorsWithChecksError}
totalCount={effectiveTotalCount}
page="infrastructure"
actionLink="/infrastructure/create"
controller={c}
bulkActions
showTypeFilter={false}
summaryProps={{ showBreached: true }}
>
<InfraMonitorsTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
<HeaderCreate
path="/infrastructure/create"
isLoading={isLoading}
isAdmin={isAdmin}
/>
</MonitorListPage>
<HeaderMonitorsSummary
summary={summary}
showBreached={true}
/>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
{!isLoading && (
<BulkActionsBar
selectedCount={selectedRows.length}
onCancel={handleCancelSelection}
>
<Button
size="small"
startIcon={<Play size={16} />}
onClick={handleBulkResume}
>
{t("common.buttons.resume")}
</Button>
<Button
size="small"
startIcon={<Pause size={16} />}
onClick={handleBulkPause}
>
{t("common.buttons.pause")}
</Button>
</BulkActionsBar>
)}
<InfraMonitorsTable
monitors={monitorsWithChecksData?.monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={(value: number) => {
dispatch(
setRowsPerPage({
value,
table: "infrastructure",
})
);
setPage(0);
}}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
);
};
+9 -5
View File
@@ -2,10 +2,10 @@ import Box from "@mui/material/Box";
import { StatBox } from "@/Components/design-elements";
import prettyBytes from "pretty-bytes";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import type { Diagnostics } from "@/Types/Diagnostics";
import { formatDuration, formatMs } from "@/Utils/TimeUtils";
interface StatsProps {
diagnostics: Diagnostics | null;
@@ -18,9 +18,13 @@ export const Stats = ({ diagnostics }: StatsProps) => {
const theme = useTheme();
const eventLoopDelay = diagnostics
? formatMs(diagnostics.eventLoopDelayMs ?? 0)
? prettyMilliseconds(diagnostics.eventLoopDelayMs ?? 0, {
millisecondsDecimalDigits: 2,
})
: PLACEHOLDER;
const uptime = diagnostics
? prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true })
: PLACEHOLDER;
const uptime = diagnostics ? formatDuration(diagnostics.uptimeMs ?? 0) : PLACEHOLDER;
const usedHeap = diagnostics
? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0)
: PLACEHOLDER;
@@ -33,10 +37,10 @@ export const Stats = ({ diagnostics }: StatsProps) => {
return (
<Box
display="grid"
gap={theme.spacing(8)}
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" },
gap: theme.spacing(8),
"& > *": { width: "100% !important" },
}}
>
@@ -1,6 +1,7 @@
import { Table, Pagination } from "@/Components/design-elements";
import { Typography, useTheme } from "@mui/material";
import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils";
import prettyMilliseconds from "pretty-ms";
import { formatTimestamp } from "@/Utils/TimeUtils";
import { useTranslation } from "react-i18next";
import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue";
@@ -83,7 +84,7 @@ export const TableJobs = ({
id: "interval",
content: t("common.table.headers.interval"),
render: (row) => (
<Typography sx={cellSx}>{formatDuration(row.monitorInterval ?? 0)}</Typography>
<Typography sx={cellSx}>{prettyMilliseconds(row.monitorInterval ?? 0)}</Typography>
),
},
{
@@ -4,6 +4,7 @@ import { Pagination } from "@/Components/design-elements/Table";
import { ActionsMenu } from "@/Components/actions-menu";
import { DialogInput } from "@/Components/inputs/Dialog";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material";
import type { Header } from "@/Components/design-elements/Table";
import type { ActionMenuItem } from "@/Components/actions-menu";
@@ -17,7 +18,6 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice";
import dayjs from "dayjs";
import { useState } from "react";
import { useDelete, usePatch } from "@/Hooks/UseApi";
import { formatDuration } from "@/Utils/TimeUtils";
interface MaintenanceWindowTableProps {
maintenanceWindows: MaintenanceWindow[];
@@ -51,7 +51,7 @@ const getTimeToNextWindow = (
// Window is in the future
if (start.isAfter(now)) {
return formatDuration(start.diff(now));
return prettyMilliseconds(start.diff(now), { unitCount: 2, hideSeconds: true });
}
return "N/A";
@@ -152,7 +152,9 @@ export const MaintenanceWindowTable = ({
id: "repeat",
content: t("pages.maintenanceWindow.table.headers.repeat"),
render: (row) =>
row.repeat === 0 ? t("common.labels.na") : formatDuration(row.repeat),
row.repeat === 0
? t("common.labels.na")
: prettyMilliseconds(row.repeat, { verbose: true }),
},
{
id: "actions",
+258 -117
View File
@@ -1,8 +1,18 @@
import Stack from "@mui/material/Stack";
import MenuItem from "@mui/material/MenuItem";
import { logger } from "@/Utils/logger";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { BasePage, ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import {
TextField,
Select,
DatePicker,
TimePicker,
Button,
Autocomplete,
} from "@/Components/inputs";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
@@ -15,17 +25,10 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { mutate } from "swr";
import { useParams, useNavigate } from "react-router-dom";
import type { Monitor } from "@/Types/Monitor";
import { useForm, FormProvider } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { useEffect } from "react";
import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import {
FormDatePickerField,
FormTimePickerField,
} from "@/Components/inputs/forms/FormDateTimePickerField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
import { Trash2 } from "lucide-react";
const CreateMaintenanceWindowPage = () => {
const theme = useTheme();
@@ -54,7 +57,7 @@ const CreateMaintenanceWindowPage = () => {
defaultValues: defaults,
});
const { handleSubmit, trigger } = form;
const { control, handleSubmit, trigger } = form;
useEffect(() => {
if (existingMaintenanceWindow) {
form.reset(defaults);
@@ -111,117 +114,255 @@ const CreateMaintenanceWindowPage = () => {
};
return (
<FormProvider {...form}>
<BasePage
component={"form"}
onSubmit={handleSubmit(onSubmit, onError)}
>
<ConfigBox
title={t("pages.maintenanceWindow.form.general.title")}
subtitle={t("pages.maintenanceWindow.form.general.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="name"
fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")}
placeholder={t(
"pages.maintenanceWindow.form.general.option.name.placeholder"
)}
/>
<FormSelectField
name="repeat"
fieldLabel={t("pages.maintenanceWindow.form.general.option.repeat.label")}
options={repeatOptions.map((option) => ({
value: option.id,
label: option.name,
}))}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startDate.title")}
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormDatePickerField
name="startDate"
fieldLabel={t(
"pages.maintenanceWindow.form.startDate.option.startDate.label"
)}
onValueChange={() => trigger("startDate")}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTimePickerField
name="startTime"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.startTime.label"
)}
onValueChange={() => trigger("startDate")}
/>
<Stack
direction="row"
alignItems="flex-start"
spacing={theme.spacing(LAYOUT.MD)}
>
<FormNumberField
name="duration"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.duration.label"
<BasePage
component={"form"}
onSubmit={handleSubmit(onSubmit, onError)}
>
<ConfigBox
title={t("pages.maintenanceWindow.form.general.title")}
subtitle={t("pages.maintenanceWindow.form.general.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="name"
control={control}
defaultValue={defaults.name}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.maintenanceWindow.form.general.option.name.label")}
placeholder={t(
"pages.maintenanceWindow.form.general.option.name.placeholder"
)}
sx={{ width: 120 }}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
<FormSelectField
name="durationUnit"
fieldLabel={" "}
options={durationUnitOptions.map((option) => ({
value: option.id,
label: option.name,
}))}
/>
</Stack>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
rightContent={
<FormMultiSelectField
name="monitors"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
placeholder={t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
options={monitors ?? []}
/>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(SPACING.LG)}
<Controller
name="repeat"
control={control}
defaultValue={defaults.repeat}
render={({ field, fieldState }) => (
<Select
value={field.value}
fieldLabel={t(
"pages.maintenanceWindow.form.general.option.repeat.label"
)}
error={!!fieldState.error}
onChange={field.onChange}
>
{repeatOptions.map((option) => (
<MenuItem
key={option.id}
value={option.id}
>
<Typography>{option.name}</Typography>
</MenuItem>
))}
</Select>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startDate.title")}
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="startDate"
control={control}
defaultValue={defaults.startDate}
render={({ field, fieldState }) => (
<DatePicker
fieldLabel={t(
"pages.maintenanceWindow.form.startDate.option.startDate.label"
)}
value={field.value ? dayjs(field.value, "YYYY-MM-DD") : null}
onChange={(date) => {
field.onChange(date ? date.format("YYYY-MM-DD") : "");
trigger("startDate");
}}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="startTime"
control={control}
defaultValue={defaults.startTime}
render={({ field }) => (
<TimePicker
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.startTime.label"
)}
value={field.value ? dayjs(field.value, "HH:mm") : null}
onChange={(time) => {
field.onChange(time ? time.format("HH:mm") : "");
trigger("startDate");
}}
/>
)}
/>
<Stack
direction="row"
alignItems="flex-start"
spacing={theme.spacing(LAYOUT.MD)}
>
<Controller
name="duration"
control={control}
defaultValue={defaults.duration}
render={({ field, fieldState }) => (
<TextField
{...field}
type="number"
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.option.duration.label"
)}
value={field.value === 0 ? "" : field.value}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
sx={{ width: 120 }}
/>
)}
/>
<Controller
name="durationUnit"
control={control}
defaultValue={defaults.durationUnit}
render={({ field }) => (
<Select
fieldLabel=" "
value={field.value}
onChange={field.onChange}
sx={{ minWidth: 120 }}
>
{durationUnitOptions.map((option) => (
<MenuItem
key={option.id}
value={option.id}
>
<Typography>{option.name}</Typography>
</MenuItem>
))}
</Select>
)}
/>
</Stack>
</Stack>
}
/>
<ConfigBox
title={t("pages.maintenanceWindow.form.startTime.monitors.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.monitors.description")}
rightContent={
<Controller
name="monitors"
control={control}
defaultValue={defaults.monitors}
render={({ field, fieldState }) => {
const monitorsList = monitors ?? [];
const selectedMonitors = field.value
.map((id: string) => monitorsList.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={monitorsList}
getOptionLabel={(option: Monitor) => option.name}
value={selectedMonitors}
onChange={(_, newValue) => {
field.onChange(newValue.map((m: Monitor) => m.id));
}}
fieldLabel={t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)}
renderInput={(params) => (
<TextField
{...params}
placeholder={
selectedMonitors.length === 0
? t(
"pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label"
)
: ""
}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
{selectedMonitors.length > 0 && (
<Stack>
{selectedMonitors.map((monitor) => (
<Stack
key={monitor.id}
direction="row"
alignItems="center"
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
}}
>
<Typography flexGrow={1}>{monitor.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
field.value.filter((id: string) => id !== monitor.id)
);
}}
aria-label="Remove monitor"
>
<Trash2 size={16} />
</IconButton>
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(SPACING.LG)}
>
<Button
loading={isLoading}
type="submit"
variant="contained"
color="primary"
>
<Button
loading={isLoading}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
);
};
+381 -192
View File
@@ -1,12 +1,14 @@
import { BasePage, ConfigBox } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { TextField, Select, Button } from "@/Components/inputs";
import MenuItem from "@mui/material/MenuItem";
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useEffect, useMemo } from "react";
import { useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { FormProvider, useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import { useNotificationForm } from "@/Hooks/useNotificationForm";
@@ -14,8 +16,6 @@ import type { NotificationFormData } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification";
import { useTranslation } from "react-i18next";
import { NotificationChannels } from "@/Types/Notification";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
const NotificationsCreatePage = () => {
const { t } = useTranslation();
@@ -39,7 +39,7 @@ const NotificationsCreatePage = () => {
defaultValues: defaults,
});
const { watch, reset, handleSubmit, clearErrors, trigger, getValues } = form;
const { control, watch, reset, handleSubmit, clearErrors, trigger, getValues } = form;
useEffect(() => {
reset(defaults);
@@ -93,195 +93,384 @@ const NotificationsCreatePage = () => {
};
return (
<FormProvider {...form}>
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit)}
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.notifications.form.notificationName.title")}
subtitle={t("pages.notifications.form.notificationName.description")}
rightContent={
<Controller
name="notificationName"
control={control}
defaultValue={defaults.notificationName}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.notificationName.optionName")}
placeholder={t("pages.notifications.form.notificationName.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.notifications.form.type.title")}
subtitle={t("pages.notifications.form.type.description")}
rightContent={
<Controller
name="type"
control={control}
defaultValue={defaults.type}
render={({ field, fieldState }) => (
<Select
value={field.value}
fieldLabel={t("pages.notifications.form.type.optionType")}
error={!!fieldState.error}
onChange={field.onChange}
>
{NotificationChannels.map((type: string) => (
<MenuItem
key={type}
value={type}
>
<Typography textTransform="capitalize">{type}</Typography>
</MenuItem>
))}
</Select>
)}
/>
}
/>
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
rightContent={
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={addressConfig.fieldLabel}
placeholder={addressConfig.placeholder}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
}
/>
)}
{watchedType === "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" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
subtitle={t("pages.notifications.form.telegram.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
placeholder={t(
"pages.notifications.form.telegram.placeholderBotToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "pushover" && (
<ConfigBox
title={t("pages.notifications.form.pushover.title")}
subtitle={t("pages.notifications.form.pushover.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
placeholder={t(
"pages.notifications.form.pushover.placeholderAppToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
placeholder={t(
"pages.notifications.form.pushover.placeholderUserKey"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
subtitle={t("pages.notifications.form.twilio.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accountSid"
control={control}
defaultValue={"accountSid" in defaults ? defaults.accountSid : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAccountSid"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
placeholder={t(
"pages.notifications.form.twilio.placeholderAuthToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="twilioPhoneNumber"
control={control}
defaultValue={
"twilioPhoneNumber" in defaults ? defaults.twilioPhoneNumber : ""
}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
placeholder={t(
"pages.notifications.form.twilio.placeholderFromNumber"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="phone"
control={control}
defaultValue={"phone" in defaults ? defaults.phone : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
subtitle={t("pages.notifications.form.matrix.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="homeserverUrl"
control={control}
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.homeServer.optionHomeServer")}
placeholder={t("pages.notifications.form.homeServer.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="roomId"
control={control}
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.roomId.optionRoomId")}
placeholder={t("pages.notifications.form.roomId.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t(
"pages.notifications.form.accessToken.optionAccessToken"
)}
placeholder={t("pages.notifications.form.accessToken.placeholder")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
>
<ConfigBox
title={t("pages.notifications.form.notificationName.title")}
subtitle={t("pages.notifications.form.notificationName.description")}
rightContent={
<FormTextField
name="notificationName"
fieldLabel={t("pages.notifications.form.notificationName.optionName")}
placeholder={t("pages.notifications.form.notificationName.placeholder")}
/>
}
/>
<ConfigBox
title={t("pages.notifications.form.type.title")}
subtitle={t("pages.notifications.form.type.description")}
rightContent={
<FormSelectField
name="type"
fieldLabel={t("pages.notifications.form.type.optionType")}
options={NotificationChannels.map((channel) => ({
value: channel,
label: t(`pages.notifications.form.type.value.${channel}`),
}))}
/>
}
/>
{watchedType !== "matrix" &&
watchedType !== "telegram" &&
watchedType !== "pushover" &&
watchedType !== "twilio" &&
watchedType !== "ntfy" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
rightContent={
<FormTextField
name="address"
fieldLabel={addressConfig.fieldLabel}
placeholder={addressConfig.placeholder}
/>
}
/>
)}
{watchedType === "ntfy" && (
<ConfigBox
title={t("pages.notifications.form.ntfy.title")}
subtitle={t("pages.notifications.form.ntfy.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.ntfy.optionServerUrl")}
placeholder={t("pages.notifications.form.ntfy.placeholderServerUrl")}
/>
<FormTextField
name="topic"
fieldLabel={t("pages.notifications.form.ntfy.optionTopic")}
placeholder={t("pages.notifications.form.ntfy.placeholderTopic")}
/>
</Stack>
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
subtitle={t("pages.notifications.form.telegram.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
placeholder={t("pages.notifications.form.telegram.placeholderBotToken")}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
/>
</Stack>
}
/>
)}
{watchedType === "pushover" && (
<ConfigBox
title={t("pages.notifications.form.pushover.title")}
subtitle={t("pages.notifications.form.pushover.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.pushover.optionAppToken")}
placeholder={t("pages.notifications.form.pushover.placeholderAppToken")}
/>
<FormTextField
name="address"
fieldLabel={t("pages.notifications.form.pushover.optionUserKey")}
placeholder={t("pages.notifications.form.pushover.placeholderUserKey")}
/>
</Stack>
}
/>
)}
{watchedType === "twilio" && (
<ConfigBox
title={t("pages.notifications.form.twilio.title")}
subtitle={t("pages.notifications.form.twilio.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="accountSid"
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
placeholder={t("pages.notifications.form.twilio.placeholderAccountSid")}
/>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
placeholder={t("pages.notifications.form.twilio.placeholderAuthToken")}
/>
<FormTextField
name="twilioPhoneNumber"
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderFromNumber")}
/>
<FormTextField
name="phone"
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
subtitle={t("pages.notifications.form.matrix.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<FormTextField
name="homeserverUrl"
fieldLabel={t("pages.notifications.form.homeServer.optionHomeServer")}
placeholder={t("pages.notifications.form.homeServer.placeholder")}
/>
<FormTextField
name="roomId"
fieldLabel={t("pages.notifications.form.roomId.optionRoomId")}
placeholder={t("pages.notifications.form.roomId.placeholder")}
/>
<FormTextField
name="accessToken"
fieldLabel={t("pages.notifications.form.accessToken.optionAccessToken")}
placeholder={t("pages.notifications.form.accessToken.placeholder")}
/>
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
<Button
variant="contained"
color="primary"
onClick={handleTest}
loading={isTesting}
>
<Button
variant="contained"
color="primary"
onClick={handleTest}
loading={isTesting}
>
{t("common.buttons.test")}
</Button>
<Button
loading={isSubmitting || isPatching}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
{t("common.buttons.test")}
</Button>
<Button
loading={isSubmitting || isPatching}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
);
};
+2 -4
View File
@@ -45,8 +45,6 @@ const PageSpeedDetails = () => {
const monitor = monitorData?.monitorData?.monitor;
const groupedChecks = monitorData?.monitorData?.groupedChecks || [];
const monitorStats = monitorData?.monitorStats || null;
const recentChecks = monitor?.recentChecks ?? [];
const latestCheck = recentChecks[recentChecks.length - 1];
return (
<BasePage
@@ -77,8 +75,8 @@ const PageSpeedDetails = () => {
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(10)}
>
<PiePageSpeed latestCheck={latestCheck} />
<PiePageSpeedLegend latestCheck={latestCheck} />
<PiePageSpeed latestCheck={monitor?.recentChecks?.[0]} />
<PiePageSpeedLegend latestCheck={monitor?.recentChecks?.[0]} />
</Stack>
</BasePage>
);
+205 -33
View File
@@ -1,57 +1,229 @@
import { PageSpeedKeyPriorityFallback } from "@/Components/design-elements";
import {
MonitorBasePageWithStates,
PageSpeedKeyPriorityFallback,
ColoredLabel,
} from "@/Components/design-elements";
import { Dialog, TextField } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material";
import { SPACING } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet } from "@/Hooks/UseApi";
import { useGet, useDelete } from "@/Hooks/UseApi";
import useDebounce from "@/Hooks/useDebounce";
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
import type { AppSettingsResponse } from "@/Types/Settings";
import { MonitorListPage } from "@/Components/monitors";
import { useMonitorListController } from "@/Hooks/useMonitorListController";
import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
const PageSpeedMonitorsPage = () => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const { deleteFn, loading: isDeleting } = useDelete();
const settingsUrl = "/settings";
const {
data: settingsData,
isLoading: settingsIsLoading,
error: settingsError,
} = useGet<AppSettingsResponse>("/settings");
} = useGet<AppSettingsResponse>(settingsUrl);
const c = useMonitorListController({
types: ["pagespeed"],
checksLimit: 25,
refreshInterval: 30000,
rowsPerPageTable: "pagespeed",
rowsPerPageDefault: 10,
initialSortField: "name",
});
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const [sortField, setSortField] = useState<string>("name");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [search, setSearch] = useState<string>("");
const debouncedSearch = useDebounce<string>(search, 300);
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Priority: status > isActive > search > sort
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || debouncedSearch;
const { data: tags } = useGet<Tag[]>("/tags/team");
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "pagespeed");
selectedTags.forEach((tagId) => params.append("tags", tagId));
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, sortOrder, selectedTags, filter, field]);
const {
data: monitorsData,
isLoading: monitorsIsLoading,
error: monitorsError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 30000 }
);
const monitors = monitorsData?.monitors;
const monitorsCount = monitorsData?.count ?? 0;
const summary = monitorsData?.summary ?? null;
const isLoading = monitorsIsLoading || settingsIsLoading;
const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet;
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSelectedTags([]);
setSearch("");
}, []);
const hasActiveFilters = Boolean(
selectedStatus || selectedState || selectedTags.length > 0 || search
);
const effectiveTotalCount =
hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
? 1
: (summary?.totalMonitors ?? 0);
return (
<MonitorListPage
<MonitorBasePageWithStates
headerKey="pageSpeed"
loading={isLoading}
error={monitorsError || settingsError}
totalCount={effectiveTotalCount}
page="pageSpeed"
actionLink="/pagespeed/create"
controller={c}
showTypeFilter={false}
extraLoading={settingsIsLoading}
extraError={settingsError}
priorityFallback={showApiKeyWarning ? <PageSpeedKeyPriorityFallback /> : undefined}
>
<PageSpeedMonitorsTable
monitors={c.monitors || []}
tags={c.tags || []}
refetch={c.refetch}
setSelectedMonitor={c.setSelectedMonitor}
count={c.count || 0}
page={c.page}
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setRowsPerPage={c.handleSetRowsPerPage}
<HeaderCreate
path="/pagespeed/create"
isLoading={isLoading}
isAdmin={isAdmin}
/>
</MonitorListPage>
<HeaderMonitorsSummary summary={summary} />
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
tagOptions={tags ?? []}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
{selectedTags.length > 0 && (
<Stack
direction={isSmall ? "column" : "row"}
alignItems={isSmall ? "flex-start" : "center"}
flexWrap="wrap"
gap={theme.spacing(SPACING.XL)}
>
<Typography color={theme.palette.text.secondary}>
{t("pages.uptime.filters.activeTags")}
</Typography>
{selectedTags.map((tagId) => {
const tag = tags?.find((t) => t.id === tagId);
if (!tag) return null;
return (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
/>
);
})}
</Stack>
)}
<PageSpeedMonitorsTable
monitors={monitors || []}
tags={tags ?? []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={monitorsCount || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
);
};
@@ -1,103 +0,0 @@
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import Typography from "@mui/material/Typography";
import type { Header } from "@/Components/design-elements/Table";
import { Table } from "@/Components/design-elements";
import { Pagination } from "@/Components/design-elements/Table";
import { useClientPagination } from "@/Hooks/useClientPagination";
import type { ProxyResponse } from "@/Types/Proxy";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
interface ProxiesTableProps {
proxies: ProxyResponse[];
setSelectedProxy: Function;
}
export const ProxiesTable = ({ proxies, setSelectedProxy }: ProxiesTableProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const theme = useTheme();
const { pagedRows, paginationProps } = useClientPagination(proxies);
const getActions = (proxy: ProxyResponse): ActionMenuItem[] => {
return [
{
id: 1,
label: t("pages.common.monitors.actions.configure"),
action: () => {
navigate(`/proxies/configure/${proxy.id}`);
},
closeMenu: true,
},
{
id: 2,
label: (
<Typography color={theme.palette.error.main}>
{t("pages.common.monitors.actions.delete")}
</Typography>
),
action: async () => {
setSelectedProxy(proxy);
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const headers: Header<ProxyResponse>[] = [
{
id: "name",
content: t("common.table.headers.name"),
render: (row) => {
return <Typography>{row?.name}</Typography>;
},
},
{
id: "protocol",
content: t("pages.proxies.table.headers.protocol"),
render: (row) => {
return <Typography>{row?.protocol}</Typography>;
},
},
{
id: "host",
content: t("pages.proxies.table.headers.host"),
render: (row) => {
return <Typography>{row?.host}</Typography>;
},
},
{
id: "port",
content: t("pages.proxies.table.headers.port"),
render: (row) => {
return <Typography>{row?.port}</Typography>;
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
return (
<>
<Table
headers={getHeaders()}
data={pagedRows}
onRowClick={(row) => {
navigate(`/proxies/configure/${row.id}`);
}}
/>
{proxies.length > 0 && <Pagination {...paginationProps} />}
</>
);
};
-213
View File
@@ -1,213 +0,0 @@
import { BasePage, ConfigBox } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { Button, FieldLabel } from "@/Components/inputs";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { mutate } from "swr";
import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
import type { ProxyFormData } from "@/Validation/proxy";
import { ProxyProtocols, type ProxyResponse } from "@/Types/Proxy";
import { useTranslation } from "react-i18next";
import { useProxiesForm } from "@/Hooks/useProxiesForm";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
import { useMemo } from "react";
import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
const ProxiesCreatePage = () => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const { proxyId } = useParams<{ proxyId: string }>();
const isEditMode = Boolean(proxyId);
const { data: existingProxy } = useGet<ProxyResponse>(
isEditMode ? `/proxies/${proxyId}` : null
);
const { post, loading: isSubmitting } = usePost<ProxyFormData, ProxyResponse>();
const { patch, loading: isPatching } = usePatch<ProxyFormData, ProxyResponse>();
const { schema, defaults } = useProxiesForm({ data: existingProxy });
const form = useForm<ProxyFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
});
const { reset, handleSubmit } = form;
const isPasswordSet = existingProxy?.hasPassword ?? false;
const [passwordHasBeenReset, setPasswordHasBeenReset] = useState(false);
const handleResetPassword = () => {
form.setValue("password", "");
setPasswordHasBeenReset(true);
};
const isUsernameSet = Boolean(existingProxy?.username);
const [usernameHasBeenReset, setUsernameHasBeenReset] = useState(false);
const handleResetUsername = () => {
form.setValue("username", "");
setUsernameHasBeenReset(true);
};
const protocolOptions = useMemo(
() =>
ProxyProtocols.map((protocol) => ({
value: protocol,
label: protocol,
})),
[]
);
useEffect(() => {
reset(defaults);
}, [defaults, reset]);
const onSubmit = async (data: ProxyFormData) => {
const payload: ProxyFormData & { clearPassword?: boolean; clearUsername?: boolean } =
{
...data,
};
// Don't send password/username if one is set and the user hasn't clicked reset
if (isPasswordSet && !passwordHasBeenReset) {
delete payload.password;
} else if (passwordHasBeenReset && !payload.password) {
payload.clearPassword = true;
}
if (isUsernameSet && !usernameHasBeenReset) {
delete payload.username;
} else if (usernameHasBeenReset && !payload.username) {
payload.clearUsername = true;
}
const result = isEditMode
? await patch(`/proxies/${proxyId}`, payload)
: await post("/proxies", payload);
if (result?.success) {
await mutate((key) => typeof key === "string" && key.startsWith("/proxies"));
navigate("/proxies");
}
};
return (
<FormProvider {...form}>
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit)}
>
<ConfigBox
title={t("pages.proxies.form.name.title")}
subtitle={t("pages.proxies.form.name.description")}
rightContent={
<FormTextField
name="name"
fieldLabel={t("pages.proxies.form.name.optionName")}
placeholder={t("pages.proxies.form.name.placeholder")}
/>
}
/>
<ConfigBox
title={t("pages.proxies.form.server.title")}
subtitle={t("pages.proxies.form.server.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
<FormSelectField
name="protocol"
fieldLabel={t("pages.proxies.form.server.option.protocol.label")}
options={protocolOptions}
/>
<FormTextField
name="host"
fieldLabel={t("pages.proxies.form.server.option.host.label")}
placeholder={t("pages.proxies.form.server.option.host.placeholder")}
/>
<FormNumberField
name="port"
fieldLabel={t("pages.proxies.form.server.option.port.label")}
/>
</Stack>
}
/>
<ConfigBox
title={t("pages.proxies.form.auth.title")}
subtitle={t("pages.proxies.form.auth.description")}
rightContent={
<Stack gap={theme.spacing(LAYOUT.MD)}>
{(isUsernameSet === false || usernameHasBeenReset === true) && (
<FormTextField
name="username"
fieldLabel={t("pages.proxies.form.auth.option.username.label")}
placeholder={t("pages.proxies.form.auth.option.username.placeholder")}
/>
)}
{isUsernameSet === true && usernameHasBeenReset === false && (
<Box>
<FieldLabel>
{t("pages.proxies.form.auth.option.username.labelSet")}
</FieldLabel>
<Button
onClick={handleResetUsername}
variant="contained"
color="error"
>
{t("common.buttons.reset")}
</Button>
</Box>
)}
{(isPasswordSet === false || passwordHasBeenReset === true) && (
<FormTextField
name="password"
type="password"
fieldLabel={t("pages.proxies.form.auth.option.password.label")}
/>
)}
{isPasswordSet === true && passwordHasBeenReset === false && (
<Box>
<FieldLabel>
{t("pages.proxies.form.auth.option.password.labelSet")}
</FieldLabel>
<Button
onClick={handleResetPassword}
variant="contained"
color="error"
>
{t("common.buttons.reset")}
</Button>
</Box>
)}
</Stack>
}
/>
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
>
<Button
loading={isSubmitting || isPatching}
type="submit"
variant="contained"
color="primary"
>
{t("common.buttons.save")}
</Button>
</Stack>
</BasePage>
</FormProvider>
);
};
export default ProxiesCreatePage;
-72
View File
@@ -1,72 +0,0 @@
import { BasePageWithStates } from "@/Components/design-elements";
import { ProxiesTable } from "@/Pages/Proxies/components/ProxiesTable";
import { Dialog } from "@/Components/inputs";
import { HeaderCreate } from "@/Components/common";
import { useState } from "react";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useTranslation } from "react-i18next";
import type { ProxyResponse } from "@/Types/Proxy";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
const ProxiesPage = () => {
const { t } = useTranslation();
const isAdmin = useIsAdmin();
const [selectedProxy, setSelectedProxy] = useState<ProxyResponse | null>(null);
const isDialogOpen = Boolean(selectedProxy);
const {
data: proxies,
isLoading,
isValidating,
error,
refetch,
} = useGet<ProxyResponse[]>("/proxies/team", {}, { keepPreviousData: true });
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedProxy) return;
await deleteFn(`/proxies/${selectedProxy.id}`);
setSelectedProxy(null);
refetch();
};
const handleCancel = () => {
setSelectedProxy(null);
};
return (
<BasePageWithStates
headerKey="proxies"
page={t("pages.proxies.fallback.title")}
description={t("pages.proxies.fallback.description")}
loading={isLoading || isValidating}
error={!!error}
totalCount={proxies?.length ?? 0}
actionButtonText={t("pages.proxies.fallback.actionButton")}
actionLink="/proxies/create"
>
<HeaderCreate
path="/proxies/create"
isLoading={isLoading || isValidating}
isAdmin={isAdmin}
/>
<ProxiesTable
proxies={proxies ?? []}
setSelectedProxy={setSelectedProxy}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</BasePageWithStates>
);
};
export default ProxiesPage;
+2 -1
View File
@@ -13,12 +13,13 @@ const generateDummyChecks = (): CheckSnapshot[] => {
const checks: CheckSnapshot[] = [];
for (let i = 0; i < MAX_RECENT_CHECKS; i++) {
const isUp = Math.random() > 0.1;
const responseTime = Math.floor(Math.random() * 800) + 20;
const responseTime = Math.floor(Math.random() * 80) + 20;
checks.push({
id: `dummy-${i}`,
status: isUp,
statusCode: isUp ? 200 : 500,
responseTime: responseTime,
originalResponseTime: responseTime,
message: "",
createdAt: new Date(Date.now() - i * 60000).toISOString(),
});
File diff suppressed because it is too large. Load diff
+493 -289
View File
@@ -1,81 +1,56 @@
import {
BasePage,
ColoredLabel,
ConfigBox,
StepProgress,
} from "@/Components/design-elements";
import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import { logger } from "@/Utils/logger";
import { LAYOUT } from "@/Utils/Theme/constants";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import Typography from "@mui/material/Typography";
import { ImageUpload, Button, Dialog } from "@/Components/inputs";
import IconButton from "@mui/material/IconButton";
import FormControlLabel from "@mui/material/FormControlLabel";
import { Trash2, GripVertical } from "lucide-react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import type { DropResult } from "@hello-pangea/dnd";
import {
ImageUpload,
SwitchComponent,
Button,
TextField,
Autocomplete,
Checkbox,
Dialog,
ColorInput,
} from "@/Components/inputs";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useState } from "react";
import { useForm, FormProvider, useController } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
import type { StatusPageFormData } from "@/Validation/statusPage";
import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statusPage";
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import { SelectableMonitorTypes } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import { timezoneOptions } from "@/Utils/timezoneOptions";
import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom";
import { mutate } from "swr";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
import { ThemePicker } from "./Components/ThemePicker";
import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField";
import { FormTextField } from "@/Components/inputs/forms/FormTextField";
import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField";
import { FormAutocompleteField } from "@/Components/inputs/forms/FormAutoCompleteField";
import { FormCheckboxField } from "@/Components/inputs/forms/FormCheckboxField";
import { FormColorField } from "@/Components/inputs/forms/FormColorField";
const ThemePickerField = () => {
const { field: themeField } = useController<StatusPageFormData, "theme">({
name: "theme",
});
const { field: modeField } = useController<StatusPageFormData, "themeMode">({
name: "themeMode",
});
return (
<ThemePicker
theme={themeField.value ?? "refined"}
themeMode={modeField.value ?? "auto"}
onThemeChange={themeField.onChange}
onThemeModeChange={modeField.onChange}
/>
);
};
const LogoUploadField = () => {
const { field } = useController<StatusPageFormData, "logo">({ name: "logo" });
return (
<ImageUpload
src={field.value?.data}
onChange={(file) => {
field.onChange(
file ? { data: file.src, contentType: file.file.type, file: file.file } : null
);
}}
/>
);
};
// Derived from SelectableMonitorTypes so a newly supported monitor type is
// offered here automatically; the previous hardcoded list silently omitted DNS.
// "hardware" is appended because infrastructure monitors are created on their
// own page and so are absent from the selectable set.
const monitorsUrl = (() => {
const params = new URLSearchParams();
[...SelectableMonitorTypes, "hardware"].forEach((type) => params.append("type", type));
["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach(
(type) => params.append("type", type)
);
return `/monitors/team?${params.toString()}`;
})();
interface TimezoneOption {
_id: string;
name: string;
}
const buildStatusPageKey = (slug: string | null | undefined) =>
slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null;
@@ -92,9 +67,7 @@ const CreateStatusPage = () => {
useGet<StatusPageResponse>(isCreate ? null : buildStatusPageKey(url));
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]);
const { data: tagsResponse } = useGet<Tag[]>("/tags/team");
const tags = useMemo(() => tagsResponse ?? [], [tagsResponse]);
const monitors = monitorsResponse ?? [];
const { post, loading: isSubmittingPost } = usePost();
const { put, loading: isSubmittingPut } = usePut();
@@ -114,7 +87,7 @@ const CreateStatusPage = () => {
defaultValues: defaults,
});
const { reset, handleSubmit, trigger } = form;
const { control, reset, handleSubmit, trigger } = form;
// Reset form when defaults change (from fetched data)
useEffect(() => {
@@ -135,7 +108,7 @@ const CreateStatusPage = () => {
};
const handleBack = () => setCurrentStep((step) => step - 1);
const watchedMonitorIds: string[] = form.watch("monitors");
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
const computedTypes: MonitorDisplayType[] = useMemo(() => {
const selectedMonitors = (watchedMonitorIds ?? [])
.map((id) => monitors.find((m) => m.id === id))
@@ -151,7 +124,7 @@ const CreateStatusPage = () => {
useEffect(() => {
form.setValue("type", computedTypes);
}, [computedTypes, form]);
}, [computedTypes]);
const onError = (errors: any) => {
logger.debug("Status page validation errors", errors);
@@ -205,8 +178,18 @@ const CreateStatusPage = () => {
if (data.logo === null) {
// Signal to remove the logo
fd.append("removeLogo", "true");
} else if (data.logo?.file) {
fd.append("logo", data.logo.file);
} else if (data.logo?.data && data.logo.data !== "") {
if (data.logo.data.startsWith("blob:")) {
try {
const imageResult = await axios.get(data.logo.data, {
responseType: "blob",
});
fd.append("logo", imageResult.data);
URL.revokeObjectURL(data.logo.data);
} catch (e) {
logger.error("Failed to fetch logo blob", e instanceof Error ? e : undefined);
}
}
}
let result;
@@ -233,249 +216,470 @@ const CreateStatusPage = () => {
return <BasePage>Loading...</BasePage>;
}
return (
<FormProvider {...form}>
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit, onError)}
>
{!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />}
{isCreate && (
<StepProgress
steps={totalSteps}
current={currentStep}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.access.title")}
subtitle={t("pages.statusPages.form.access.description")}
rightContent={
<FormSwitchField
<BasePage
component="form"
onSubmit={handleSubmit(onSubmit, onError)}
>
{!isCreate && <HeaderConfigStatusControls onDelete={handleDeleteClick} />}
{isCreate && (
<StepProgress
steps={totalSteps}
current={currentStep}
/>
)}
{showStep(0) && (
<ConfigBox
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"
label={t("pages.statusPages.form.access.option.published.name")}
control={control}
render={({ field }) => (
<SwitchComponent
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.basicInfo.title")}
subtitle={t("pages.statusPages.form.basicInfo.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormTextField
name="companyName"
fieldLabel={t("pages.statusPages.form.basicInfo.option.name.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.name.placeholder"
)}
/>
<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
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);
<FormTextField
name="url"
fieldLabel={t("pages.statusPages.form.basicInfo.option.url.label")}
placeholder={t(
"pages.statusPages.form.basicInfo.option.url.placeholder"
)}
/>
<FormTextField
name="customDomain"
fieldLabel={t(
"pages.statusPages.form.basicInfo.option.customDomain.label"
)}
placeholder={t(
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
)}
helperText={t(
"pages.statusPages.form.basicInfo.option.customDomain.helper"
)}
/>
</Stack>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.monitors.title")}
subtitle={t("pages.statusPages.form.monitors.description")}
rightContent={
<FormMultiSelectField
sortable
name="monitors"
fieldLabel={t("pages.statusPages.form.monitors.option.monitors.label")}
placeholder={t(
"pages.statusPages.form.monitors.option.monitors.placeholder"
)}
options={monitors}
renderOptionContent={(monitor) => (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
flexWrap="wrap"
>
<Typography>
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
</Typography>
{monitor.tags.map((tagId) => {
const tag = tags.find(({ id }) => id === tagId);
return tag ? (
<ColoredLabel
key={tag.id}
text={tag.name}
color={tag.color}
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}
/>
) : null;
})}
)}
/>
{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>
)}
renderRow={(monitor) => (
<Typography flexGrow={1}>
{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}
</Typography>
)}
/>
}
/>
)}
{showStep(0) && (
<ConfigBox
title={t("pages.statusPages.form.timezone.title")}
subtitle={t("pages.statusPages.form.timezone.description")}
rightContent={
<FormAutocompleteField
name="timezone"
options={timezoneOptions}
fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")}
placeholder={t(
"pages.statusPages.form.timezone.option.timezone.placeholder"
)}
/>
}
/>
)}
{showStep(1) && (
<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"}>
<LogoUploadField />
</Stack>
<FormColorField
name="color"
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={<ThemePickerField />}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.customCSS.title")}
subtitle={t("pages.statusPages.form.customCSS.description")}
rightContent={
<FormTextField
name="customCSS"
multiline
rows={8}
fieldLabel={t("pages.statusPages.form.customCSS.option.customCSS.label")}
placeholder={t(
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<FormCheckboxField
name="showCharts"
label={t("pages.statusPages.form.features.option.showCharts.label")}
/>
<FormCheckboxField
name="showInfrastructure"
label={t(
"pages.statusPages.form.features.option.showInfrastructure.label"
);
}}
/>
}
/>
)}
{showStep(0) && (
<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"
)}
/>
)}
/>
{/*
<FormCheckboxField
name="showUptimePercentage"
)}
/>
}
/>
)}
{showStep(1) && (
<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>
}
/>
)}
{showStep(1) && (
<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}
/>
)}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.customCSS.title")}
subtitle={t("pages.statusPages.form.customCSS.description")}
rightContent={
<Controller
name="customCSS"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
multiline
rows={8}
fieldLabel={t(
"pages.statusPages.form.customCSS.option.customCSS.label"
)}
placeholder={t(
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<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"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t(
"pages.statusPages.form.features.option.showUptimePercentage.label"
)}
/>
<FormCheckboxField
name="showAdminLoginLink"
)}
/>
<Controller
name="showAdminLoginLink"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t(
"pages.statusPages.form.features.option.showAdminLoginLink.label"
)}
/> */}
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent={isCreate ? "space-between" : "flex-end"}
>
{isCreate && (
<Button
type="button"
variant="outlined"
color="secondary"
disabled={currentStep === 0}
onClick={handleBack}
>
{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>
<Dialog
open={isDeleteDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
loading={isDeleting}
/>
)}
/> */}
</Stack>
}
/>
</BasePage>
</FormProvider>
)}
<Stack
direction="row"
justifyContent={isCreate ? "space-between" : "flex-end"}
>
{isCreate && (
<Button
type="button"
variant="outlined"
color="secondary"
disabled={currentStep === 0}
onClick={handleBack}
>
{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>
<Dialog
open={isDeleteDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
loading={isDeleting}
/>
</BasePage>
);
};
+3 -34
View File
@@ -1,6 +1,6 @@
import { BasePage, BaseFallback } from "@/Components/design-elements";
import Typography from "@mui/material/Typography";
import { Link, useSearchParams } from "react-router-dom";
import { Link } from "react-router-dom";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
@@ -9,11 +9,7 @@ import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useLocation, useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import {
isStatusPageRange,
resolveStatusPageTheme,
type StatusPageRange,
} from "@/Types/StatusPage";
import { resolveStatusPageTheme } from "@/Types/StatusPage";
import {
PUBLIC_STATUS_PAGE_PREFIX,
type StatusPageResponse,
@@ -25,7 +21,6 @@ import {
getStatusPagePublicUrl,
isCustomDomainHost,
} from "@/Utils/statusPageUrl";
import { cssReferencesExternalResource } from "@/Utils/customCss";
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
import {
@@ -84,37 +79,21 @@ const StatusPageView = () => {
const { url } = useParams();
const isAdmin = useIsAdmin();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const onRangeChange = (next: StatusPageRange) => {
const updated = new URLSearchParams(searchParams);
if (next === "latest") {
updated.delete("range");
} else {
updated.set("range", next);
}
setSearchParams(updated, { replace: true });
};
const onCustomDomainHost = isCustomDomainHost();
const isPublic =
onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const rawRange = searchParams.get("range");
const range = isStatusPageRange(rawRange) ? rawRange : "latest";
const apiUrl = buildStatusPageApiPath({
url,
useCustomDomain: onCustomDomainHost,
range,
});
const { data, isLoading, error } = useGet<StatusPageResponse>(
apiUrl,
{},
{
keepPreviousData: true,
refreshInterval: range === "latest" ? 10000 : 60000,
refreshInterval: 10000,
}
);
@@ -153,27 +132,17 @@ const StatusPageView = () => {
// Public route: render directly on the viewport, themed background covers everything.
if (isPublic) {
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const customCss =
statusPage.customCSS && !cssReferencesExternalResource(statusPage.customCSS)
? statusPage.customCSS
: "";
return (
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
brandColor={statusPage.color}
paintBody
>
{customCss && <style>{customCss}</style>}
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
range={range}
onRangeChange={onRangeChange}
bucketTimezone={data.bucketTimezone ?? "Etc/UTC"}
checkTTLDays={data.checkTTLDays}
/>
</StatusPageThemeProvider>
);
@@ -6,7 +6,6 @@ import {
useState,
type ReactNode,
} from "react";
import { alpha, darken } from "@mui/material/styles";
import {
DEFAULT_STATUS_PAGE_THEME,
resolveStatusPageTheme,
@@ -37,15 +36,6 @@ interface Props {
theme?: StatusPageTheme;
themeMode?: StatusPageThemeMode;
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
* covers beyond the provider's wrapper (useful on the public route
@@ -64,32 +54,10 @@ interface Props {
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 = ({
theme,
themeMode,
timezone,
brandColor,
paintBody = false,
transparent = false,
children,
@@ -141,11 +109,7 @@ export const StatusPageThemeProvider = ({
const resolvedMode: ResolvedMode =
resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
const baseTokens = themeTokens[resolvedTheme][resolvedMode];
const tokens = useMemo(
() => resolveBrandTokens(baseTokens, brandColor),
[baseTokens, brandColor]
);
const tokens = themeTokens[resolvedTheme][resolvedMode];
const value = useMemo(
() => ({
@@ -1,13 +1,11 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
export type BoldHeatCell = HeatCellKind;
export type BoldBarKind = BarKind;
export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type BoldBarKind = "up" | "down" | "empty";
export type BoldGaugeFill = "ok" | "warm" | "hot";
export interface BoldStyles {
@@ -34,9 +32,9 @@ export interface BoldStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -57,7 +55,6 @@ export const boldStyles = (
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`,
slow: tokens.warn,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -65,14 +62,12 @@ export const boldStyles = (
fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`,
degraded: `0 0 6px color-mix(in srgb, ${tokens.degraded} 20%, transparent)`,
down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`,
empty: "none",
};
const barBg: Record<BoldBarKind, string> = {
up: tokens.up,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -327,12 +322,9 @@ export const boldStyles = (
gap: { xs: "1px", md: "3px" },
height: 48,
},
heatmapCell: (kind, severity = 1) => ({
heatmapCell: (kind) => ({
borderRadius: "3px",
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
background: heatCellBg[kind],
boxShadow: heatCellShadow[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
@@ -347,11 +339,8 @@ export const boldStyles = (
alignItems: "flex-end",
height: 48,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "3px",
minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1,
@@ -1,5 +1,5 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import {
EDITORIAL_SECONDARY_SANS_STACK,
@@ -8,10 +8,8 @@ import {
} from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
export type EditorialHeatCell = HeatCellKind;
export type EditorialBarKind = BarKind;
export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type EditorialBarKind = "up" | "down" | "empty";
export type EditorialGaugeFill = "ok" | "warm" | "hot";
export interface EditorialStyles {
@@ -38,9 +36,9 @@ export interface EditorialStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -61,14 +59,12 @@ export const editorialStyles = (
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
slow: tokens.warn,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
const barBg: Record<EditorialBarKind, string> = {
up: tokens.up,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -272,11 +268,8 @@ export const editorialStyles = (
gap: { xs: "1px", md: "2px" },
height: 40,
},
heatmapCell: (kind, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
heatmapCell: (kind) => ({
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.5 : 1,
}),
@@ -288,11 +281,8 @@ export const editorialStyles = (
alignItems: "flex-end",
height: 40,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
minHeight: 3,
opacity: kind === "empty" ? 0.5 : 1,
height: `${heightPct}%`,
@@ -1,14 +1,12 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { keyframes } from "@mui/system";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
export type ModernHeatCell = HeatCellKind;
export type ModernBarKind = BarKind;
export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type ModernBarKind = "up" | "down" | "empty";
export type ModernGaugeFill = "ok" | "warm" | "hot";
export interface ModernStyles {
@@ -37,9 +35,9 @@ export interface ModernStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: ModernHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -83,20 +81,17 @@ export const modernStyles = (
): ModernStyles => {
const cardShadow = isDark ? darkShadow : lightShadow;
const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
const darken = (color: string) => `color-mix(in srgb, ${color} 70%, #000000 30%)`;
const heatCellBg: Record<ModernHeatCell, string> = {
fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
degraded: `linear-gradient(180deg, ${tokens.degraded}, ${darken(tokens.degraded)})`,
down: `linear-gradient(180deg, ${tokens.down}, ${darken(tokens.down)})`,
down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
empty: tokens.border,
};
const barBg: Record<ModernBarKind, string> = {
up: tokens.up,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -104,7 +99,7 @@ export const modernStyles = (
const gaugeFillBg: Record<ModernGaugeFill, string> = {
ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`,
warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`,
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down}, ${darken(tokens.down)})`,
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
};
return {
@@ -355,19 +350,13 @@ export const modernStyles = (
gap: { xs: "1px", md: "3px" },
height: 46,
},
heatmapCell: (kind, severity = 1) => {
const mixed = mixSeverityColor(tokens.up, tokens.degraded, severity);
return {
borderRadius: "3px",
background:
kind === "degraded"
? `linear-gradient(180deg, ${mixed}, ${darken(mixed)})`
: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.2)" },
};
},
heatmapCell: (kind) => ({
borderRadius: "3px",
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.2)" },
}),
histogram: {
padding: { xs: "0 12px", md: "0 24px" },
@@ -377,11 +366,8 @@ export const modernStyles = (
alignItems: "flex-end",
height: 46,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "3px",
minHeight: 4,
opacity: kind === "empty" ? 0.4 : 1,
@@ -1,12 +1,10 @@
import type { SxProps, Theme } from "@mui/material/styles";
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
import type { BarKind, HeatCellKind } from "../shared/ChartCells";
export type RefinedHeatCell = HeatCellKind;
export type RefinedBarKind = BarKind;
export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type RefinedBarKind = "up" | "down" | "empty";
export type RefinedGaugeFill = "ok" | "warm" | "hot";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
@@ -37,9 +35,9 @@ export interface RefinedStyles {
monitorUrl: SxProps<Theme>;
badge: (tone: OverallTone) => SxProps<Theme>;
heatmap: SxProps<Theme>;
heatmapCell: (kind: RefinedHeatCell, severity?: number) => SxProps<Theme>;
heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
histogram: SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
chartStats: SxProps<Theme>;
infra: SxProps<Theme>;
infraEmpty: SxProps<Theme>;
@@ -73,14 +71,12 @@ export const refinedStyles = (
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
slow: tokens.warn,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
const barBg: Record<RefinedBarKind, string> = {
up: tokens.up,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -123,7 +119,7 @@ export const refinedStyles = (
width: 28,
height: 28,
borderRadius: "8px",
background: tokens.brand,
background: tokens.up,
display: "grid",
placeItems: "center",
color: "#fff",
@@ -195,16 +191,16 @@ export const refinedStyles = (
whiteSpace: "nowrap",
textAlign: "center",
border: 0,
background: active ? tokens.brandSoft : "transparent",
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
fontSize: 11,
padding: { xs: "5px 6px", md: "5px 14px" },
cursor: "pointer",
color: active ? tokens.brand : tokens.textMuted,
color: active ? tokens.up : tokens.textMuted,
borderRadius: "5px",
transition: "background 0.15s ease, color 0.15s ease",
fontWeight: active ? 600 : 500,
"&:hover": { color: active ? tokens.brand : tokens.text },
"&:hover": { color: active ? tokens.up : tokens.text },
}),
monitorList: {
@@ -263,9 +259,9 @@ export const refinedStyles = (
},
pillHardware: {
...pillBase,
color: tokens.brand,
color: tokens.up,
border: `1px solid ${tokens.border}`,
background: tokens.brandSoft,
background: tokens.upSoft,
},
monitorUrl: {
fontSize: 12,
@@ -296,12 +292,9 @@ export const refinedStyles = (
gap: { xs: "1px", md: "3px" },
height: 42,
},
heatmapCell: (kind, severity = 1) => ({
heatmapCell: (kind) => ({
borderRadius: "2px",
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: heatCellBg[kind],
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
transition: "transform 0.15s",
"&:hover": { transform: "scaleY(1.15)" },
@@ -315,11 +308,8 @@ export const refinedStyles = (
alignItems: "flex-end",
height: 42,
},
bar: (kind, heightPct, severity = 1) => ({
background:
kind === "degraded"
? mixSeverityColor(tokens.up, tokens.degraded, severity)
: barBg[kind],
bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "2px",
minHeight: 3,
opacity: kind === "empty" ? 0.4 : 1,
@@ -392,11 +382,11 @@ export const refinedStyles = (
fontSize: 12,
mt: "40px",
"& a": {
color: tokens.brand,
color: tokens.up,
textDecoration: "underline",
textUnderlineOffset: "3px",
fontWeight: 600,
"&:hover": { color: tokens.brandStrong },
"&:hover": { color: tokens.upStrong || tokens.up },
},
},
};
Loaded 100 of 461 files, more files were not shown because too many files have changed in this diff. Show more